From 2d9aab555ce8178611417a256a634d4c74580a45 Mon Sep 17 00:00:00 2001 From: ld Date: Tue, 8 Sep 2026 11:29:31 +0800 Subject: [PATCH 01/12] feat: support CPA carpool participants, quotas and request access --- README.md | 2 +- backend/monitor/access.py | 8 +- .../monitor/accounting/dynamic_attribution.py | 11 + backend/monitor/accounting/model_inputs.py | 10 +- backend/monitor/accounting/replay.py | 17 +- backend/monitor/api_usage.py | 2 +- backend/monitor/cpa/monitoring.py | 2 + backend/monitor/cpa/participants.py | 456 ++++++++++++ backend/monitor/cpa/reporting.py | 239 +++++++ backend/monitor/engine.py | 1 + .../migrations/0052_cpa_participants.py | 87 +++ .../migrations/0053_cpa_claim_events.py | 27 + backend/monitor/models/__init__.py | 7 + backend/monitor/models/cpa_participants.py | 94 +++ backend/monitor/models/observations.py | 1 + backend/monitor/models/participants.py | 4 +- backend/monitor/openapi/accounts.py | 1 + backend/monitor/openapi/cpa.py | 187 +++++ backend/monitor/openapi/document.py | 4 +- backend/monitor/openapi/participants.py | 8 +- backend/monitor/openapi/statistics.py | 1 + backend/monitor/reporting/recommendations.py | 25 +- backend/monitor/reporting/usage.py | 6 +- backend/monitor/serializers/participants.py | 28 +- .../tests/api/test_cpa_participants.py | 652 ++++++++++++++++++ .../monitor/tests/api/test_readonly_api.py | 3 + .../migrations/test_0052_cpa_participants.py | 64 ++ backend/monitor/urls.py | 13 + backend/monitor/views/cpa_participants.py | 323 +++++++++ backend/monitor/views/dashboard.py | 7 +- backend/monitor/views/participants.py | 37 +- backend/monitor/views/readonly_api.py | 2 + backend/monitor/views/settings.py | 2 +- backend/monitor/views/statistics.py | 3 + docs/architecture.md | 4 +- docs/cpa-carpool.md | 100 +++ docs/statistics.md | 2 +- .../src/components/common/CPAPoolCard.vue | 221 ++++++ frontend/src/demo/backend.ts | 2 + frontend/src/demo/cpa.ts | 516 ++++++++++++++ frontend/src/demo/fixtures.ts | 23 +- frontend/src/demo/handlers/dashboard.ts | 98 ++- frontend/src/demo/handlers/observations.ts | 48 +- frontend/src/demo/handlers/participants.ts | 12 +- frontend/src/demo/handlers/reporting.ts | 47 +- frontend/src/demo/selectors.ts | 2 +- frontend/src/demo/state.ts | 11 +- frontend/src/demo/trajectoryFixtures.ts | 3 +- frontend/src/types/cpa.ts | 124 ++++ frontend/src/types/dashboard.ts | 2 + frontend/src/types/participants.ts | 11 +- frontend/src/types/statistics.ts | 2 + frontend/src/views/index/Index.vue | 2 + .../general/components/CPAConnectionCard.vue | 3 +- .../src/views/statistics/StatisticsView.vue | 7 + .../statistics/components/CPARequestsCard.vue | 216 ++++++ .../components/ParticipantUsageCard.vue | 6 +- .../views/users/allocation/AllocationView.vue | 22 +- .../users/participants/ParticipantsView.vue | 122 +++- .../participants/components/CPAKeyManager.vue | 397 +++++++++++ .../components/ParticipantEditorDialog.vue | 23 +- .../src/views/users/participants/types.ts | 2 +- scripts/cpa_browser_smoke.py | 310 +++++++++ 63 files changed, 4524 insertions(+), 148 deletions(-) create mode 100644 backend/monitor/cpa/participants.py create mode 100644 backend/monitor/cpa/reporting.py create mode 100644 backend/monitor/migrations/0052_cpa_participants.py create mode 100644 backend/monitor/migrations/0053_cpa_claim_events.py create mode 100644 backend/monitor/models/cpa_participants.py create mode 100644 backend/monitor/openapi/cpa.py create mode 100644 backend/monitor/tests/api/test_cpa_participants.py create mode 100644 backend/monitor/tests/migrations/test_0052_cpa_participants.py create mode 100644 backend/monitor/views/cpa_participants.py create mode 100644 docs/cpa-carpool.md create mode 100644 frontend/src/components/common/CPAPoolCard.vue create mode 100644 frontend/src/demo/cpa.ts create mode 100644 frontend/src/types/cpa.ts create mode 100644 frontend/src/views/statistics/components/CPARequestsCard.vue create mode 100644 frontend/src/views/users/participants/components/CPAKeyManager.vue create mode 100644 scripts/cpa_browser_smoke.py diff --git a/README.md b/README.md index 37e384c..04cebe9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - 平均恒定模型保留透明的累计比例对照;时变额度模型使用混合粒子滤波估计连续容量、整数显示规则和参与者归属,并同时给出概率区间与确定性边界。 - 在参与者用户余额耗尽、建议余额变化、测算率变化或采集异常时发送邮件通知。 - 支持 SMTP 和 Resend 邮件服务。 -- 提供额度总览、账号状态、观测记录、粒子轨迹、额度统计、登录审计和使用教程;CPA 统计按本地哈希后的 API Key 分组,并显示未知模型的未计价请求数,不创建参与者、余额或额度建议。 +- 提供额度总览、账号状态、观测记录、粒子轨迹、额度统计、登录审计和使用教程;CPA 支持参与者绑定多个 Key、独立或多账号额度池、个人权益与同车成员用量汇总,以及本人请求明细;历史请求可预览后认领,未计价模型和采集缺口显式保留。配置见 [CPA 拼车](docs/cpa-carpool.md)。 - 管理员可为普通系统用户逐页配置只读访问权限,并精确选择其可见账号与参与者;账号范围同时约束额度总览、账号状态、观测、粒子轨迹和统计,所有页面写操作仍仅限管理员。系统设置页无需授权且不可关闭,普通用户在其中只会看到自己的 API Key 卡片。 - 提供内置 OpenAPI 3.1 文档的业务 API;管理员全局 Bearer Key 拥有全部已开放权限并可一键设置建议余额,普通系统用户可在系统设置页生成绑定自身的 Key,其端点发现、OpenAPI 文档和业务响应实时继承可配置页面、账号与参与者权限,且不能执行管理写操作。 - 记录服务端来源 IP,并可选记录浏览器通过 WebRTC 上报的辅助地址。 diff --git a/backend/monitor/access.py b/backend/monitor/access.py index 720eb98..d68f784 100644 --- a/backend/monitor/access.py +++ b/backend/monitor/access.py @@ -2,7 +2,7 @@ from collections.abc import Iterable -from django.db.models import QuerySet +from django.db.models import Q, QuerySet from rest_framework.permissions import BasePermission from .models import ( @@ -86,13 +86,15 @@ def visible_accounts_for(user, queryset: QuerySet | None = None) -> QuerySet: accounts = queryset if queryset is not None else MonitoredAccount.objects.all() if user.is_staff: return accounts - return accounts.filter(authorized_users=user) + return accounts.filter(authorized_users=user).filter( + Q(provider="sub2api") | Q(pool__allocations__participant__authorized_users=user) + ).distinct() def visible_account_ids(user) -> set[int] | None: if user.is_staff: return None - return set(user.visible_monitored_accounts.values_list("id", flat=True)) + return set(visible_accounts_for(user).values_list("id", flat=True)) def scope_participant_data( diff --git a/backend/monitor/accounting/dynamic_attribution.py b/backend/monitor/accounting/dynamic_attribution.py index cf63059..ece7073 100644 --- a/backend/monitor/accounting/dynamic_attribution.py +++ b/backend/monitor/accounting/dynamic_attribution.py @@ -543,6 +543,17 @@ def replay_dynamic_segment( snapshot.recommendation_applied = False snapshot.needs_manual_update = needs_update snapshot.reason = reason + if account_id < 0: + snapshot.current_balance_usd = None + snapshot.recommended_balance_usd = None + snapshot.recommended_balance_min_usd = None + snapshot.recommended_balance_max_usd = None + snapshot.deterministic_balance_min_usd = None + snapshot.deterministic_balance_max_usd = None + snapshot.balance_difference_usd = None + snapshot.needs_manual_update = False + snapshot.recommendation_applied = False + snapshot.reason = "CPA 本地估算权益" if snapshot.cpa_contract_known else "缺少历史份额,剩余权益未知" if snapshots: ParticipantSnapshot.objects.bulk_update( snapshots, diff --git a/backend/monitor/accounting/model_inputs.py b/backend/monitor/accounting/model_inputs.py index 263eda4..d4e4ba8 100644 --- a/backend/monitor/accounting/model_inputs.py +++ b/backend/monitor/accounting/model_inputs.py @@ -74,23 +74,25 @@ def build_dynamic_replay_input( user_ids.add(row.sub2api_user_id) participant_by_id = {} + source_by_participant = {} share_by_participant_id = {} snapshots_by_observation: list[dict[int, object]] = [] for observation in observations: by_user = {} for snapshot in observation.participant_snapshots.all(): participant = snapshot.participant + source_by_participant[participant.id] = participant.id if account_id < 0 else (participant.sub2api_user_id if participant.sub2api_user_id is not None else snapshot.source_sub2api_user_id) participant_by_id[participant.id] = participant share_by_participant_id[participant.id] = snapshot.share_percent - user_ids.add(participant.sub2api_user_id) - by_user[participant.sub2api_user_id] = snapshot + user_ids.add(source_by_participant[participant.id]) + by_user[source_by_participant[participant.id]] = snapshot snapshots_by_observation.append(by_user) ordered_users = tuple(sorted(user_ids)) subject_user_ids: tuple[int | None, ...] = (*ordered_users, RESIDUAL_SUBJECT) user_index = {user_id: index for index, user_id in enumerate(ordered_users)} participant_subject_indices = { - participant_id: user_index[participant.sub2api_user_id] + participant_id: user_index[source_by_participant[participant.id]] for participant_id, participant in participant_by_id.items() } rights = np.zeros(len(subject_user_ids), dtype=float) @@ -114,7 +116,7 @@ def build_dynamic_replay_input( participant = participant_by_id.get(participant_id) if participant is not None: baseline_by_user.setdefault( - participant.sub2api_user_id, + source_by_participant[participant.id], baseline, ) diff --git a/backend/monitor/accounting/replay.py b/backend/monitor/accounting/replay.py index 870915f..7f3a166 100644 --- a/backend/monitor/accounting/replay.py +++ b/backend/monitor/accounting/replay.py @@ -88,7 +88,9 @@ def _replay_usage_samples( observed_at__in=[sample.observed_at for sample in samples], ) } + historical_sources = {row.participant_id: row.source_sub2api_user_id for row in ParticipantSnapshot.objects.filter(observation__account_id=account_id).order_by("observation__observed_at", "id")} for sample in samples: + source_id = sample.participant_id if account_id < 0 else (sample.participant.sub2api_user_id if sample.participant.sub2api_user_id is not None else historical_sources.get(sample.participant_id)) segment = None for candidate in segments: if candidate.first_observed_at <= sample.observed_at: @@ -101,7 +103,7 @@ def _replay_usage_samples( continue sample.attribution_started_at = segment.started_at raw_cost = normalized_by_key.get( - (sample.participant.sub2api_user_id, sample.observed_at), + (source_id, sample.observed_at), sample.raw_selected_cost, ) baseline = segment.participant_baselines.get( @@ -115,7 +117,7 @@ def _replay_usage_samples( }: baseline = normalized_by_key.get( ( - sample.participant.sub2api_user_id, + source_id, segment.first_observed_at, ), baseline, @@ -125,7 +127,7 @@ def _replay_usage_samples( raw_cost - baseline + correction_prefix.user_between( - sample.participant.sub2api_user_id, + source_id, segment.started_at, sample.observed_at, ), @@ -151,7 +153,7 @@ def _update_participant_latest(account_id: int) -> None: return snapshots = list(latest.participant_snapshots.all()) participant_ids = [snapshot.participant_id for snapshot in snapshots] - account = MonitoredAccount.objects.filter(external_account_id=account_id).first() + account = MonitoredAccount.for_fact_key(account_id) memberships = ( { item.participant_id: item @@ -177,10 +179,14 @@ def _update_participant_latest(account_id: int) -> None: ["latest_selected_cost", "last_checked_at"], ) + if account_id < 0: + return + latest_balance_sample = ParticipantBalanceSample.objects.filter( participant_id=OuterRef("pk"), ).order_by("-captured_at", "-id") latest_snapshot = ParticipantSnapshot.objects.filter( + observation__account_id__gt=0, participant_id=OuterRef("pk"), observation__excluded_at__isnull=True, ).order_by("-observation__observed_at", "-id") @@ -343,6 +349,9 @@ def rebuild_account( CPAAccountCollectionInterval.objects.filter(account=monitored_account) .order_by("connected_at", "id") ) + if monitored_account is not None and monitored_account.provider == "cpa": + from ..cpa.participants import materialize_participants + materialize_participants(monitored_account, config) all_observations = list( Observation.objects.select_for_update() .select_related("sample_point", "manual_start_end") diff --git a/backend/monitor/api_usage.py b/backend/monitor/api_usage.py index e8362fd..d821ab5 100644 --- a/backend/monitor/api_usage.py +++ b/backend/monitor/api_usage.py @@ -208,7 +208,7 @@ def refresh_due_api_usage_snapshots(config: AppSettings) -> dict[str, int]: observation = latest_cycle_observation(account) if observation is None: continue - participants = Participant.objects.filter(enabled=True) + participants = Participant.objects.filter(enabled=True, sub2api_user_id__isnull=False) for participant in participants: if fresh_snapshot( participant=participant, diff --git a/backend/monitor/cpa/monitoring.py b/backend/monitor/cpa/monitoring.py index f70505e..8f88bde 100644 --- a/backend/monitor/cpa/monitoring.py +++ b/backend/monitor/cpa/monitoring.py @@ -64,6 +64,8 @@ def _persist_cpa_capture( interval_started_at = cost_started_at interval_cost = total_cost + from .participants import record_contract + record_contract(account, timezone.now()) point = UsageSamplePoint.objects.create( account_id=account.fact_key, observed_at=observed_at, diff --git a/backend/monitor/cpa/participants.py b/backend/monitor/cpa/participants.py new file mode 100644 index 0000000..cccecf5 --- /dev/null +++ b/backend/monitor/cpa/participants.py @@ -0,0 +1,456 @@ +"""Time-scoped CPA ownership, explicit claims, and account projections.""" + +from bisect import bisect_left, bisect_right +from collections import defaultdict +from datetime import timedelta +from decimal import Decimal + +from django.db.models import Q +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from ..fact_utils import canonical_digest +from ..history_state import fenced_fact_write +from ..models import ( + AccountParticipant, + AppSettings, + CPAAPIKey, + CPAClaimPlan, + CPAClaimEvent, + CPAKeyBinding, + CPAQuotaContract, + CPAUsageEvent, + MonitoredAccount, + Observation, + ParticipantSnapshot, + ParticipantUsageSample, +) +from .usage import _api_key_identity, cpa_event_cost + +ZERO = Decimal("0") + + +def ownership_filter(participant_ids): + """Filter events by immutable ownership intervals, never by current owner.""" + result = Q(pk__in=[]) + for binding in CPAKeyBinding.objects.filter( + participant_id__in=participant_ids + ).select_related("key"): + if binding.claim_id is not None: + result |= Q(ownership_claim__plan_id=binding.claim_id) + continue + condition = Q( + api_key_hash=binding.key.key_hash, occurred_at__gte=binding.started_at + ) + if binding.ended_at is not None: + condition &= Q(occurred_at__lt=binding.ended_at) + result |= condition + return result + + +def owner_index(): + result = defaultdict(list) + claimed = defaultdict(set) + for plan_id, event_id in CPAClaimEvent.objects.values_list("plan_id", "event_id"): + claimed[plan_id].add(event_id) + for binding in CPAKeyBinding.objects.select_related("key").order_by( + "started_at", "id" + ): + binding.claimed_event_ids = ( + claimed[binding.claim_id] if binding.claim_id else None + ) + result[binding.key.key_hash].append(binding) + return result + + +def event_owner(event, bindings): + for binding in reversed(bindings.get(event.api_key_hash, ())): + if ( + binding.claimed_event_ids is not None + and event.pk not in binding.claimed_event_ids + ): + continue + if binding.started_at <= event.occurred_at and ( + binding.ended_at is None or event.occurred_at < binding.ended_at + ): + return binding.participant_id + return None + + +def cpa_accounts(): + return MonitoredAccount.objects.filter(provider="cpa").order_by("id") + + +def register_key(*, raw_key="", observed_hash="", name=""): + if raw_key: + digest, hint = _api_key_identity(raw_key) + else: + existing = CPAAPIKey.objects.filter(key_hash=observed_hash).first() + if existing is not None: + if name and existing.name != name: + existing.name = name + existing.save(update_fields=["name"]) + return existing + event = ( + CPAUsageEvent.objects.filter(api_key_hash=observed_hash) + .exclude(api_key_hash="") + .first() + ) + if event is None: + raise ValidationError("该 Key 尚未采集,请输入完整 Key 预先绑定") + digest, hint = event.api_key_hash, event.api_key_hint + if not digest: + raise ValidationError("Key 不能为空") + key, _ = CPAAPIKey.objects.get_or_create( + key_hash=digest, defaults={"hint": hint, "name": name} + ) + if name and key.name != name: + key.name = name + key.save(update_fields=["name"]) + return key + + +def assert_available(key, started_at, ended_at=None, claiming_participant_id=None): + rows = key.bindings.filter(Q(ended_at__isnull=True) | Q(ended_at__gt=started_at)) + if ended_at is not None: + rows = rows.filter(started_at__lt=ended_at) + if claiming_participant_id is not None: + rows = rows.exclude(claim__isnull=False, participant_id=claiming_participant_id) + if rows.exists(): + raise ValidationError("此时间范围已有 Key 归属,请调整范围或先解绑当前归属") + + +def bind_key(*, participant, user, raw_key="", observed_hash="", name=""): + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + key = register_key(raw_key=raw_key, observed_hash=observed_hash, name=name) + started_at = timezone.now() + assert_available(key, started_at) + return CPAKeyBinding.objects.create( + key=key, participant=participant, started_at=started_at, created_by=user + ) + + +def unbind_key(binding_id): + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + binding = CPAKeyBinding.objects.select_for_update().get(pk=binding_id) + if binding.ended_at is None: + binding.ended_at = max( + timezone.now(), binding.started_at + timedelta(microseconds=1) + ) + binding.save(update_fields=["ended_at"]) + return binding + + +def coverage_data(account, started_at, ended_at): + intervals = list( + account.cpa_collection_intervals.filter(connected_at__lte=ended_at) + .filter(Q(disconnected_at__isnull=True) | Q(disconnected_at__gte=started_at)) + .order_by("connected_at", "id") + ) + cursor = started_at + gaps = [] + for interval in intervals: + if interval.connected_at > cursor: + gaps.append( + { + "started_at": cursor.isoformat(), + "ended_at": min(interval.connected_at, ended_at).isoformat(), + } + ) + cursor = max(cursor, min(interval.disconnected_at or ended_at, ended_at)) + if cursor < ended_at: + gaps.append( + {"started_at": cursor.isoformat(), "ended_at": ended_at.isoformat()} + ) + uncertain_end = any( + row.disconnected_at is not None + and not row.end_reliable + and started_at < row.disconnected_at <= ended_at + for row in intervals + ) + return { + "complete": not gaps and not uncertain_end, + "gaps": gaps, + "uncertain_end": uncertain_end, + } + + +def _claim_events(key, start, end): + return CPAUsageEvent.objects.filter( + api_key_hash=key.key_hash, + occurred_at__gte=start, + occurred_at__lt=end, + ownership_claim__isnull=True, + ).order_by("id") + + +def claim_digest(key, start, end, config): + accounts = list(cpa_accounts()) + return canonical_digest( + { + "events": list(_claim_events(key, start, end).values()), + "bindings": list(key.bindings.order_by("id").values()), + "settings": AppSettings.objects.filter(pk=config.pk).values().get(), + "accounts": [(a.id, a.pool_id, a.pool.contract_revision) for a in accounts], + "contracts": list(CPAQuotaContract.objects.order_by("id").values()), + "observations": list( + Observation.objects.filter( + account_id__in=[a.fact_key for a in accounts] + ) + .order_by("id") + .values( + "id", + "observed_at", + "upstream_used_percent", + "upstream_resets_at", + "excluded_at", + "is_manual_start", + "manual_start_end_id", + ) + ), + "coverage": [ + list(a.cpa_collection_intervals.order_by("id").values()) + for a in accounts + ], + } + ) + + +def preview_claim(*, key, participant, started_at, ended_at, user): + if started_at >= ended_at or ended_at > timezone.now(): + raise ValidationError("历史范围必须为过去的有效起止时间") + with fenced_fact_write([a.fact_key for a in cpa_accounts()]): + assert_available(key, started_at, ended_at, participant.pk) + config = AppSettings.load() + groups = {} + for event in _claim_events(key, started_at, ended_at).select_related("account"): + row = groups.setdefault( + event.account_id, + { + "account_id": event.account_id, + "account_name": event.account.name, + "request_count": 0, + "token_count": 0, + "usage_usd": ZERO, + "unpriced_request_count": 0, + "coverage": coverage_data(event.account, started_at, ended_at), + }, + ) + cost, unknown = cpa_event_cost(event, config) + row["request_count"] += 1 + row["token_count"] += event.total_tokens + row["usage_usd"] += cost + row["unpriced_request_count"] += int(unknown) + if not groups: + raise ValidationError("此范围没有已采集请求") + for row in groups.values(): + row["usage_usd"] = float(row["usage_usd"]) + return CPAClaimPlan.objects.create( + key=key, + participant=participant, + started_at=started_at, + ended_at=ended_at, + created_by=user, + expires_at=timezone.now() + timedelta(minutes=15), + source_digest=claim_digest(key, started_at, ended_at, config), + preview={ + "accounts": list(groups.values()), + "historical_contract_policy": "缺少历史份额时只认领请求与消耗,剩余权益保持未知", + }, + ) + + +def apply_claim(plan_id): + from ..replay import rebuild_account + + with fenced_fact_write([a.fact_key for a in cpa_accounts()]) as guards: + plan = CPAClaimPlan.objects.select_for_update().get(pk=plan_id) + if plan.applied_at is not None: + return plan + config = AppSettings.load() + if plan.expires_at <= timezone.now() or plan.source_digest != claim_digest( + plan.key, plan.started_at, plan.ended_at, config + ): + raise ValidationError("认领预览已过期或数据已变化,请重新预览") + assert_available(plan.key, plan.started_at, plan.ended_at, plan.participant_id) + events = list( + _claim_events(plan.key, plan.started_at, plan.ended_at).values_list( + "id", "account_id" + ) + ) + CPAKeyBinding.objects.create( + key=plan.key, + participant=plan.participant, + started_at=plan.started_at, + ended_at=plan.ended_at, + created_by=plan.created_by, + claim=plan, + ) + CPAClaimEvent.objects.bulk_create( + [CPAClaimEvent(plan=plan, event_id=event_id) for event_id, _ in events], + batch_size=500, + ) + account_ids = {account_id for _, account_id in events} + for account in cpa_accounts().filter(pk__in=account_ids): + rebuild_account(account.fact_key, config, guard=guards[account.fact_key]) + plan.applied_at = timezone.now() + plan.save(update_fields=["applied_at"]) + return plan + + +def record_contract(account, effective_at): + allocations = [ + {"participant_id": row.participant_id, "share_percent": str(row.share_percent)} + for row in account.pool.allocations.order_by("participant_id") + ] + previous = account.cpa_contracts.order_by("-effective_at", "-id").first() + if ( + previous + and previous.pool_id_at_capture == account.pool_id + and previous.revision == account.pool.contract_revision + and previous.allocations == allocations + ): + return previous + return CPAQuotaContract.objects.create( + account=account, + effective_at=effective_at, + pool_id_at_capture=account.pool_id, + pool_name=account.pool.name, + revision=account.pool.contract_revision, + allocations=allocations, + ) + + +def materialize_participants(account, config): + """Rebuild participant inputs from events and frozen policy before model replay.""" + bindings = owner_index() + prefixes = defaultdict(lambda: [[], [ZERO]]) + for event in ( + CPAUsageEvent.objects.filter(account=account) + .order_by("occurred_at", "id") + .iterator(chunk_size=1000) + ): + owner = event_owner(event, bindings) + if owner is None: + continue + times, sums = prefixes[owner] + times.append(event.occurred_at) + sums.append(sums[-1] + cpa_event_cost(event, config)[0]) + contracts = list(account.cpa_contracts.order_by("effective_at", "id")) + contract_times = [row.effective_at for row in contracts] + observations = list( + Observation.objects.filter(account_id=account.fact_key).order_by( + "observed_at", "id" + ) + ) + snapshots = { + (row.observation_id, row.participant_id): row + for row in ParticipantSnapshot.objects.filter( + observation__account_id=account.fact_key + ) + } + samples = { + (row.observed_at, row.participant_id): row + for row in ParticipantUsageSample.objects.filter(account_id=account.fact_key) + } + new_snapshots, changed_snapshots, new_samples, changed_samples = [], [], [], [] + snapshot_fields = [ + "raw_selected_cost", + "share_percent", + "cpa_contract_known", + "quota_pool_id", + "quota_pool_name", + "pool_contract_revision", + ] + member_ids = set() + for observation in observations: + index = bisect_right(contract_times, observation.observed_at) - 1 + contract = contracts[index] if index >= 0 else None + shares = ( + { + row["participant_id"]: Decimal(row["share_percent"]) + for row in contract.allocations + } + if contract + else {} + ) + start = observation.cost_window_started_at or ( + observation.upstream_resets_at + - timedelta(seconds=observation.window_seconds) + ) + for participant_id in set(prefixes) | set(shares): + times, sums = prefixes[participant_id] + if participant_id not in shares and ( + not times or times[0] > observation.observed_at + ): + continue + member_ids.add(participant_id) + cost = ( + sums[bisect_right(times, observation.observed_at)] + - sums[bisect_left(times, start)] + ) + values = { + "raw_selected_cost": cost, + "share_percent": shares.get(participant_id, ZERO), + "cpa_contract_known": participant_id in shares, + "quota_pool_id": contract.pool_id_at_capture if contract else None, + "quota_pool_name": contract.pool_name if contract else "", + "pool_contract_revision": contract.revision if contract else None, + } + snapshot = snapshots.get((observation.id, participant_id)) + if snapshot is None: + new_snapshots.append( + ParticipantSnapshot( + observation=observation, + participant_id=participant_id, + selected_cost=cost, + **values, + ) + ) + elif any( + getattr(snapshot, field) != value for field, value in values.items() + ): + for field, value in values.items(): + setattr(snapshot, field, value) + changed_snapshots.append(snapshot) + sample = samples.get((observation.observed_at, participant_id)) + if sample is None: + new_samples.append( + ParticipantUsageSample( + participant_id=participant_id, + account_id=account.fact_key, + observed_at=observation.observed_at, + sample_point_id=observation.sample_point_id, + raw_selected_cost=cost, + selected_cost=cost, + balance_usd=None, + ) + ) + elif sample.raw_selected_cost != cost: + sample.raw_selected_cost = cost + changed_samples.append(sample) + ParticipantSnapshot.objects.bulk_create(new_snapshots, batch_size=500) + ParticipantSnapshot.objects.bulk_update( + changed_snapshots, snapshot_fields, batch_size=500 + ) + ParticipantUsageSample.objects.bulk_create(new_samples, batch_size=500) + ParticipantUsageSample.objects.bulk_update( + changed_samples, ["raw_selected_cost"], batch_size=500 + ) + existing = set(account.memberships.values_list("participant_id", flat=True)) + AccountParticipant.objects.bulk_create( + [ + AccountParticipant(account=account, participant_id=pk) + for pk in member_ids - existing + ], + ignore_conflicts=True, + ) + + +def has_cpa_contract_history(participant_id): + return any( + any(row["participant_id"] == participant_id for row in allocations) + for allocations in CPAQuotaContract.objects.values_list( + "allocations", flat=True + ).iterator() + ) diff --git a/backend/monitor/cpa/reporting.py b/backend/monitor/cpa/reporting.py new file mode 100644 index 0000000..6d7aae0 --- /dev/null +++ b/backend/monitor/cpa/reporting.py @@ -0,0 +1,239 @@ +"""CPA pool summaries expose peers' totals, never their keys or requests.""" + +from collections import defaultdict +from datetime import timedelta +from decimal import Decimal + +from django.utils import timezone + +from ..access import visible_accounts_for, visible_participant_ids +from ..models import ( + AppSettings, + CPAUsageEvent, + Observation, + Participant, + PoolParticipant, +) +from ..reporting.recommendations import _capacity_values +from .collector_state import get_collector_status +from .participants import coverage_data, event_owner, owner_index +from .usage import cpa_event_cost + +ZERO = Decimal("0") + + +def _totals(): + return { + "usage_usd": ZERO, + "request_count": 0, + "token_count": 0, + "unpriced_request_count": 0, + } + + +def account_summary(account, config, now, bindings): + observation = ( + Observation.objects.filter( + account_id=account.fact_key, + excluded_at__isnull=True, + attribution_started_at__isnull=False, + ) + .prefetch_related("participant_snapshots") + .order_by("-observed_at", "-id") + .first() + ) + start = ( + observation.upstream_resets_at - timedelta(seconds=observation.window_seconds) + if observation + else account.created_at + ) + summaries = defaultdict(_totals) + total = _totals() + latest_request_at = None + for event in ( + CPAUsageEvent.objects.filter( + account=account, occurred_at__gte=start, occurred_at__lte=now + ) + .order_by("occurred_at", "id") + .iterator(chunk_size=1000) + ): + latest_request_at = event.occurred_at + cost, unknown = cpa_event_cost(event, config) + for row in (summaries[event_owner(event, bindings)], total): + row["usage_usd"] += cost + row["request_count"] += 1 + row["token_count"] += event.total_tokens + row["unpriced_request_count"] += int(unknown) + coverage = coverage_data( + account, start, observation.observed_at if observation else now + ) + snapshots = ( + {row.participant_id: row for row in observation.participant_snapshots.all()} + if observation + else {} + ) + return observation, start, summaries, total, coverage, snapshots, latest_request_at + + +def pool_summary(user, account, config=None): + config = config or AppSettings.load() + now = timezone.now() + mine = visible_participant_ids(user) + accounts = list( + visible_accounts_for(user) + .filter(provider="cpa", pool_id=account.pool_id, enabled=True) + .order_by("id") + ) + allocations = list( + PoolParticipant.objects.filter(pool=account.pool) + .select_related("participant") + .order_by("participant_id") + ) + shares = {row.participant_id: row.share_percent for row in allocations} + members = {row.participant_id: row.participant for row in allocations} + if mine is not None: + for participant in Participant.objects.filter( + pk__in=mine, cpa_bindings__isnull=False + ).distinct(): + members.setdefault(participant.id, participant) + bindings = owner_index() + by_account = [] + rows = { + pk: { + "participant_id": pk, + "participant_name": member.name, + "is_self": mine is not None and pk in mine, + "share_percent": float(shares[pk]) if pk in shares else None, + **_totals(), + "expected_entitlement_usd": ZERO, + "consumed_entitlement_usd": ZERO, + "remaining_entitlement_usd": ZERO, + "quota_available": bool(accounts), + "is_overused": False, + "_remaining_upper": ZERO, + "account_breakdowns": [], + } + for pk, member in members.items() + } + unassigned = _totals() + for selected in accounts: + observation, start, totals, total, coverage, snapshots, latest_request_at = ( + account_summary(selected, config, now, bindings) + ) + observed_at = observation.observed_at if observation else None + valid = bool( + observation + and observation.valid_sample + and observation.upstream_resets_at > now + and coverage["complete"] + and observation.interval_used_percent == observation.upstream_used_percent + and total["unpriced_request_count"] == 0 + ) + by_account.append( + { + "account_id": selected.id, + "account_name": selected.name, + "selected": selected.id == account.id, + "quota_as_of": observed_at.isoformat() if observed_at else None, + "requests_as_of": latest_request_at.isoformat() + if latest_request_at + else None, + "cycle_started_at": start.isoformat(), + "resets_at": observation.upstream_resets_at.isoformat() + if observation + else None, + "coverage": coverage, + "quota_available": valid, + **{ + k: float(v) if isinstance(v, Decimal) else v + for k, v in total.items() + }, + } + ) + for owner, values in totals.items(): + if owner not in rows: + for key in unassigned: + unassigned[key] += values[key] + for pk, row in rows.items(): + values = totals[pk] + for key in values: + row[key] += values[key] + snapshot = snapshots.get(pk) + available = bool( + valid + and snapshot + and snapshot.cpa_contract_known + and pk in shares + and snapshot.quota_pool_id == selected.pool_id + and snapshot.pool_contract_revision == selected.pool.contract_revision + ) + breakdown = { + "account_id": selected.id, + "quota_available": available, + "quota_as_of": observed_at.isoformat() if observed_at else None, + "charged_percent": None, + "remaining_share_percent": None, + "usage_usd": float(values["usage_usd"]), + "estimated_capacity_usd": None, + "expected_entitlement_usd": None, + "consumed_entitlement_usd": None, + "remaining_entitlement_usd": None, + } + if available: + capacity, capacity_lo, capacity_hi, charged, charged_lo, charged_hi = ( + _capacity_values(snapshot, config) + ) + expected = shares[pk] * capacity / 100 + consumed = charged * capacity / 100 + remaining = expected - consumed + breakdown.update( + charged_percent=float(charged), + remaining_share_percent=float(max(ZERO, shares[pk] - charged)), + estimated_capacity_usd=float(capacity), + expected_entitlement_usd=float(expected), + consumed_entitlement_usd=float(consumed), + remaining_entitlement_usd=float(remaining), + ) + row["expected_entitlement_usd"] += expected + row["consumed_entitlement_usd"] += consumed + row["remaining_entitlement_usd"] += remaining + row["_remaining_upper"] += max( + (shares[pk] - spent) * cap / 100 + for spent in (charged_lo, charged_hi) + for cap in (capacity_lo, capacity_hi) + ) + else: + row["quota_available"] = False + row["account_breakdowns"].append(breakdown) + for row in rows.values(): + if not row["quota_available"]: + for key in ( + "expected_entitlement_usd", + "consumed_entitlement_usd", + "remaining_entitlement_usd", + ): + row[key] = None + row["is_overused"] = False + else: + # Mixed-pool credits offset deficits, as in existing pool accounting. + row["is_overused"] = row["_remaining_upper"] < ZERO + row.pop("_remaining_upper") + for key, value in list(row.items()): + if isinstance(value, Decimal): + row[key] = float(value) + return { + "pool_id": account.pool_id, + "pool_name": account.pool.name, + "selected_account_id": account.id, + "partial_scope": len(accounts) + != account.pool.accounts.filter(enabled=True).count(), + "accounts": by_account, + "members": list(rows.values()), + "unattributed": { + k: float(v) if isinstance(v, Decimal) else v for k, v in unassigned.items() + }, + "collector": get_collector_status(include_error=user.is_staff), + "cost_estimate": True, + "enforcement_enabled": False, + "generated_at": now.isoformat(), + } diff --git a/backend/monitor/engine.py b/backend/monitor/engine.py index b476feb..6b4c6fb 100644 --- a/backend/monitor/engine.py +++ b/backend/monitor/engine.py @@ -156,6 +156,7 @@ def _run_monitor_locked( .filter( pool_id=account.pool_id, participant__enabled=True, + participant__sub2api_user_id__isnull=False, share_percent__gt=ZERO, ) .order_by("-participant__is_owner", "participant_id") diff --git a/backend/monitor/migrations/0052_cpa_participants.py b/backend/monitor/migrations/0052_cpa_participants.py new file mode 100644 index 0000000..3026204 --- /dev/null +++ b/backend/monitor/migrations/0052_cpa_participants.py @@ -0,0 +1,87 @@ +# Generated by Django 5.2.17 on 2026-09-08 01:15 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0051_pooled_research_raw_only'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CPAAPIKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key_hash', models.CharField(max_length=64, unique=True)), + ('hint', models.CharField(max_length=4)), + ('name', models.CharField(blank=True, max_length=80)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + migrations.AddField( + model_name='participantsnapshot', + name='cpa_contract_known', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='participant', + name='sub2api_user_id', + field=models.BigIntegerField(blank=True, null=True, unique=True), + ), + migrations.CreateModel( + name='CPAClaimPlan', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('started_at', models.DateTimeField()), + ('ended_at', models.DateTimeField()), + ('source_digest', models.CharField(max_length=64)), + ('preview', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField()), + ('applied_at', models.DateTimeField(null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='monitor.cpaapikey')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cpa_claim_plans', to='monitor.participant')), + ], + ), + migrations.CreateModel( + name='CPAKeyBinding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField()), + ('ended_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('key', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bindings', to='monitor.cpaapikey')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cpa_bindings', to='monitor.participant')), + ], + options={ + 'ordering': ['started_at', 'id'], + 'indexes': [models.Index(fields=['key', 'started_at', 'ended_at'], name='monitor_cpa_key_id_d216c1_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('ended_at__isnull', True)), fields=('key',), name='one_open_cpa_key_binding'), models.CheckConstraint(condition=models.Q(('ended_at__isnull', True), ('ended_at__gt', models.F('started_at')), _connector='OR'), name='cpa_binding_positive_interval')], + }, + ), + migrations.CreateModel( + name='CPAQuotaContract', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('effective_at', models.DateTimeField()), + ('pool_id_at_capture', models.BigIntegerField()), + ('pool_name', models.CharField(max_length=160)), + ('revision', models.PositiveBigIntegerField()), + ('allocations', models.JSONField(default=list)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cpa_contracts', to='monitor.monitoredaccount')), + ], + options={ + 'ordering': ['effective_at', 'id'], + 'indexes': [models.Index(fields=['account', 'effective_at'], name='monitor_cpa_account_71a032_idx')], + }, + ), + ] diff --git a/backend/monitor/migrations/0053_cpa_claim_events.py b/backend/monitor/migrations/0053_cpa_claim_events.py new file mode 100644 index 0000000..9f114ac --- /dev/null +++ b/backend/monitor/migrations/0053_cpa_claim_events.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-09-08 01:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('monitor', '0052_cpa_participants'), + ] + + operations = [ + migrations.AddField( + model_name='cpakeybinding', + name='claim', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='monitor.cpaclaimplan'), + ), + migrations.CreateModel( + name='CPAClaimEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='ownership_claim', to='monitor.cpausageevent')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='monitor.cpaclaimplan')), + ], + ), + ] diff --git a/backend/monitor/models/__init__.py b/backend/monitor/models/__init__.py index e79dcfb..03c9098 100644 --- a/backend/monitor/models/__init__.py +++ b/backend/monitor/models/__init__.py @@ -45,6 +45,11 @@ "CPAUsageEvent", "CPAAccountCollectionInterval", "CPACollectorState", + "CPAAPIKey", + "CPAKeyBinding", + "CPAClaimPlan", + "CPAClaimEvent", + "CPAQuotaContract", "HistoricalRebuildRun", "HistoryMaintenanceState", "MonitoredAccount", @@ -76,3 +81,5 @@ ] from .research import ResearchSettings, ResearchRequestComponents, ResearchEvidenceBatch + +from .cpa_participants import CPAAPIKey, CPAKeyBinding, CPAClaimPlan, CPAClaimEvent, CPAQuotaContract diff --git a/backend/monitor/models/cpa_participants.py b/backend/monitor/models/cpa_participants.py new file mode 100644 index 0000000..7b14624 --- /dev/null +++ b/backend/monitor/models/cpa_participants.py @@ -0,0 +1,94 @@ +"""CPA identities and immutable, time-scoped ownership/contract evidence.""" + +import uuid + +from django.conf import settings +from django.db import models +from django.db.models import F, Q +from django.utils import timezone + + +class CPAAPIKey(models.Model): + key_hash = models.CharField(max_length=64, unique=True) + hint = models.CharField(max_length=4) + name = models.CharField(max_length=80, blank=True) + created_at = models.DateTimeField(default=timezone.now) + + +class CPAKeyBinding(models.Model): + key = models.ForeignKey( + CPAAPIKey, on_delete=models.PROTECT, related_name="bindings" + ) + participant = models.ForeignKey( + "Participant", on_delete=models.PROTECT, related_name="cpa_bindings" + ) + started_at = models.DateTimeField() + ended_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(default=timezone.now) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + claim = models.OneToOneField( + "CPAClaimPlan", null=True, blank=True, on_delete=models.PROTECT + ) + + class Meta: + ordering = ["started_at", "id"] + constraints = [ + models.UniqueConstraint( + fields=["key"], + condition=Q(ended_at__isnull=True), + name="one_open_cpa_key_binding", + ), + models.CheckConstraint( + condition=Q(ended_at__isnull=True) | Q(ended_at__gt=F("started_at")), + name="cpa_binding_positive_interval", + ), + ] + indexes = [models.Index(fields=["key", "started_at", "ended_at"])] + + +class CPAClaimPlan(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + key = models.ForeignKey(CPAAPIKey, on_delete=models.PROTECT) + participant = models.ForeignKey( + "Participant", on_delete=models.PROTECT, related_name="cpa_claim_plans" + ) + started_at = models.DateTimeField() + ended_at = models.DateTimeField() + source_digest = models.CharField(max_length=64) + preview = models.JSONField(default=dict) + created_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + applied_at = models.DateTimeField(null=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL + ) + + +class CPAQuotaContract(models.Model): + """Frozen per-account pool policy; never backdate today's allocations.""" + + account = models.ForeignKey( + "MonitoredAccount", on_delete=models.PROTECT, related_name="cpa_contracts" + ) + effective_at = models.DateTimeField() + pool_id_at_capture = models.BigIntegerField() + pool_name = models.CharField(max_length=160) + revision = models.PositiveBigIntegerField() + allocations = models.JSONField(default=list) + + class Meta: + ordering = ["effective_at", "id"] + indexes = [models.Index(fields=["account", "effective_at"])] + + +class CPAClaimEvent(models.Model): + """A historical claim owns only the immutable events confirmed in its preview.""" + + plan = models.ForeignKey( + CPAClaimPlan, on_delete=models.PROTECT, related_name="events" + ) + event = models.OneToOneField( + "CPAUsageEvent", on_delete=models.PROTECT, related_name="ownership_claim" + ) diff --git a/backend/monitor/models/observations.py b/backend/monitor/models/observations.py index 3becfa6..281eefd 100644 --- a/backend/monitor/models/observations.py +++ b/backend/monitor/models/observations.py @@ -202,6 +202,7 @@ class ParticipantSnapshot(models.Model): observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="participant_snapshots") participant = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name="snapshots") + cpa_contract_known = models.BooleanField(default=True) # Identity at collection time; participant bindings may change later. source_sub2api_user_id = models.BigIntegerField(null=True, blank=True) share_percent = models.DecimalField( diff --git a/backend/monitor/models/participants.py b/backend/monitor/models/participants.py index 8463f11..20f0759 100644 --- a/backend/monitor/models/participants.py +++ b/backend/monitor/models/participants.py @@ -51,11 +51,11 @@ def __str__(self) -> str: class Participant(models.Model): - """一个 Sub2API 用户;余额与身份属于整个 Sub2API 渠道。""" + """一个拼车参与者,可绑定 Sub2API 用户和多个 CPA Key。""" name = models.CharField(max_length=80) email = models.EmailField(blank=True) - sub2api_user_id = models.BigIntegerField(unique=True) + sub2api_user_id = models.BigIntegerField(unique=True, null=True, blank=True) # Sub2API 用户名随参与者关系一起缓存,避免首页为了显示名称额外访问 Admin API。 sub2api_username = models.CharField(max_length=150, blank=True) # 邮箱与用户名来自同一次 Admin 用户列表读取;用户名为空时用邮箱展示账号身份。 diff --git a/backend/monitor/openapi/accounts.py b/backend/monitor/openapi/accounts.py index a934d5d..40e44df 100644 --- a/backend/monitor/openapi/accounts.py +++ b/backend/monitor/openapi/accounts.py @@ -94,6 +94,7 @@ def account_schemas(nullable_number: dict, nullable_string: dict) -> dict: "needs_manual_update_count", ], "properties": { + "cpa_summary": {"oneOf": [{"$ref": "#/components/schemas/CPAPoolSummary"}, {"type": "null"}]}, "configured": {"type": "boolean"}, "monitoring_enabled": {"type": "boolean"}, "accounts": { diff --git a/backend/monitor/openapi/cpa.py b/backend/monitor/openapi/cpa.py new file mode 100644 index 0000000..c8db342 --- /dev/null +++ b/backend/monitor/openapi/cpa.py @@ -0,0 +1,187 @@ +"""CPA carpool read API schemas and query contract.""" + +from .common import _account_id_parameter, _success_response + + +def _object(properties): + return {"type": "object", "properties": properties, "required": list(properties)} + + +def _array(schema): + return {"type": "array", "items": schema} + + +def cpa_schemas(): + number = {"type": "number"} + integer = {"type": "integer"} + text = {"type": "string"} + boolean = {"type": "boolean"} + nullable_number = {"type": ["number", "null"]} + nullable_time = {"type": ["string", "null"], "format": "date-time"} + time = {"type": "string", "format": "date-time"} + totals = { + "usage_usd": number, + "request_count": integer, + "token_count": integer, + "unpriced_request_count": integer, + } + coverage = _object( + { + "complete": boolean, + "uncertain_end": boolean, + "gaps": _array(_object({"started_at": time, "ended_at": time})), + } + ) + breakdown = _object( + { + "account_id": integer, + "quota_available": boolean, + "quota_as_of": nullable_time, + "charged_percent": nullable_number, + "remaining_share_percent": nullable_number, + "usage_usd": number, + "estimated_capacity_usd": nullable_number, + "expected_entitlement_usd": nullable_number, + "consumed_entitlement_usd": nullable_number, + "remaining_entitlement_usd": nullable_number, + } + ) + return { + "CPAPoolSummary": _object( + { + "pool_id": integer, + "pool_name": text, + "selected_account_id": integer, + "partial_scope": boolean, + "cost_estimate": boolean, + "enforcement_enabled": boolean, + "generated_at": time, + "accounts": _array( + _object( + { + **totals, + "account_id": integer, + "account_name": text, + "selected": boolean, + "quota_as_of": nullable_time, + "requests_as_of": nullable_time, + "cycle_started_at": time, + "resets_at": nullable_time, + "coverage": coverage, + "quota_available": boolean, + } + ) + ), + "members": _array( + _object( + { + **totals, + "participant_id": integer, + "participant_name": text, + "is_self": boolean, + "share_percent": nullable_number, + "quota_available": boolean, + "is_overused": boolean, + "expected_entitlement_usd": nullable_number, + "consumed_entitlement_usd": nullable_number, + "remaining_entitlement_usd": nullable_number, + "account_breakdowns": _array(breakdown), + } + ) + ), + "unattributed": _object(totals), + "collector": { + "type": "object", + "description": "采集器状态;普通用户不返回 last_error。", + }, + } + ), + "CPARequest": _object( + { + "id": integer, + "occurred_at": time, + "request_id": text, + "api_key_hint": text, + "model": text, + "endpoint": text, + "input_tokens": integer, + "cached_input_tokens": integer, + "output_tokens": integer, + "reasoning_tokens": integer, + "total_tokens": integer, + "failed": boolean, + "latency_ms": integer, + "ttft_ms": integer, + "usage_usd": number, + "unpriced": boolean, + "requested_service_tier": text, + "response_service_tier": text, + } + ), + "CPARequests": _object( + { + "account_id": integer, + "items": _array({"$ref": "#/components/schemas/CPARequest"}), + "total": integer, + "page": integer, + "page_size": integer, + "cost_estimate": boolean, + "generated_at": time, + "keys": _array(_object({"id": integer, "name": text, "hint": text})), + "models": _array(text), + } + ), + } + + +def cpa_paths(): + def parameter(name, schema): + return {"name": name, "in": "query", "required": False, "schema": schema} + + result = {} + for suffix, schema, label in ( + ("summary", "CPAPoolSummary", "读取 CPA 同车成员汇总"), + ("requests", "CPARequests", "分页读取本人 CPA 请求"), + ): + params = [_account_id_parameter()] + params[0]["required"] = True + if suffix == "requests": + params += [ + parameter(name, spec) + for name, spec in ( + ("participant_id", {"type": "integer", "minimum": 1}), + ("key_id", {"type": "integer", "minimum": 1}), + ("model", {"type": "string"}), + ("failed", {"type": "boolean"}), + ("started_at", {"type": "string", "format": "date-time"}), + ("ended_at", {"type": "string", "format": "date-time"}), + ("page", {"type": "integer", "minimum": 1, "default": 1}), + ( + "page_size", + { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50, + }, + ), + ) + ] + result[f"/v1/cpa/{suffix}"] = { + "get": { + "summary": label, + "operationId": f"get{schema}", + "description": "按账号授权与 CPA 池成员关系限制访问。请求和 Key 分项仅本人授权参与者及管理员可读;时间范围左闭右开,默认最近 7 天,最多 90 天。", + "parameters": params, + "responses": { + "200": _success_response( + label, {"$ref": f"#/components/schemas/{schema}"} + ), + **{ + str(code): {"$ref": f"#/components/responses/{name}"} + for code, name in ((400, "BadRequest"), (401, "Unauthorized")) + }, + }, + } + } + return result diff --git a/backend/monitor/openapi/document.py b/backend/monitor/openapi/document.py index b7e1efc..dc02a99 100644 --- a/backend/monitor/openapi/document.py +++ b/backend/monitor/openapi/document.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .cpa import cpa_paths, cpa_schemas from .corrections import with_correction_schemas from .accounts import account_schemas from .common import _nullable, common_schemas, error_responses, security_schemes @@ -18,7 +19,7 @@ def openapi_document( ) -> dict: nullable_number = _nullable("number") nullable_string = _nullable("string") - paths = openapi_paths() + paths = {**openapi_paths(), **cpa_paths()} if endpoint_paths is not None: visible_paths = { "/v1", @@ -54,6 +55,7 @@ def openapi_document( "responses": error_responses(), "schemas": with_correction_schemas({ **common_schemas(), + **cpa_schemas(), **account_schemas(nullable_number, nullable_string), **participant_schemas(nullable_number, nullable_string), **observation_schemas(nullable_number, nullable_string), diff --git a/backend/monitor/openapi/participants.py b/backend/monitor/openapi/participants.py index 5626b4b..41e7761 100644 --- a/backend/monitor/openapi/participants.py +++ b/backend/monitor/openapi/participants.py @@ -46,7 +46,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "id": {"type": "integer"}, "name": {"type": "string"}, "email": {"type": "string"}, - "sub2api_user_id": {"type": "integer"}, + "sub2api_user_id": {"type": ["integer", "null"]}, "sub2api_username": {"type": "string"}, "sub2api_email": {"type": "string"}, "sub2api_identity": {"type": "string"}, @@ -80,6 +80,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "ParticipantSnapshot": { "type": "object", "required": [ + "cpa_contract_known", "participant_id", "participant_name", "quota_pool_id", @@ -110,6 +111,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "allocation_model", ], "properties": { + "cpa_contract_known": {"type": "boolean", "description": "CPA 观测是否存在当时生效的份额依据;无依据时剩余份额未知。"}, "participant_id": {"type": "integer"}, "participant_name": {"type": "string"}, "quota_pool_id": { @@ -126,7 +128,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "charged_cycle_percent": {"type": "number"}, "charged_percent_lower": nullable_number, "charged_percent_upper": nullable_number, - "remaining_share_percent": {"type": "number"}, + "remaining_share_percent": nullable_number, "current_balance_usd": nullable_number, "recommended_balance_usd": nullable_number, "recommended_balance_min_usd": nullable_number, @@ -320,7 +322,7 @@ def participant_schemas(nullable_number: dict, nullable_string: dict) -> dict: "properties": { "operation_id": {"type": "string", "format": "uuid"}, "participant_id": {"type": "integer"}, - "sub2api_user_id": {"type": "integer"}, + "sub2api_user_id": {"type": ["integer", "null"]}, "applied_balance_usd": {"type": "number"}, "account_count": {"type": "integer"}, }, diff --git a/backend/monitor/openapi/statistics.py b/backend/monitor/openapi/statistics.py index 2d0caa5..7a4a5fc 100644 --- a/backend/monitor/openapi/statistics.py +++ b/backend/monitor/openapi/statistics.py @@ -130,6 +130,7 @@ def statistics_schemas(nullable_number: dict, nullable_string: dict) -> dict: "cpa_api_key_series", ], "properties": { + "cpa_summary": {"oneOf": [{"$ref": "#/components/schemas/CPAPoolSummary"}, {"type": "null"}]}, "account": { "$ref": "#/components/schemas/MonitoredAccountSummary" }, diff --git a/backend/monitor/reporting/recommendations.py b/backend/monitor/reporting/recommendations.py index d54cb85..29b16ca 100644 --- a/backend/monitor/reporting/recommendations.py +++ b/backend/monitor/reporting/recommendations.py @@ -84,7 +84,11 @@ def snapshot_data(snapshot: ParticipantSnapshot) -> dict: charged_lower=snapshot.charged_percent_lower, charged_upper=snapshot.charged_percent_upper, ) + cpa_unknown = snapshot.observation.account_id < 0 and not snapshot.cpa_contract_known + if cpa_unknown: + overuse = {"is_overused": False, "overused_percent": ZERO, "overused_percent_min": ZERO, "overused_percent_max": ZERO} return { + "cpa_contract_known": snapshot.cpa_contract_known, "participant_id": snapshot.participant_id, "participant_name": ( snapshot.participant.name if hasattr(snapshot, "participant") else "" @@ -99,7 +103,7 @@ def snapshot_data(snapshot: ParticipantSnapshot) -> dict: ), "charged_delta_percent": float(snapshot.charged_delta_percent), "charged_cycle_percent": float(snapshot.charged_cycle_percent), - "remaining_share_percent": float(snapshot.remaining_share_percent), + "remaining_share_percent": None if cpa_unknown else float(snapshot.remaining_share_percent), "current_balance_usd": ( float(snapshot.current_balance_usd) if snapshot.current_balance_usd is not None @@ -166,11 +170,11 @@ def latest_snapshot( "participant", ).filter( observation__excluded_at__isnull=True, - source_sub2api_user_id=participant.sub2api_user_id, + **({} if account is not None and account.provider == "cpa" else {"source_sub2api_user_id": participant.sub2api_user_id, "observation__account_id__gt": 0}), ) if account is not None: snapshots = snapshots.filter( - observation__account_id=account.external_account_id + observation__account_id=account.fact_key ) return snapshots.order_by("-observation__observed_at", "-id").first() @@ -357,11 +361,12 @@ def _display_snapshot_data( snapshot: ParticipantSnapshot, config: AppSettings, ) -> dict: - if config.weekly_quota_model != "constant_average": + if snapshot.observation.account_id < 0 or config.weekly_quota_model != "constant_average": return snapshot_data(snapshot) values = _constant_average_values(snapshot, config) return { + "cpa_contract_known": snapshot.cpa_contract_known, "participant_id": snapshot.participant_id, "participant_name": snapshot.participant.name, "selected_cost": float(values["selected_cost"]), @@ -570,6 +575,7 @@ def _pooled_safety_factor( enabled=True, pool_allocations__share_percent__gt=ZERO, pool_allocations__pool__accounts__enabled=True, + pool_allocations__pool__accounts__provider="sub2api", ) .distinct() .order_by("id") @@ -582,13 +588,14 @@ def _pooled_safety_factor( candidate.pool_allocations.filter( share_percent__gt=ZERO, pool__accounts__enabled=True, + pool__accounts__provider="sub2api", ) .distinct() .values_list("pool_id", "share_percent") ) candidate_accounts = list( MonitoredAccount.objects.select_related("pool") - .filter(enabled=True, pool_id__in=candidate_allocations) + .filter(enabled=True, provider="sub2api", pool_id__in=candidate_allocations) .order_by("id") ) net = ZERO @@ -702,12 +709,15 @@ def aggregate_recommendation( config: AppSettings, ) -> tuple[dict | None, list[ParticipantSnapshot]]: """Sum the participant's current pool contracts into one global balance.""" + if participant.sub2api_user_id is None: + return None, [] allocations = list( PoolParticipant.objects.select_related("pool") .filter( participant=participant, share_percent__gt=ZERO, pool__accounts__enabled=True, + pool__accounts__provider="sub2api", ) .distinct() .order_by("pool__name", "pool_id") @@ -720,6 +730,7 @@ def aggregate_recommendation( .filter( enabled=True, pool_id__in=allocation_by_pool_id, + provider="sub2api", ) .order_by("pool__name", "pool_id", "name", "external_account_id") ) @@ -995,7 +1006,7 @@ def participant_data( allocation.pool_id: allocation for allocation in allocations } accounts = list( - MonitoredAccount.objects.select_related("pool").order_by( + MonitoredAccount.objects.filter(provider="sub2api").select_related("pool").order_by( "pool__name", "pool_id", "name", @@ -1022,7 +1033,7 @@ def participant_data( "sub2api_identity": ( participant.sub2api_username or participant.sub2api_email - or f"账号 {participant.sub2api_user_id}" + or (f"账号 {participant.sub2api_user_id}" if participant.sub2api_user_id is not None else "仅 CPA") ), "pool_allocations": [ { diff --git a/backend/monitor/reporting/usage.py b/backend/monitor/reporting/usage.py index 3778333..7982936 100644 --- a/backend/monitor/reporting/usage.py +++ b/backend/monitor/reporting/usage.py @@ -26,7 +26,7 @@ def participant_usage_series( usage_precision: str, ) -> list[dict]: """按调用者可见范围和请求粒度生成参与者用量序列。""" - participants = Participant.objects.all() + participants = Participant.objects.filter(sub2api_user_id__isnull=False) if not user.is_staff: participants = participants.filter(authorized_users=user) @@ -87,12 +87,16 @@ def cpa_api_key_usage_series( now: datetime, usage_days: int, usage_precision: str, + user=None, ) -> list[dict]: events = CPAUsageEvent.objects.filter( account=account, occurred_at__gte=now - timedelta(days=usage_days), occurred_at__lte=now, ).order_by("occurred_at", "id") + if user is not None and not user.is_staff: + from ..cpa.participants import ownership_filter + events = events.filter(ownership_filter(user.quota_participants.values_list("id", flat=True))) series: dict[str, dict] = {} for event in events: key = event.api_key_hash or "unattributed" diff --git a/backend/monitor/serializers/participants.py b/backend/monitor/serializers/participants.py index 3450309..1e46e1a 100644 --- a/backend/monitor/serializers/participants.py +++ b/backend/monitor/serializers/participants.py @@ -48,6 +48,8 @@ def _validate_user_identity( sub2api_user_id: int, instance_id: int | None, ) -> None: + if sub2api_user_id is None: + return duplicate = Participant.objects.select_for_update().filter( sub2api_user_id=sub2api_user_id, ) @@ -60,6 +62,8 @@ def _validate_user_identity( @staticmethod def _ensure_account_usage_rows(participant: Participant) -> None: + if participant.sub2api_user_id is None: + return existing = set( participant.account_memberships.values_list("account_id", flat=True) ) @@ -79,7 +83,7 @@ def _ensure_account_usage_rows(participant: Participant) -> None: def create(self, validated_data): with fenced_fact_write(self._account_external_ids()): self._validate_user_identity( - sub2api_user_id=validated_data["sub2api_user_id"], + sub2api_user_id=validated_data.get("sub2api_user_id"), instance_id=None, ) participant = Participant.objects.create(**validated_data) @@ -132,21 +136,23 @@ class QuotaPoolWriteSerializer(serializers.Serializer): class QuotaAllocationWriteSerializer(serializers.Serializer): """Atomically replace the complete account partition and pool contracts.""" + provider = serializers.ChoiceField(choices=("sub2api", "cpa"), default="sub2api") pools = QuotaPoolWriteSerializer(many=True) def validate(self, attrs): pools = attrs["pools"] configured_account_ids = set( - MonitoredAccount.objects.filter(provider="sub2api").values_list( + MonitoredAccount.objects.filter(provider=attrs["provider"]).values_list( "id", flat=True, ) ) - configured_participant_ids = set( - Participant.objects.values_list("id", flat=True) - ) + participants = Participant.objects.all() + if attrs["provider"] == "sub2api": + participants = participants.filter(sub2api_user_id__isnull=False) + configured_participant_ids = set(participants.values_list("id", flat=True)) existing_pool_ids = set( - QuotaPool.objects.filter(accounts__provider="sub2api") + QuotaPool.objects.filter(accounts__provider=attrs["provider"]) .distinct() .values_list("id", flat=True) ) @@ -254,7 +260,7 @@ def _generated_name( def apply(self) -> list[QuotaPool]: accounts = list( MonitoredAccount.objects.select_for_update() - .filter(provider="sub2api") + .filter(provider=self.validated_data["provider"]) .select_related("pool") .order_by("id") ) @@ -262,7 +268,7 @@ def apply(self) -> list[QuotaPool]: existing_pools = { pool.id: pool for pool in QuotaPool.objects.select_for_update() - .filter(accounts__provider="sub2api") + .filter(accounts__provider=self.validated_data["provider"]) .prefetch_related("allocations") .distinct() .order_by("id") @@ -368,4 +374,10 @@ def apply(self) -> list[QuotaPool]: QuotaPool.objects.exclude(id__in=retained_pool_ids).filter( accounts__isnull=True ).delete() + if self.validated_data["provider"] == "cpa": + from django.utils import timezone + from ..cpa.participants import record_contract + now = timezone.now() + for account in MonitoredAccount.objects.filter(pk__in=accounts_by_id).select_related("pool"): + record_contract(account, now) return [pool for pool, _spec, _changed in applied] diff --git a/backend/monitor/tests/api/test_cpa_participants.py b/backend/monitor/tests/api/test_cpa_participants.py new file mode 100644 index 0000000..27e8a46 --- /dev/null +++ b/backend/monitor/tests/api/test_cpa_participants.py @@ -0,0 +1,652 @@ +from datetime import timedelta +from decimal import Decimal +import uuid + +import pytest +from django.contrib.auth import get_user_model +from django.test import Client +from django.utils import timezone +from rest_framework.exceptions import ValidationError + +from monitor.api_auth import hash_api_key +from monitor.cpa.participants import ( + apply_claim, + bind_key, + preview_claim, + record_contract, + unbind_key, + ownership_filter, +) +from monitor.cpa.reporting import pool_summary +from monitor.cpa.usage import _api_key_identity, persist_usage_event +from monitor.models import ( + AppSettings, + CPAAPIKey, + CPAKeyBinding, + CPAUsageEvent, + CPAAccountCollectionInterval, + Participant, + ParticipantSnapshot, + PoolParticipant, + Observation, + SystemUserAPIKey, + SystemUserPageAccess, +) +from monitor.replay import rebuild_account +from monitor.serializers import ( + ParticipantWriteSerializer, + QuotaAllocationWriteSerializer, +) +from monitor.tests.helpers import ( + create_cpa_account, + create_monitored_account, + jwt_login, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def setup(): + config = AppSettings.load() + config.cpa_model_pricing = { + "gpt-test": {"input": "10", "cached_input": "1", "output": "30"} + } + config.save() + admin = get_user_model().objects.create_superuser( + "owner", "owner@example.com", "very-strong-password" + ) + account = create_cpa_account() + alice = Participant.objects.create( + name="Alice", sub2api_user_id=51, latest_balance_usd=Decimal("123") + ) + bob = Participant.objects.create(name="Bob") + for person in (alice, bob): + PoolParticipant.objects.create( + pool=account.pool, participant=person, share_percent=50 + ) + start = timezone.now() - timedelta(hours=2) + account.created_at = start + account.save(update_fields=["created_at"]) + record_contract(account, start) + keys = [] + for person, raw in ((alice, "alice-cpa-key"), (bob, "bob-cpa-key")): + digest, hint = _api_key_identity(raw) + key = CPAAPIKey.objects.create(key_hash=digest, hint=hint) + CPAKeyBinding.objects.create(key=key, participant=person, started_at=start) + keys.append(key) + return config, admin, account, alice, bob, keys, start + + +def event(account, key, when, *, tokens=1_000_000, model="gpt-test", failed=False): + return CPAUsageEvent.objects.create( + account=account, + event_fingerprint=uuid.uuid4().hex, + request_id=uuid.uuid4().hex, + occurred_at=when, + api_key_hash=key.key_hash, + api_key_hint=key.hint, + model=model, + input_tokens=tokens, + total_tokens=tokens, + failed=failed, + ) + + +def observation(account, start, when, percent, cost): + return Observation.objects.create( + account_id=account.fact_key, + observed_at=when, + upstream_resets_at=start + timedelta(days=7), + window_seconds=604800, + upstream_used_percent=percent, + effective_usd_per_percent=Decimal("10"), + raw_selected_total_cost=cost, + selected_total_cost=cost, + total_standard_cost=cost, + total_actual_cost=cost, + cost_window_started_at=start, + cost_window_ended_at=when, + raw_window={"provider": "cpa"}, + ) + + +def member_client(account, participant, name="rider"): + user = get_user_model().objects.create_user(name, password="very-strong-password") + participant.authorized_users.add(user) + account.authorized_users.add(user) + for page in ("dashboard", "participants", "statistics", "observations"): + SystemUserPageAccess.objects.create(user=user, page_code=page) + client = Client() + headers, _ = jwt_login(client, name) + return user, client, headers + + +def test_nullable_identity_and_channel_partition(setup): + config, admin, account, alice, bob, keys, start = setup + serializer = ParticipantWriteSerializer(data={"name": "CPA only"}) + assert serializer.is_valid(), serializer.errors + person = serializer.save() + assert person.sub2api_user_id is None + assert not person.account_memberships.exists() + sub = create_monitored_account(7) + payload = { + "provider": "cpa", + "pools": [{"account_ids": [account.id, sub.id], "allocations": []}], + } + serializer = QuotaAllocationWriteSerializer(data=payload) + assert not serializer.is_valid() + serializer = QuotaAllocationWriteSerializer( + data={ + "provider": "cpa", + "pools": [ + { + "id": account.pool_id, + "account_ids": [account.id], + "allocations": [{"participant_id": bob.id, "share_percent": 100}], + } + ], + } + ) + assert serializer.is_valid(), serializer.errors + serializer.apply() + sub.refresh_from_db() + assert sub.pool_id != account.pool_id + assert account.cpa_contracts.count() == 2 + + +def test_multi_key_binding_no_plaintext_or_overlap_and_unbind(setup): + config, admin, account, alice, bob, keys, start = setup + binding = bind_key( + participant=alice, user=admin, raw_key="new-private-cpa-key", name="Laptop" + ) + assert binding.key.key_hash != "new-private-cpa-key" + assert binding.key.hint == "-key" + with pytest.raises(ValidationError): + bind_key(participant=bob, user=admin, raw_key="new-private-cpa-key") + unbind_key(binding.id) + replacement = bind_key(participant=bob, user=admin, raw_key="new-private-cpa-key") + assert replacement.started_at >= CPAKeyBinding.objects.get(pk=binding.id).ended_at + assert alice.cpa_bindings.count() == 2 + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_model_replay_and_pool_summary_preserve_sub2api_balance(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=start + ) + observation(account, start, start, 0, 0) + event(account, keys[0], start + timedelta(minutes=10)) + event(account, keys[1], start + timedelta(minutes=20), tokens=2_000_000) + end = observation(account, start, start + timedelta(hours=1), 10, 30) + rebuild_account(account.fact_key, config) + alice.refresh_from_db() + assert alice.latest_balance_usd == Decimal("123") + first = ParticipantSnapshot.objects.get(observation=end, participant=alice) + second = ParticipantSnapshot.objects.get(observation=end, participant=bob) + assert first.raw_selected_cost == 10 + assert second.raw_selected_cost == 20 + assert first.charged_cycle_percent > 0 + assert first.charged_cycle_percent < second.charged_cycle_percent + assert first.recommended_balance_usd is None + assert not first.needs_manual_update + summary = pool_summary(admin, account, config) + assert all(row["quota_available"] for row in summary["members"]) + assert sum(row["usage_usd"] for row in summary["members"]) == 30 + before = list( + end.participant_snapshots.order_by("participant_id").values( + "selected_cost", "charged_cycle_percent" + ) + ) + rebuild_account(account.fact_key, config) + assert ( + list( + end.participant_snapshots.order_by("participant_id").values( + "selected_cost", "charged_cycle_percent" + ) + ) + == before + ) + + +def test_peer_totals_own_requests_and_readonly_scope(setup): + config, admin, account, alice, bob, keys, start = setup + a = event(account, keys[0], start + timedelta(minutes=10)) + b = event(account, keys[1], start + timedelta(minutes=20), tokens=2_000_000) + user, client, headers = member_client(account, alice) + response = client.get(f"/api/cpa/summary?account_id={account.id}", **headers) + assert response.status_code == 200, response.content + rows = response.json()["data"]["members"] + assert {row["participant_name"] for row in rows} == {"Alice", "Bob"} + assert "key_hash" not in str(rows) and "request_id" not in str(rows) + for route in ("cpa/requests", "v1/cpa/requests"): + chosen_headers = headers + if route.startswith("v1/"): + SystemUserAPIKey.objects.create( + user=user, key_hash=hash_api_key("user-read-key"), hint="-key" + ) + chosen_headers = {"HTTP_AUTHORIZATION": "Bearer user-read-key"} + own = client.get(f"/api/{route}?account_id={account.id}", **chosen_headers) + assert own.status_code == 200, own.content + assert [row["id"] for row in own.json()["data"]["items"]] == [a.id] + assert ( + client.get( + f"/api/{route}?account_id={account.id}&participant_id={bob.id}", + **chosen_headers, + ).status_code + == 400 + ) + assert ( + client.get( + f"/api/{route}?account_id={account.id}&key_id={keys[1].id}", + **chosen_headers, + ).status_code + == 404 + ) + stats = client.get( + f"/api/statistics?account_id={account.id}&usage_precision=raw", **headers + ) + assert len(stats.json()["data"]["cpa_api_key_series"]) == 1 + other = create_cpa_account("private") + other.authorized_users.add(user) + assert ( + client.get(f"/api/cpa/summary?account_id={other.id}", **headers).status_code + == 400 + ) + allocation = client.get("/api/quota-allocation?provider=cpa", **headers).json()[ + "data" + ] + assert [row["id"] for row in allocation["accounts"]] == [account.id] + assert all(row["sub2api_email"] == "" for row in allocation["participants"]) + assert b.request_id not in str( + client.get(f"/api/cpa/requests?account_id={account.id}", **headers).json() + ) + + +def test_admin_readonly_requests_principal(setup): + config, admin, account, alice, bob, keys, start = setup + config.readonly_api_key_hash = hash_api_key("admin-read-key") + config.save() + event(account, keys[1], start + timedelta(minutes=20)) + client = Client() + response = client.get( + f"/api/v1/cpa/requests?account_id={account.id}", + HTTP_AUTHORIZATION="Bearer admin-read-key", + ) + assert response.status_code == 200, response.content + assert response.json()["data"]["total"] == 1 + + +def test_claim_is_explicit_idempotent_and_does_not_invent_contract(setup): + config, admin, account, alice, bob, keys, start = setup + earlier = start - timedelta(hours=2) + raw = event(account, keys[0], earlier + timedelta(minutes=5)) + old = observation(account, earlier, earlier + timedelta(hours=1), 10, 10) + before = CPAUsageEvent.objects.get(pk=raw.id).__dict__.copy() + plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + assert plan.preview["accounts"][0]["request_count"] == 1 + assert not plan.preview["accounts"][0]["coverage"]["complete"] + assert CPAKeyBinding.objects.count() == 2 + apply_claim(plan.id) + apply_claim(plan.id) + assert CPAKeyBinding.objects.count() == 3 + snapshot = ParticipantSnapshot.objects.get(observation=old, participant=alice) + assert not snapshot.cpa_contract_known + assert snapshot.quota_pool_id is None + fresh = CPAUsageEvent.objects.get(pk=raw.id).__dict__ + assert {k: v for k, v in fresh.items() if k != "_state"} == { + k: v for k, v in before.items() if k != "_state" + } + + +def test_claim_does_not_automatically_own_late_historical_events(setup): + config, admin, account, alice, bob, keys, start = setup + earlier = start - timedelta(hours=2) + original = event(account, keys[0], earlier + timedelta(minutes=5)) + old = observation(account, earlier, earlier + timedelta(hours=1), 10, 20) + plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + apply_claim(plan.id) + late = event(account, keys[0], earlier + timedelta(minutes=6)) + rebuild_account(account.fact_key, config) + assert ( + ParticipantSnapshot.objects.get( + observation=old, participant=alice + ).raw_selected_cost + == 10 + ) + assert set( + CPAUsageEvent.objects.filter(ownership_filter([alice.id])).values_list( + "id", flat=True + ) + ) == {original.id} + with pytest.raises(ValidationError): + preview_claim( + key=keys[0], participant=bob, started_at=earlier, ended_at=start, user=admin + ) + new_plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + assert new_plan.preview["accounts"][0]["request_count"] == 1 + apply_claim(new_plan.id) + apply_claim(new_plan.id) + assert ( + ParticipantSnapshot.objects.get( + observation=old, participant=alice + ).raw_selected_cost + == 20 + ) + assert set( + CPAUsageEvent.objects.filter(ownership_filter([alice.id])).values_list( + "id", flat=True + ) + ) == {original.id, late.id} + + +@pytest.mark.parametrize("change", ["events", "pricing", "binding"]) +def test_claim_rejects_changed_preview(setup, change): + config, admin, account, alice, bob, keys, start = setup + earlier = start - timedelta(hours=2) + event(account, keys[0], earlier + timedelta(minutes=5)) + plan = preview_claim( + key=keys[0], participant=alice, started_at=earlier, ended_at=start, user=admin + ) + if change == "events": + event(account, keys[0], earlier + timedelta(minutes=6)) + elif change == "pricing": + config.cpa_fast_multiplier = 4 + config.save() + else: + unbind_key(keys[0].bindings.get().id) + with pytest.raises(ValidationError, match="重新预览"): + apply_claim(plan.id) + + +def test_unknown_models_and_collection_gap_do_not_show_remaining(setup): + config, admin, account, alice, bob, keys, start = setup + event(account, keys[0], start + timedelta(minutes=10), model="unpriced") + observation(account, start, start + timedelta(hours=1), 10, 0) + rebuild_account(account.fact_key, config) + summary = pool_summary(admin, account, config) + assert summary["members"][0]["remaining_entitlement_usd"] is None + assert summary["members"][0]["unpriced_request_count"] == 1 + assert not summary["accounts"][0]["coverage"]["complete"] + + +def test_request_filters_and_participant_with_history_cannot_be_deleted(setup): + config, admin, account, alice, bob, keys, start = setup + event(account, keys[0], start + timedelta(minutes=10)) + failed = event(account, keys[0], start + timedelta(minutes=20), failed=True) + client = Client() + headers, _ = jwt_login(client) + assert ( + client.get(f"/api/cpa/requests?account_id={account.id}", **headers).json()[ + "data" + ]["total"] + == 2 + ) + response = client.get( + f"/api/cpa/requests?account_id={account.id}&failed=true&page_size=1", **headers + ) + assert response.json()["data"]["total"] == 1 + assert response.json()["data"]["items"][0]["id"] == failed.id + assert ( + client.get( + f"/api/cpa/requests?account_id={account.id}&page_size=101", **headers + ).status_code + == 400 + ) + assert client.delete(f"/api/participants/{bob.id}", **headers).status_code == 409 + + +def test_late_usage_duplicate_and_repricing_refresh_participant_inputs(setup): + from monitor.cpa.usage import refresh_cpa_history + + config, admin, account, alice, bob, keys, start = setup + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=start + ) + observation(account, start, start, 0, 0) + end = observation(account, start, start + timedelta(hours=1), 10, 0) + payload = { + "auth_index": account.cpa_auth_index, + "timestamp": (start + timedelta(minutes=20)).isoformat(), + "request_id": "late-cpa-event", + "model": "gpt-test", + "api_key": "alice-cpa-key", + "tokens": {"input_tokens": 1_000_000, "total_tokens": 1_000_000}, + } + assert persist_usage_event(payload) == "created" + assert persist_usage_event(payload) == "duplicate" + assert CPAUsageEvent.objects.filter(request_id="late-cpa-event").count() == 1 + assert ( + ParticipantSnapshot.objects.get( + observation=end, participant=alice + ).raw_selected_cost + == 10 + ) + config.cpa_model_pricing["gpt-test"]["input"] = "20" + config.save() + refresh_cpa_history(config) + assert ( + ParticipantSnapshot.objects.get( + observation=end, participant=alice + ).raw_selected_cost + == 20 + ) + alice.refresh_from_db() + assert alice.latest_balance_usd == 123 + + +def test_pool_partial_scope_does_not_expose_hidden_account_cost(setup): + config, admin, account, alice, bob, keys, start = setup + hidden = create_cpa_account("same-pool-hidden") + hidden.pool = account.pool + hidden.save(update_fields=["pool"]) + event(account, keys[0], start + timedelta(minutes=10)) + event(hidden, keys[0], start + timedelta(minutes=20), tokens=50_000_000) + user, client, headers = member_client(account, alice) + summary = client.get(f"/api/cpa/summary?account_id={account.id}", **headers).json()[ + "data" + ] + assert summary["partial_scope"] + assert [a["account_id"] for a in summary["accounts"]] == [account.id] + assert ( + next(row for row in summary["members"] if row["participant_id"] == alice.id)[ + "usage_usd" + ] + == 10 + ) + + +def test_positive_baseline_does_not_claim_complete_personal_quota(setup): + config, admin, account, alice, bob, keys, start = setup + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=start + ) + observation(account, start, start + timedelta(minutes=10), 10, 10) + event(account, keys[0], start + timedelta(minutes=5)) + event(account, keys[1], start + timedelta(minutes=20)) + observation(account, start, start + timedelta(hours=1), 20, 20) + rebuild_account(account.fact_key, config) + assert all( + not row["quota_available"] + for row in pool_summary(admin, account, config)["members"] + ) + + +def test_cpa_membership_does_not_enter_sub2api_balance_guards(setup, monkeypatch): + config, admin, account, alice, bob, keys, start = setup + sub = create_monitored_account(7) + PoolParticipant.objects.create(pool=sub.pool, participant=alice, share_percent=50) + captured = [] + from monitor.history_state import LeaseBusyError + + def stop_after_scope(account_ids): + captured.append(account_ids) + raise LeaseBusyError("test scope") + + monkeypatch.setattr("monitor.views.dashboard._acquire_guards", stop_after_scope) + client = Client() + headers, _ = jwt_login(client) + assert ( + client.post( + f"/api/dashboard/participants/{alice.id}/apply-recommendation", **headers + ).status_code + == 409 + ) + assert captured == [{7}] + assert ( + client.post( + f"/api/dashboard/participants/{bob.id}/apply-recommendation", **headers + ).status_code + == 400 + ) + + +def test_rebind_registered_key_without_events_and_keep_contract_identity(setup): + config, admin, account, alice, bob, keys, start = setup + binding = bind_key(participant=alice, user=admin, raw_key="never-used-key") + unbind_key(binding.id) + new = bind_key(participant=bob, user=admin, observed_hash=binding.key.key_hash) + assert new.key_id == binding.key_id + person = Participant.objects.create(name="Contract only") + PoolParticipant.objects.create( + pool=account.pool, participant=person, share_percent=0 + ) + record_contract(account, timezone.now()) + client = Client() + headers, _ = jwt_login(client) + assert client.delete(f"/api/participants/{person.id}", **headers).status_code == 409 + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_pool_combines_account_credits_and_keeps_unassigned_cost(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + second = create_cpa_account("second-pool-account") + second.pool = account.pool + second.created_at = start + second.save(update_fields=["pool", "created_at"]) + record_contract(second, start) + unknown = CPAAPIKey.objects.create(key_hash="f" * 64, hint="0000") + for selected in (account, second): + CPAAccountCollectionInterval.objects.create( + account=selected, session_key="live", connected_at=start + ) + observation(selected, start, start, 0, 0) + event(account, keys[0], start + timedelta(minutes=10), tokens=6_000_000) + observation(account, start, start + timedelta(hours=1), 60, 60) + event(second, unknown, start + timedelta(minutes=10), tokens=5_000_000) + observation(second, start, start + timedelta(hours=1), 10, 50) + for selected in (account, second): + rebuild_account(selected.fact_key, config) + summary = pool_summary(admin, account, config) + member = next( + row for row in summary["members"] if row["participant_id"] == alice.id + ) + assert member["quota_available"] + assert len(member["account_breakdowns"]) == 2 + assert member["usage_usd"] == 60 + assert member["remaining_entitlement_usd"] == pytest.approx( + sum(row["remaining_entitlement_usd"] for row in member["account_breakdowns"]) + ) + assert not member["is_overused"] + assert summary["unattributed"]["usage_usd"] == 50 + second.enabled = False + second.save(update_fields=["enabled"]) + single = pool_summary(admin, account, config) + assert next(row for row in single["members"] if row["participant_id"] == alice.id)[ + "is_overused" + ] + + +@pytest.mark.parametrize("model", ["constant_average", "time_varying"]) +def test_cpa_participant_cost_resets_with_official_cycle(setup, model): + config, admin, account, alice, bob, keys, start = setup + config.weekly_quota_model = model + config.save() + previous_start = start - timedelta(days=7) + record_contract(account, previous_start) + keys[0].bindings.update(started_at=previous_start) + CPAAccountCollectionInterval.objects.create( + account=account, session_key="live", connected_at=previous_start + ) + observation(account, previous_start, previous_start, 0, 0) + event(account, keys[0], previous_start + timedelta(hours=1), tokens=9_000_000) + old = observation(account, previous_start, start - timedelta(minutes=5), 90, 90) + observation(account, start, start, 0, 0) + event(account, keys[0], start + timedelta(minutes=10)) + current = observation(account, start, start + timedelta(hours=1), 10, 10) + rebuild_account(account.fact_key, config) + assert ( + ParticipantSnapshot.objects.get( + observation=old, participant=alice + ).raw_selected_cost + == 90 + ) + snapshot = ParticipantSnapshot.objects.get(observation=current, participant=alice) + assert snapshot.raw_selected_cost == 10 + assert snapshot.charged_cycle_percent < 50 + member = next( + row + for row in pool_summary(admin, account, config)["members"] + if row["participant_id"] == alice.id + ) + assert member["usage_usd"] == 10 + assert not member["is_overused"] + + +def test_detached_sub2api_identity_keeps_historical_replay_subject(setup): + from monitor.models import AccountParticipant + from monitor.tests.helpers import create_participant_snapshot + + config, admin, account, alice, bob, keys, start = setup + sub = create_monitored_account(7) + AccountParticipant.objects.create(account=sub, participant=alice) + PoolParticipant.objects.create(pool=sub.pool, participant=alice, share_percent=50) + for when, percent, cost in ((start, 0, 0), (start + timedelta(hours=1), 10, 10)): + row = observation(sub, start, when, percent, cost) + create_participant_snapshot( + observation=row, + participant=alice, + raw_selected_cost=cost, + selected_cost=cost, + ) + rebuild_account(sub.fact_key, config) + before = list( + ParticipantSnapshot.objects.filter(observation__account_id=7) + .order_by("id") + .values( + "raw_selected_cost", + "selected_cost", + "charged_cycle_percent", + "source_sub2api_user_id", + ) + ) + serializer = ParticipantWriteSerializer( + alice, data={"sub2api_user_id": None}, partial=True + ) + assert serializer.is_valid(), serializer.errors + serializer.save() + rebuild_account(sub.fact_key, config) + assert ( + list( + ParticipantSnapshot.objects.filter(observation__account_id=7) + .order_by("id") + .values( + "raw_selected_cost", + "selected_cost", + "charged_cycle_percent", + "source_sub2api_user_id", + ) + ) + == before + ) diff --git a/backend/monitor/tests/api/test_readonly_api.py b/backend/monitor/tests/api/test_readonly_api.py index 7d1e58a..925c24a 100644 --- a/backend/monitor/tests/api/test_readonly_api.py +++ b/backend/monitor/tests/api/test_readonly_api.py @@ -147,6 +147,8 @@ def test_api_key_lifecycle_and_scope(): assert index_data["openapi"] == "/api/v1/openapi.json" assert index_data["authentication"]["scheme"] == "bearer" expected_endpoint_paths = { + "/api/v1/cpa/summary", + "/api/v1/cpa/requests", "/api/v1/accounts", "/api/v1/dashboard", "/api/v1/recommendations", @@ -515,6 +517,7 @@ def test_system_user_api_key_follows_live_page_and_data_permissions(): index_data = index.json()["data"] assert index_data["authentication"]["permissions"] == "page_scoped" expected_paths = { + "/api/v1/cpa/summary", "/api/v1/accounts", "/api/v1/account-status", "/api/v1/participants", diff --git a/backend/monitor/tests/migrations/test_0052_cpa_participants.py b/backend/monitor/tests/migrations/test_0052_cpa_participants.py new file mode 100644 index 0000000..ad17d80 --- /dev/null +++ b/backend/monitor/tests/migrations/test_0052_cpa_participants.py @@ -0,0 +1,64 @@ +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.utils import timezone + + +@pytest.mark.django_db(transaction=True) +def test_cpa_migration_preserves_users_grants_and_unclaimed_events(): + executor = MigrationExecutor(connection) + latest = executor.loader.graph.leaf_nodes() + before = [("monitor", "0051_pooled_research_raw_only")] + after = [("monitor", "0052_cpa_participants")] + try: + executor.migrate(before) + apps = executor.loader.project_state(before).apps + Participant = apps.get_model("monitor", "Participant") + Pool = apps.get_model("monitor", "QuotaPool") + Account = apps.get_model("monitor", "MonitoredAccount") + Event = apps.get_model("monitor", "CPAUsageEvent") + User = apps.get_model("auth", "User") + user = User.objects.create(username="legacy-rider") + person = Participant.objects.create(name="原成员", sub2api_user_id=99) + person.authorized_users.add(user) + pool = Pool.objects.create(name="原池") + account = Account.objects.create( + provider="cpa", cpa_auth_index="legacy-cpa", pool=pool, name="原 CPA" + ) + event = Event.objects.create( + account=account, + occurred_at=timezone.now(), + model="gpt-test", + event_fingerprint="legacy-event", + api_key_hash="a" * 64, + api_key_hint="9999", + total_tokens=100, + ) + executor = MigrationExecutor(connection) + executor.migrate(after) + apps = executor.loader.project_state(after).apps + assert ( + apps.get_model("monitor", "Participant") + .objects.get(pk=person.pk) + .sub2api_user_id + == 99 + ) + assert ( + apps.get_model("monitor", "Participant") + .objects.get(pk=person.pk) + .authorized_users.filter(pk=user.pk) + .exists() + ) + assert ( + apps.get_model("monitor", "CPAUsageEvent") + .objects.get(pk=event.pk) + .api_key_hash + == "a" * 64 + ) + assert not apps.get_model("monitor", "CPAKeyBinding").objects.exists() + assert not apps.get_model("monitor", "CPAQuotaContract").objects.exists() + apps.get_model("monitor", "Participant").objects.create( + name="CPA 新成员", sub2api_user_id=None + ) + finally: + MigrationExecutor(connection).migrate(latest) diff --git a/backend/monitor/urls.py b/backend/monitor/urls.py index db86f87..b94f1bd 100644 --- a/backend/monitor/urls.py +++ b/backend/monitor/urls.py @@ -202,3 +202,16 @@ ), path("monitor/run", RunMonitorView.as_view()), ] + +from .views.cpa_participants import (CPAKeyListView, CPABindingDetailView, CPAClaimPreviewView, CPAClaimApplyView, CPASummaryView, CPARequestsView, ReadOnlyCPASummaryView, ReadOnlyCPARequestsView) + +urlpatterns += [ + path("cpa/keys", CPAKeyListView.as_view()), + path("cpa/bindings/", CPABindingDetailView.as_view()), + path("cpa/claims/preview", CPAClaimPreviewView.as_view()), + path("cpa/claims//apply", CPAClaimApplyView.as_view()), + path("cpa/summary", CPASummaryView.as_view()), + path("cpa/requests", CPARequestsView.as_view()), + path("v1/cpa/summary", ReadOnlyCPASummaryView.as_view()), + path("v1/cpa/requests", ReadOnlyCPARequestsView.as_view()), +] diff --git a/backend/monitor/views/cpa_participants.py b/backend/monitor/views/cpa_participants.py new file mode 100644 index 0000000..f1058a4 --- /dev/null +++ b/backend/monitor/views/cpa_participants.py @@ -0,0 +1,323 @@ +"""CPA carpool API. Administrative identity writes and scoped reads stay separate.""" + +from datetime import timedelta + +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import serializers + +from ..api_auth import APIKeyAuthentication +from ..cpa.participants import ( + apply_claim, + bind_key, + ownership_filter, + preview_claim, + unbind_key, +) +from ..cpa.reporting import pool_summary +from ..cpa.usage import cpa_event_cost +from ..models import ( + AppSettings, + CPAAPIKey, + CPAClaimPlan, + CPAKeyBinding, + CPAUsageEvent, + PagePermission, + Participant, +) +from .base import AdminAPIView, PageAccessAPIView, error, ok +from .query_params import monitored_account_query + + +class CPAErrorEnvelope: + def handle_exception(self, exc): + from ..history_state import LeaseBusyError + + if isinstance(exc, LeaseBusyError): + return error("账号正在采集或重算,请稍后重试", 409) + response = super().handle_exception(exc) + + def first_message(value): + if isinstance(value, dict): + return first_message(next(iter(value.values()), "请求失败")) + if isinstance(value, (list, tuple)): + return first_message(value[0]) if value else "请求失败" + return str(value) + + result = error( + first_message(response.data), response.status_code, response.data + ) + result.exception = True + for name, value in response.items(): + result[name] = value + return result + + +class CPAAdminView(CPAErrorEnvelope, AdminAPIView): + pass + + +class CPAReadView(CPAErrorEnvelope, PageAccessAPIView): + pass + + +def cpa_account(request, *, enabled_only=True): + if not request.query_params.get("account_id"): + raise serializers.ValidationError("必须指定 account_id") + try: + account = monitored_account_query(request, enabled_only=enabled_only) + except ValueError as exc: + raise serializers.ValidationError(str(exc)) from exc + if account is None or account.provider != "cpa": + raise serializers.ValidationError("请选择已授权的 CPA 账号") + return account + + +def binding_data(binding): + return { + "id": binding.id, + "key_id": binding.key_id, + "hint": binding.key.hint, + "name": binding.key.name, + "participant_id": binding.participant_id, + "participant_name": binding.participant.name, + "started_at": binding.started_at.isoformat(), + "ended_at": binding.ended_at.isoformat() if binding.ended_at else None, + } + + +def claim_data(plan): + return { + "id": str(plan.id), + "key_id": plan.key_id, + "participant_id": plan.participant_id, + "started_at": plan.started_at.isoformat(), + "ended_at": plan.ended_at.isoformat(), + "expires_at": plan.expires_at.isoformat(), + "applied_at": plan.applied_at.isoformat() if plan.applied_at else None, + **plan.preview, + } + + +class BindingWrite(serializers.Serializer): + participant_id = serializers.PrimaryKeyRelatedField( + queryset=Participant.objects.filter(enabled=True), source="participant" + ) + raw_key = serializers.CharField( + required=False, allow_blank=True, max_length=4096, write_only=True + ) + observed_hash = serializers.RegexField(r"^[a-f0-9]{64}$", required=False) + name = serializers.CharField(required=False, allow_blank=True, max_length=80) + + def validate(self, attrs): + if bool(attrs.get("raw_key")) == bool(attrs.get("observed_hash")): + raise serializers.ValidationError("请选择已采集 Key 或输入完整 Key,二选一") + return attrs + + +class CPAKeyListView(CPAAdminView): + def get(self, request): + keys = list( + CPAAPIKey.objects.prefetch_related("bindings__participant").order_by("id") + ) + known = {key.key_hash for key in keys} + observed = ( + CPAUsageEvent.objects.exclude(api_key_hash="") + .exclude(api_key_hash__in=known) + .order_by() + .values("api_key_hash", "api_key_hint") + .distinct() + ) + return ok( + { + "keys": [ + { + "id": key.id, + "hint": key.hint, + "name": key.name, + "observed_hash": key.key_hash, + "bindings": [binding_data(row) for row in key.bindings.all()], + } + for key in keys + ], + "unregistered": [ + {"observed_hash": row["api_key_hash"], "hint": row["api_key_hint"]} + for row in observed + ], + } + ) + + def post(self, request): + serializer = BindingWrite(data=request.data) + serializer.is_valid(raise_exception=True) + return ok( + binding_data(bind_key(user=request.user, **serializer.validated_data)), 201 + ) + + +class CPABindingDetailView(CPAAdminView): + def delete(self, request, binding_id): + get_object_or_404(CPAKeyBinding, pk=binding_id) + return ok(binding_data(unbind_key(binding_id))) + + def patch(self, request, binding_id): + binding = get_object_or_404(CPAKeyBinding, pk=binding_id) + name = serializers.CharField(max_length=80, allow_blank=True).run_validation( + request.data.get("name", "") + ) + binding.key.name = name + binding.key.save(update_fields=["name"]) + return ok(binding_data(binding)) + + +class ClaimWrite(serializers.Serializer): + key_id = serializers.PrimaryKeyRelatedField( + queryset=CPAAPIKey.objects.all(), source="key" + ) + participant_id = serializers.PrimaryKeyRelatedField( + queryset=Participant.objects.filter(enabled=True), source="participant" + ) + started_at = serializers.DateTimeField() + ended_at = serializers.DateTimeField() + + +class CPAClaimPreviewView(CPAAdminView): + def post(self, request): + serializer = ClaimWrite(data=request.data) + serializer.is_valid(raise_exception=True) + return ok( + claim_data(preview_claim(user=request.user, **serializer.validated_data)), + 201, + ) + + +class CPAClaimApplyView(CPAAdminView): + def post(self, request, plan_id): + get_object_or_404(CPAClaimPlan, pk=plan_id) + return ok(claim_data(apply_claim(plan_id))) + + +class CPASummaryView(CPAReadView): + required_page_permissions = ( + PagePermission.DASHBOARD, + PagePermission.PARTICIPANTS, + PagePermission.STATISTICS, + ) + + def get(self, request): + return ok(pool_summary(request.user, cpa_account(request))) + + +class CPARequestQuery(serializers.Serializer): + participant_id = serializers.IntegerField(required=False, min_value=1) + key_id = serializers.IntegerField(required=False, min_value=1) + model = serializers.CharField(required=False, max_length=255) + failed = serializers.BooleanField(required=False) + started_at = serializers.DateTimeField(required=False) + ended_at = serializers.DateTimeField(required=False) + page = serializers.IntegerField(default=1, min_value=1) + page_size = serializers.IntegerField(default=50, min_value=1, max_value=100) + + def validate(self, attrs): + end = attrs.get("ended_at", timezone.now()) + start = attrs.get("started_at", end - timedelta(days=7)) + if start >= end or end - start > timedelta(days=90): + raise serializers.ValidationError("请求查询时间范围须大于零且不超过 90 天") + attrs.update(started_at=start, ended_at=end) + return attrs + + +class CPARequestsView(CPAReadView): + required_page_permissions = (PagePermission.STATISTICS,) + + def get(self, request): + account = cpa_account(request, enabled_only=False) + # QueryDict makes DRF treat omitted booleans like unchecked HTML fields. + # An absent status filter must include both successful and failed requests. + serializer = CPARequestQuery(data=request.query_params.dict()) + serializer.is_valid(raise_exception=True) + params = serializer.validated_data + events = CPAUsageEvent.objects.filter(account=account) + owned = ( + list(request.user.quota_participants.values_list("id", flat=True)) + if not request.user.is_staff + else [] + ) + if not request.user.is_staff: + events = events.filter(ownership_filter(owned)) + if "participant_id" in params: + if not request.user.is_staff and params["participant_id"] not in owned: + raise serializers.ValidationError("只能查看本人授权参与者的请求") + events = events.filter(ownership_filter([params["participant_id"]])) + # Options are scoped before accepting client-supplied key/model filters. + key_hashes = events.order_by().values_list("api_key_hash", flat=True).distinct() + keys = CPAAPIKey.objects.filter(key_hash__in=key_hashes).order_by("id") + key_options = [ + {"id": key.id, "name": key.name, "hint": key.hint} for key in keys + ] + model_options = list( + events.order_by("model").values_list("model", flat=True).distinct() + ) + if "key_id" in params: + key = get_object_or_404(keys, pk=params["key_id"]) + events = events.filter(api_key_hash=key.key_hash) + events = events.filter( + occurred_at__gte=params["started_at"], occurred_at__lt=params["ended_at"] + ) + if params.get("model"): + events = events.filter(model=params["model"]) + if "failed" in params: + events = events.filter(failed=params["failed"]) + count = events.count() + offset = (params["page"] - 1) * params["page_size"] + config = AppSettings.load() + rows = [] + for event in events.order_by("-occurred_at", "-id")[ + offset : offset + params["page_size"] + ]: + cost, unknown = cpa_event_cost(event, config) + rows.append( + { + "id": event.id, + "occurred_at": event.occurred_at.isoformat(), + "request_id": event.request_id, + "api_key_hint": event.api_key_hint, + "model": event.model, + "endpoint": event.endpoint, + "input_tokens": event.input_tokens, + "cached_input_tokens": event.cached_input_tokens, + "output_tokens": event.output_tokens, + "reasoning_tokens": event.reasoning_tokens, + "total_tokens": event.total_tokens, + "failed": event.failed, + "latency_ms": event.latency_ms, + "ttft_ms": event.ttft_ms, + "usage_usd": float(cost), + "unpriced": unknown, + "requested_service_tier": event.requested_service_tier, + "response_service_tier": event.response_service_tier, + } + ) + return ok( + { + "account_id": account.id, + "items": rows, + "total": count, + "page": params["page"], + "page_size": params["page_size"], + "keys": key_options, + "models": model_options, + "cost_estimate": True, + "generated_at": timezone.now().isoformat(), + } + ) + + +class ReadOnlyCPASummaryView(CPASummaryView): + authentication_classes = [APIKeyAuthentication] + http_method_names = ["get", "head", "options"] + + +class ReadOnlyCPARequestsView(CPARequestsView): + authentication_classes = [APIKeyAuthentication] + http_method_names = ["get", "head", "options"] diff --git a/backend/monitor/views/dashboard.py b/backend/monitor/views/dashboard.py index a6a54a4..b4568ac 100644 --- a/backend/monitor/views/dashboard.py +++ b/backend/monitor/views/dashboard.py @@ -9,6 +9,7 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone +from ..cpa.reporting import pool_summary from .base import AdminAPIView, PageAccessAPIView, error, ok from ..api_auth import APIKeyAuthentication from ..access import ( @@ -100,7 +101,7 @@ def _participant_rows( ) -> tuple[list[dict], list[dict]]: all_rows = [ participant_data(item, config) - for item in Participant.objects.filter(enabled=True).prefetch_related( + for item in Participant.objects.filter(enabled=True, sub2api_user_id__isnull=False).prefetch_related( "account_memberships__account" ) ] @@ -229,6 +230,7 @@ def get(self, request): "accounts": [_account_data(item) for item in accounts], "selected_account_id": account.id if account else None, "selected_provider": account.provider if account else None, + "cpa_summary": pool_summary(request.user, account, config) if account is not None and account.provider == "cpa" else None, "last_local_check_at": iso( account.last_local_check_at if account else config.last_local_check_at ), @@ -592,6 +594,8 @@ class ApplyParticipantRecommendationView(AdminAPIView): def post(self, _request, participant_id: int): participant = get_object_or_404(Participant, pk=participant_id, enabled=True) + if participant.sub2api_user_id is None: + return error("CPA 参与者没有可写入的 Sub2API 余额", 400) pending = ( ParticipantBalanceOperation.objects.exclude(state="committed") .filter(participant=participant) @@ -602,6 +606,7 @@ def post(self, _request, participant_id: int): account_ids = set( MonitoredAccount.objects.filter( enabled=True, + provider="sub2api", pool__allocations__participant=participant, pool__allocations__share_percent__gt=0, ) diff --git a/backend/monitor/views/participants.py b/backend/monitor/views/participants.py index fbbc68d..8b78535 100644 --- a/backend/monitor/views/participants.py +++ b/backend/monitor/views/participants.py @@ -31,9 +31,9 @@ class _ParticipantHasSnapshots(RuntimeError): pass -def quota_allocation_data(user) -> dict: +def quota_allocation_data(user, provider="sub2api") -> dict: accounts = list( - MonitoredAccount.objects.filter(provider="sub2api") + MonitoredAccount.objects.filter(provider=provider) .select_related("pool") .order_by( "pool__name", @@ -42,8 +42,12 @@ def quota_allocation_data(user) -> dict: "external_account_id", ) ) + if provider == "cpa" and not user.is_staff: + from ..access import visible_accounts_for + allowed = set(visible_accounts_for(user).values_list("id", flat=True)) + accounts = [account for account in accounts if account.id in allowed] pools = list( - QuotaPool.objects.filter(accounts__provider="sub2api") + QuotaPool.objects.filter(accounts__id__in=[account.id for account in accounts]) .prefetch_related( "accounts", "allocations__participant", @@ -57,20 +61,25 @@ def quota_allocation_data(user) -> dict: Participant.objects.order_by("-is_owner", "id"), ) ) + if provider == "sub2api": + participants = [p for p in participants if p.sub2api_user_id is not None] + if provider == "cpa" and not user.is_staff: + participants = list(Participant.objects.filter(pool_allocations__pool__in=pools).distinct()) visible_participant_ids = { participant.id for participant in participants } return { + "provider": provider, "accounts": MonitoredAccountSerializer(accounts, many=True).data, "participants": [ { "id": participant.id, "name": participant.name, - "sub2api_user_id": participant.sub2api_user_id, - "sub2api_username": participant.sub2api_username, - "sub2api_email": participant.sub2api_email, + "sub2api_user_id": participant.sub2api_user_id if provider == "sub2api" else None, + "sub2api_username": participant.sub2api_username if provider == "sub2api" else "", + "sub2api_email": participant.sub2api_email if provider == "sub2api" else "", "sub2api_identity": ( - participant.sub2api_username + "CPA" if provider == "cpa" else participant.sub2api_username or participant.sub2api_email or f"账号 {participant.sub2api_user_id}" ), @@ -90,7 +99,7 @@ def quota_allocation_data(user) -> dict: [ item for item in pool.accounts.all() - if item.provider == "sub2api" + if item.provider == provider and item.id in {account.id for account in accounts} ], key=lambda item: ( item.name, @@ -127,7 +136,10 @@ class QuotaAllocationView(PageAccessAPIView): required_page_permissions = (PagePermission.PARTICIPANTS,) def get(self, request): - return ok(quota_allocation_data(request.user)) + provider = request.query_params.get("provider", "sub2api") + if provider not in {"sub2api", "cpa"}: + return error("渠道参数无效") + return ok(quota_allocation_data(request.user, provider)) def put(self, request): serializer = QuotaAllocationWriteSerializer(data=request.data) @@ -136,7 +148,7 @@ def put(self, request): external_account_ids = [ account.fact_key for account in MonitoredAccount.objects.filter( - provider="sub2api" + provider=serializer.validated_data["provider"] ).order_by("external_account_id") ] settings_id = AppSettings.load().pk @@ -146,7 +158,7 @@ def put(self, request): serializer.apply() except serializers.ValidationError as exc: return error("分配方案已过期", details=exc.detail) - return ok(quota_allocation_data(request.user)) + return ok(quota_allocation_data(request.user, serializer.validated_data["provider"])) class Sub2APIUserListView(AdminAPIView): @@ -262,7 +274,8 @@ def delete(self, _request, participant_id: int): participant = Participant.objects.select_for_update().get( pk=participant_id ) - if participant.snapshots.exists(): + from ..cpa.participants import has_cpa_contract_history + if participant.snapshots.exists() or participant.cpa_bindings.exists() or participant.cpa_claim_plans.exists() or has_cpa_contract_history(participant.id): raise _ParticipantHasSnapshots pool_ids = list( participant.pool_allocations.order_by().values_list( diff --git a/backend/monitor/views/readonly_api.py b/backend/monitor/views/readonly_api.py index 1dfb64c..ea6bcd6 100644 --- a/backend/monitor/views/readonly_api.py +++ b/backend/monitor/views/readonly_api.py @@ -14,6 +14,8 @@ API_ENDPOINTS = [ + {"method": "GET", "path": "/api/v1/cpa/summary", "description": "读取 CPA 同车成员汇总,按池成员与账号授权限制。", "page_permission": (PagePermission.DASHBOARD, PagePermission.PARTICIPANTS, PagePermission.STATISTICS)}, + {"method": "GET", "path": "/api/v1/cpa/requests", "description": "分页读取本人授权参与者的 CPA 请求与 Key 分项。", "page_permission": PagePermission.STATISTICS}, { "method": "GET", "path": "/api/v1/accounts", diff --git a/backend/monitor/views/settings.py b/backend/monitor/views/settings.py index fbadd83..5a78cc3 100644 --- a/backend/monitor/views/settings.py +++ b/backend/monitor/views/settings.py @@ -323,7 +323,7 @@ def post(self, request): AccountParticipant.objects.bulk_create( [ AccountParticipant(account=account, participant=participant) - for participant in Participant.objects.order_by("id") + for participant in Participant.objects.filter(sub2api_user_id__isnull=False).order_by("id") ] ) else: diff --git a/backend/monitor/views/statistics.py b/backend/monitor/views/statistics.py index f6d71e0..dd1fcb6 100644 --- a/backend/monitor/views/statistics.py +++ b/backend/monitor/views/statistics.py @@ -9,6 +9,7 @@ from rest_framework.permissions import IsAuthenticated +from ..cpa.reporting import pool_summary from .base import PageAccessAPIView, error, ok from .query_params import bounded_query_int, monitored_account_query from ..access import visible_participants_for @@ -93,6 +94,7 @@ def get(self, request): now, cost_breakdowns, ), + "cpa_summary": pool_summary(request.user, account, config) if account.provider == "cpa" else None, "usage_days": usage_days, "usage_precision": usage_precision, "sample_interval_minutes": config.local_poll_minutes, @@ -110,6 +112,7 @@ def get(self, request): ), "cpa_api_key_series": ( cpa_api_key_usage_series( + user=request.user, config=config, account=account, location=location, diff --git a/docs/architecture.md b/docs/architecture.md index 97a4e3e..b134565 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ CPA usage 订阅 ───┼─> 采样证据 ─> 区间识别 ─> 账本重 - `monitor.integrations.sub2api`:访问 Sub2API;普通采样读取请求日志时严格校验 exact_total、分页游标、返回行数和重复 ID。 - `monitor.integrations.cpa` 与 `monitor.cpa`:读取 CPA Codex 账号和七天额度,并在同端口 RESP usage 订阅成功后,将活动 session 与每账号连接事件原子写入独立 SQLite spool;业务库以 `CPAAccountCollectionInterval` 独立保存连接覆盖,不从百分比观测推断连接状态。opening/closing 是可选整数百分比样本,人工排除不改变采集区间;正常结束由最终 RESP barrier 确认可靠截止点,异常退出按最后心跳恢复不可靠截止点且不伪造观测。只保存订阅成功后的直播事件,不读取 CPA FIFO。原子批量持久化不可删除的 Token/服务档位事实;延迟事件会刷新并重放其后的百分比观测,读取与重放时按当前本地价格计算成本,不保存原始 API Key。 -- `monitor.sampling`:按 `UsageSamplePoint` 原子采集并保存来源事实,不决定参与者归属;普通 participant CRUD 不追溯改写历史。 +- `monitor.sampling`:按 `UsageSamplePoint` 原子采集并保存来源事实,不决定参与者归属;普通 participant CRUD 不追溯改写历史;CPA 历史认领通过单独预览与确认流程保存归属区间,冻结的 CPA 合同决定历史份额是否已知。 - `monitor.historical_rebuild`:冻结本地全点审计计划,按 plan digest fail closed,并在 lease/fencing 保护下零联网重放派生结果。 - `monitor.history_state`:账号级 fact revision、lease 与 fencing token。 - `monitor.accounting`:识别归属区间,重放时变账本,保存兼容现有读取路径的 legacy live projection。 @@ -40,7 +40,7 @@ CPA usage 订阅 ───┼─> 采样证据 ─> 区间识别 ─> 账本重 3. 当前仍可查询的请求日志不能证明历史 retention 完整;缺失的历史 FAST 或请求数事实保持 unknown。 4. 维护 apply 必须只消费持久化 plan,零联网,并在同一事务内完成全点审计和 legacy projection 重放;不得改写来源成本。 5. 所有来源事实写入由 fact revision、lease 和 fencing token 协调;旧 owner 不得在租约失效后提交。 -6. 算法不能调用 Sub2API 写接口;写余额只允许管理员显式操作。CPA 账号不创建参与者、余额、建议或人工额度调整。 +6. 算法不能调用 Sub2API 写接口;写余额只允许管理员显式操作。CPA Key 通过带生效时间的独立归属记录接入共同参与者模型;CPA 只展示估算权益,不产生余额写入或人工余额建议。 7. 合同权益不参与消费事实推断,不得用当前参与者策略发明历史用户或 policy。 8. 未解释成本必须显式保留,不能静默分给已绑定参与者。 9. 相同原始数据、算法/构建版本和配置必须产生相同 legacy read projection。 diff --git a/docs/cpa-carpool.md b/docs/cpa-carpool.md new file mode 100644 index 0000000..0038965 --- /dev/null +++ b/docs/cpa-carpool.md @@ -0,0 +1,100 @@ +# CPA 拼车 + +CPA 复用 Sub2Pool 的系统用户、参与者和额度池。一个参与者可以只绑定 CPA Key,也可以同时绑定一个 Sub2API 用户;一个人可持有多个 CPA Key。两个渠道的额度池、权益和余额分别计算。 + +## 配置流程 + +1. 在系统设置中配置 CPA Management API 和 usage 订阅连接,添加监控账号。 +2. 在参与者页面添加成员;只用 CPA 时,Sub2API 用户选择“仅使用 CPA”。 +3. 切换到 CPA,选择成员后绑定已采集的 Key,或输入完整 Key 预先绑定;可以填写、修改 Key 备注。 +4. 在额度分配中切换到 CPA,设置独立池或多个 CPA 账号组成的混池,以及各成员份额。池内份额合计不能超过 100%,不得跨渠道混池。 +5. 在系统用户页面创建登录账号,授权对应参与者、CPA 账号及总览/参与者/统计等页面。普通用户同时满足账号授权和池成员关系才能查看该池。 + +绑定从提交时刻生效;解除绑定只关闭归属时间区间,过去的请求仍归原成员。原始 Key 仅用于计算现有 HMAC 摘要,不写入数据库、spool 或返回值。末四位相同的 Key 仍由完整摘要区分。 + +## 历史请求认领 + +绑定后可打开“历史认领”,选择参与者、开始时间和结束时间,先预览请求数、Token、费用、未计价请求和采集缺口,再确认认领。范围采用 `[开始, 结束)`;默认结束时间精确衔接当前绑定的生效时间。 + +预览有效期为 15 分钟,冻结请求事实、Key 归属、价格配置、观测和额度池合同。确认时若这些数据变化,需要重新预览。重复确认已完成的预览不会重复归属。时间范围不能与常规绑定或其他参与者的认领重叠;同一参与者可在原范围再次预览,只认领新收到且尚未归属的请求。 + +认领仅新增归属记录和确认时的事件清单并重算派生额度,不修改 `CPAUsageEvent`。确认后迟到的历史请求不会自动加入认领,需再次预览确认。断线时未采集的请求不会补造。当前份额不会被追溯到历史;历史合同缺失时可以查看请求与消耗,历史剩余权益保持未知。有 Key 归属、认领计划或 CPA 合同历史的参与者只能停用。 + +## 展示口径 + +- 总览和参与者页面显示 CPA 池成员的份额、请求数、Token、请求估算费用,以及可计算时的权益总额、已用权益、剩余权益和超额状态。 +- 个人 Key 的费用轨迹接入现有平均恒定/时变模型;未绑定 Key 的消耗保留在未归属主体中,不分摊给成员。成员在 CPA 的重放不会改动其 Sub2API 全局余额。 +- 多账号池分别依据各账号的上游重置周期计算,再汇总等效美元权益。仅授权部分账号时明确标注部分范围,不暴露其他账号用量。 +- 请求费用按当前本地模型价格重算,不代表 CPA 可扣减余额。未知模型保留请求数和 Token,同时标明未计价;它及采集缺口、有效观测不足会阻止展示可靠剩余额度。 +- 请求更新时间和额度观测时间分别显示。usage 尚在 spool 或采集器未连接时会提示数据延迟。 +- 普通用户能看同车成员汇总,只能看本人授权参与者的 Key 分项和逐次请求。管理员可看全部。逐次请求包含模型、Token、费用、成功/失败、请求标识、耗时和首 Token 延迟,不包含对话正文。 +- 当前仅展示额度和超额提示,不会停用 Key 或拦截调用。 + +## API + +所有路径以 `/api` 为前缀。管理接口使用管理员 JWT;只读接口还提供 `/v1/cpa/summary` 和 `/v1/cpa/requests`,使用 Sub2Pool 个人或管理员 API Key,执行相同的数据范围限制。CPA 调用 Key 不作为面板登录凭据。 + +| 方法与路径 | 用途 | +| --- | --- | +| `GET /cpa/keys` | 管理员读取已登记 Key、绑定区间和未登记的已采集 Key | +| `POST /cpa/keys` | 提交 `participant_id`、可选 `name`,以及二选一的 `raw_key`/`observed_hash` | +| `PATCH /cpa/bindings/{id}` | 修改 Key 的 `name` 备注 | +| `DELETE /cpa/bindings/{id}` | 幂等结束当前绑定,保留历史 | +| `POST /cpa/claims/preview` | 提交 `key_id`、`participant_id`、`started_at`、`ended_at`,返回预览和计划 ID | +| `POST /cpa/claims/{id}/apply` | 确认认领预览,重算受影响账号 | +| `GET /cpa/summary?account_id=...` | 返回该账号所属池在调用者授权范围内的成员汇总 | +| `GET /cpa/requests?account_id=...` | 查询本人/管理员可查看的请求 | + +请求查询支持 `participant_id`、`key_id`、`model`、`failed`、`started_at`、`ended_at`、`page`、`page_size`。默认最近 7 天,最多 90 天;默认每页 50 条,最多 100 条,按请求时间和 ID 倒序。返回的 Key 和模型选项也按调用者权限过滤。 + +`GET /quota-allocation?provider=cpa` 读取 CPA 分配;`PUT /quota-allocation` 的请求体增加 `provider: "cpa"`。省略渠道时仍处理 Sub2API,保存一个渠道不会删除另一个渠道的分配。参与者的 `sub2api_user_id` 现在允许为 `null`。 + +## 升级与后续 + +迁移保留参与者 ID、已有授权和所有原始 CPA 请求,不自动认领历史。现有 Sub2API 参与者无需重新配置。第一次使用 CPA 拼车时按上述流程添加绑定与份额。 + +### 部署当前 CPA 分支 + +本次功能位于 `https://github.com/ld0574/Sub2Pool` 的 `cpa` 分支。该分支的推送不会自动发布镜像;README 中的上游 `ghcr.io/lingyenbird/sub2pool:latest` 不保证包含这次改造。当前可从源码构建。 + +全新安装: + +```bash +git clone --branch cpa https://github.com/ld0574/Sub2Pool.git sub2pool +cd sub2pool +cp .env.example .env +# 编辑 .env:设置随机 DJANGO_SECRET_KEY、管理员密码和域名/IP。 +docker compose up -d --build +docker compose logs --tail=100 app +``` + +已有源码部署:在原部署目录中更新到本仓库的 `cpa` 分支,再执行 `docker compose up -d --build`。保留原 `.env`、Compose 项目名和数据卷挂载;不要重新生成已有实例的 `DJANGO_SECRET_KEY`,加密配置和 CPA Key 摘要依赖它。 + +已有 GHCR 部署:先在另一个目录构建本地镜像,然后在原部署目录增加覆盖文件,以沿用原实例的数据卷和环境变量: + +```bash +git clone --branch cpa https://github.com/ld0574/Sub2Pool.git sub2pool-cpa-src +docker build -t sub2pool-cpa:local ./sub2pool-cpa-src + +# 以下命令在原来存放 compose.yaml 和 .env 的部署目录执行。 +cat > compose.cpa.yaml <<'YAML' +services: + app: + image: sub2pool-cpa:local + pull_policy: never +YAML + +# 停止写入后备份整个数据目录,包含主数据库和 CPA spool。 +backup_dir="./backup-cpa-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$backup_dir" +docker compose stop app +docker compose cp app:/app/data/. "$backup_dir/" +docker compose -f compose.yaml -f compose.cpa.yaml up -d --no-build +docker compose -f compose.yaml -f compose.cpa.yaml logs --tail=100 app +``` + +源码升级也应在重建前使用上述停止与备份命令。若原部署使用额外的 `-p`、`--env-file` 或其他 Compose 文件,升级命令继续使用相同参数。后续使用本地镜像部署时,继续带上 `-f compose.cpa.yaml`。 + +容器启动脚本会自动执行 `migrate`(包括 `0052`、`0053`)、历史重放及管理员初始化,无需手工进入容器迁移。首次升级的历史重放可能延长启动时间,可通过日志确认完成,再访问 `http://服务器IP:8088`(或原访问地址)。升级后按本文配置流程设置 CPA 成员、Key、份额和系统用户授权。 + +第二阶段再验证目标 CPA 版本的 Key 控制能力,选择直接控制或请求网关,实现超额自动限制。 diff --git a/docs/statistics.md b/docs/statistics.md index 1426a1a..84fbcb7 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -38,7 +38,7 @@ $$ ## 账号用量明细 -Sub2API 账号按参与者与上游 API Key 展示已保存的用户用量事实。CPA 账号没有参与者、余额或建议,改为按本地 HMAC 后的 API Key 分组展示请求数、Token 和按当前价格配置动态计算的估算成本;页面只显示末尾四位提示,不能恢复原始 Key。未知模型按 0 美元汇总,并单独显示未计价请求数。 +Sub2API 账号按参与者与上游 API Key 展示已保存的用户用量事实。CPA 账号按参与者及其本地 HMAC 后的 API Key 展示请求数、Token 和按当前价格配置动态计算的估算成本;同车成员只能互看汇总,Key 分项与逐次请求仅本人及管理员可见。CPA 权益由独立渠道额度池分配,不产生上游余额建议;页面只显示末尾四位提示,不能恢复原始 Key。未知模型按 0 美元汇总,并单独显示未计价请求数。 CPA 统计只覆盖监控账号在 RESP 订阅成功后收到并持久化的 usage 事件。订阅期间原有定时百分比请求照常执行;连接建立前与断线期间的请求不推测、不回填。账号连接覆盖由独立采集区间保存,不从百分比观测推断。订阅后的 opening 百分比与取消订阅前的 closing 百分比只是可选数值样本;最终 RESP `PING` 确认截止点前的直播事件已进入持久队列后,独立结束事件关闭采集区间。数据库 busy 时持续重试;异常退出按最后持久心跳记录不可靠截止点且不伪造 closing 观测。断线期间产生的普通观测只保留审计事实,不把缺口当成零成本连续用量。CPA 成本也不会应用 Sub2API FAST 事后修正。 diff --git a/frontend/src/components/common/CPAPoolCard.vue b/frontend/src/components/common/CPAPoolCard.vue new file mode 100644 index 0000000..90cf064 --- /dev/null +++ b/frontend/src/components/common/CPAPoolCard.vue @@ -0,0 +1,221 @@ + + + diff --git a/frontend/src/demo/backend.ts b/frontend/src/demo/backend.ts index 90ad73c..451394a 100644 --- a/frontend/src/demo/backend.ts +++ b/frontend/src/demo/backend.ts @@ -1,3 +1,4 @@ +import { handleCPA } from "./cpa"; import type { DemoState } from "./state"; import { demoIdentity, loadDemoState } from "./state"; import { handleProtectedAuth, handlePublicAuth } from "./handlers/auth"; @@ -40,6 +41,7 @@ export interface DemoRequestContext { type DemoRequestHandler = (context: DemoRequestContext) => Response | null; const protectedHandlers: DemoRequestHandler[] = [ + handleCPA, handleProtectedAuth, handleSecurity, handleDashboard, diff --git a/frontend/src/demo/cpa.ts b/frontend/src/demo/cpa.ts new file mode 100644 index 0000000..f1b6daf --- /dev/null +++ b/frontend/src/demo/cpa.ts @@ -0,0 +1,516 @@ +import type { + CPAKeys, + CPARequest, + CPAPoolSummary, + CPAClaim, +} from "@/types/cpa"; +import type { DemoRequestContext } from "./backend"; +import type { DemoState } from "./state"; +import type { CPAAPIKeyUsageSeries } from "@/types/statistics"; +import { demoIdentity, saveDemoState } from "./state"; + +export interface DemoCPAState { + keys: CPAKeys; + events: (CPARequest & { + participant_id: number | null; + key_id: number; + account_id: number; + })[]; + claims: (CPAClaim & { revision: number })[]; +} + +export function initializeCPADemo(state: DemoState) { + if (state.cpa) return; + const account = { + ...state.monitoredAccounts[0]!, + id: 3, + provider: "cpa" as const, + pool_id: 2, + external_account_id: null, + cpa_auth_index: "demo-codex", + source_account_id: "demo-codex", + name: "CPA 拼车账号", + quota_query_mode: "direct" as const, + }; + state.monitoredAccounts.push(account); + state.quotaPools.push({ + id: 2, + name: "CPA 演示池", + contract_revision: 1, + account_ids: [3], + total_share_percent: 100, + allocations: state.participants.map((p, i) => ({ + participant_id: p.id, + share_percent: [40, 35, 25][i] ?? 0, + })), + }); + state.nextPoolId = Math.max(3, state.nextPoolId); + for (const user of state.systemUsers) { + user.account_ids.push(3); + user.account_names.push(account.name); + } + const anchor = Date.parse(state.clock); + const start = new Date(anchor - 3 * 86400000).toISOString(); + const keys: CPAKeys = { + keys: state.participants.map((p, i) => ({ + id: i + 1, + name: ["工作电脑", "笔记本", "开发测试"][i] ?? "演示 Key", + hint: String(1001 + i), + observed_hash: String(i + 1).padStart(64, "0"), + bindings: [ + { + id: i + 1, + key_id: i + 1, + name: ["工作电脑", "笔记本", "开发测试"][i] ?? "演示 Key", + hint: String(1001 + i), + participant_id: p.id, + participant_name: p.name, + started_at: start, + ended_at: null, + }, + ], + })), + unregistered: [{ observed_hash: "4".padStart(64, "0"), hint: "1004" }], + }; + const events: DemoCPAState["events"] = Array.from({ length: 84 }, (_, i) => { + const owner = i % 4; + return { + id: i + 1, + account_id: 3, + participant_id: state.participants[owner]?.id ?? null, + key_id: owner + 1, + occurred_at: new Date(anchor - (84 - i) * 1800000).toISOString(), + request_id: `demo-cpa-request-${i + 1}`, + api_key_hint: String(1001 + owner), + model: "gpt-5.4", + endpoint: "/v1/responses", + input_tokens: 9000 + i * 70, + cached_input_tokens: 3000, + output_tokens: 1100 + i * 13, + reasoning_tokens: 350, + total_tokens: 10100 + i * 83, + failed: i % 17 === 0, + latency_ms: 1800 + i * 15, + ttft_ms: 210 + i, + usage_usd: Number((0.14 + i * 0.004).toFixed(4)), + unpriced: false, + requested_service_tier: "", + response_service_tier: "default", + }; + }); + state.cpa = { keys, events, claims: [] }; +} + +function ownIds(state: DemoState) { + const identity = demoIdentity(); + if (identity?.is_staff) return null; + return ( + state.systemUsers.find((user) => user.username === identity?.username) + ?.participant_ids ?? [] + ); +} + +function canRead(state: DemoState, accountId: number) { + const identity = demoIdentity(); + if (identity?.is_staff) return true; + const user = state.systemUsers.find((u) => u.username === identity?.username); + const account = state.monitoredAccounts.find((a) => a.id === accountId); + const pool = state.quotaPools.find((p) => p.id === account?.pool_id); + return Boolean( + user?.account_ids.includes(accountId) && + pool?.allocations.some((a) => + user.participant_ids.includes(a.participant_id), + ), + ); +} + +export function demoCPASummary( + state: DemoState, + accountId: number, +): CPAPoolSummary | null { + const account = state.monitoredAccounts.find( + (a) => a.id === accountId && a.provider === "cpa", + ); + const pool = state.quotaPools.find((p) => p.id === account?.pool_id); + if (!account || !pool || !canRead(state, accountId)) return null; + const own = ownIds(state); + const sum = (events: DemoCPAState["events"]) => ({ + usage_usd: events.reduce((total, e) => total + e.usage_usd, 0), + request_count: events.length, + token_count: events.reduce((total, e) => total + e.total_tokens, 0), + unpriced_request_count: events.filter((e) => e.unpriced).length, + }); + const events = state.cpa!.events.filter((e) => e.account_id === accountId); + const members = pool.allocations.flatMap((allocation) => { + const person = state.participants.find( + (p) => p.id === allocation.participant_id, + ); + if (!person) return []; + const totals = sum(events.filter((e) => e.participant_id === person.id)); + const expected = allocation.share_percent * 20; + const remaining = expected - totals.usage_usd; + return [ + { + ...totals, + participant_id: person.id, + participant_name: person.name, + is_self: own != null && own.includes(person.id), + share_percent: allocation.share_percent, + quota_available: true, + is_overused: remaining < 0, + expected_entitlement_usd: expected, + consumed_entitlement_usd: totals.usage_usd, + remaining_entitlement_usd: remaining, + account_breakdowns: [ + { + account_id: accountId, + quota_available: true, + quota_as_of: state.clock, + charged_percent: totals.usage_usd / 20, + remaining_share_percent: Math.max(0, remaining / 20), + usage_usd: totals.usage_usd, + estimated_capacity_usd: 2000, + expected_entitlement_usd: expected, + consumed_entitlement_usd: totals.usage_usd, + remaining_entitlement_usd: remaining, + }, + ], + }, + ]; + }); + return { + pool_id: pool.id, + pool_name: pool.name, + selected_account_id: accountId, + partial_scope: false, + members, + accounts: [ + { + ...sum(events), + account_id: accountId, + account_name: account.name, + selected: true, + quota_as_of: state.clock, + requests_as_of: events.at(-1)?.occurred_at ?? null, + cycle_started_at: new Date( + Date.parse(state.clock) - 3 * 86400000, + ).toISOString(), + resets_at: new Date( + Date.parse(state.clock) + 4 * 86400000, + ).toISOString(), + coverage: { complete: true, uncertain_end: false, gaps: [] }, + quota_available: true, + }, + ], + unattributed: sum(events.filter((e) => e.participant_id == null)), + cost_estimate: true, + enforcement_enabled: false, + generated_at: state.clock, + collector: { + state: "connected", + connected: true, + stale: false, + connected_at: state.clock, + heartbeat_at: state.clock, + last_message_at: state.clock, + last_persisted_at: state.clock, + pending_count: 0, + last_error: "", + last_error_at: null, + }, + }; +} + +export function demoCPAKeySeries( + state: DemoState, + accountId: number, + days: number, + precision: "raw" | "hour" | "day", +): CPAAPIKeyUsageSeries[] { + if (!canRead(state, accountId)) return []; + const own = ownIds(state); + const cutoff = Date.parse(state.clock) - days * 86400000; + const groups = new Map(); + const buckets = new Map(); + const format = new Intl.DateTimeFormat("sv-SE", { + timeZone: String(state.settings.timezone), + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + for (const event of state.cpa!.events) { + if ( + event.account_id !== accountId || + Date.parse(event.occurred_at) < cutoff || + (own && + (event.participant_id == null || !own.includes(event.participant_id))) + ) + continue; + let series = groups.get(event.key_id); + if (!series) { + const key = state.cpa!.keys.keys.find((k) => k.id === event.key_id); + series = { + api_key_id: `demo-key-${event.key_id}`, + api_key_name: `${key?.name || "API Key"} ····${event.api_key_hint}`, + total_usage_usd: 0, + request_count: 0, + token_count: 0, + unpriced_request_count: 0, + points: [], + }; + groups.set(event.key_id, series); + } + series.total_usage_usd += event.usage_usd; + series.request_count++; + series.token_count += event.total_tokens; + series.unpriced_request_count += Number(event.unpriced); + const local = format.format(new Date(event.occurred_at)); + const label = + precision === "day" + ? local.slice(0, 10) + : precision === "hour" + ? `${local.slice(0, 13)}:00` + : local; + const bucketId = `${event.key_id}:${precision === "raw" ? event.id : label}`; + let point = buckets.get(bucketId); + if (!point) { + point = { + observed_at: event.occurred_at, + label, + usage_usd: 0, + request_count: 0, + token_count: 0, + }; + buckets.set(bucketId, point); + series.points.push(point); + } + point.usage_usd += event.usage_usd; + point.request_count++; + point.token_count += event.total_tokens; + } + return [...groups.values()]; +} + +export function handleCPA({ + state, + pathname, + method, + payload, + url, + ok, + fail, +}: DemoRequestContext): Response | null { + if (!pathname.startsWith("cpa/")) return null; + const cpa = state.cpa!; + const admin = demoIdentity()?.is_staff; + const own = ownIds(state); + if (pathname === "cpa/summary" || pathname === "cpa/requests") { + const accountId = Number(url.searchParams.get("account_id")); + if (!canRead(state, accountId)) return fail("CPA 账号未授权", 403); + if (pathname === "cpa/summary") return ok(demoCPASummary(state, accountId)); + let events = cpa.events.filter( + (e) => + e.account_id === accountId && + (own == null || + (e.participant_id != null && own.includes(e.participant_id))), + ); + const hashes = new Set(events.map((e) => e.key_id)); + const keys = cpa.keys.keys + .filter((k) => hashes.has(k.id)) + .map((k) => ({ id: k.id, name: k.name, hint: k.hint })); + const models = [...new Set(events.map((e) => e.model))]; + const params = url.searchParams; + if (params.get("key_id")) + events = events.filter((e) => e.key_id === Number(params.get("key_id"))); + if (params.get("model")) + events = events.filter((e) => e.model === params.get("model")); + if (params.has("failed")) + events = events.filter( + (e) => e.failed === (params.get("failed") === "true"), + ); + if (params.get("started_at")) + events = events.filter( + (e) => + Date.parse(e.occurred_at) >= Date.parse(params.get("started_at")!), + ); + if (params.get("ended_at")) + events = events.filter( + (e) => Date.parse(e.occurred_at) < Date.parse(params.get("ended_at")!), + ); + events.sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)); + const page = Math.max(1, Number(params.get("page") ?? 1)); + const size = Math.min( + 100, + Math.max(1, Number(params.get("page_size") ?? 50)), + ); + return ok({ + account_id: accountId, + items: events.slice((page - 1) * size, page * size), + total: events.length, + page, + page_size: size, + keys, + models, + generated_at: state.clock, + cost_estimate: true, + }); + } + if (!admin) return fail("仅管理员可管理 CPA Key", 403); + if (pathname === "cpa/keys" && method === "GET") return ok(cpa.keys); + if (pathname === "cpa/keys" && method === "POST") { + const person = state.participants.find( + (p) => p.id === Number(payload.participant_id), + ); + if (!person) return fail("请选择参与者"); + const hash = String(payload.observed_hash ?? ""); + let key = cpa.keys.keys.find((k) => k.observed_hash === hash); + if (key?.bindings.some((b) => !b.ended_at)) return fail("该 Key 已绑定"); + if (!key) { + const observed = cpa.keys.unregistered.find( + (k) => k.observed_hash === hash, + ); + const id = Math.max(0, ...cpa.keys.keys.map((k) => k.id)) + 1; + key = { + id, + name: String(payload.name ?? ""), + hint: observed?.hint ?? String(payload.raw_key ?? "").slice(-4), + observed_hash: hash || String(id).padStart(64, "0"), + bindings: [], + }; + cpa.keys.keys.push(key); + cpa.keys.unregistered = cpa.keys.unregistered.filter( + (k) => k.observed_hash !== hash, + ); + } + const binding = { + id: + Math.max( + 0, + ...cpa.keys.keys.flatMap((k) => k.bindings.map((b) => b.id)), + ) + 1, + key_id: key.id, + hint: key.hint, + name: key.name, + participant_id: person.id, + participant_name: person.name, + started_at: state.clock, + ended_at: null, + }; + key.bindings.push(binding); + state.revision++; + saveDemoState(state); + return ok(binding, 201); + } + const bindingMatch = /^cpa\/bindings\/(\d+)$/.exec(pathname); + if (bindingMatch && method === "PATCH") { + const key = cpa.keys.keys.find((k) => + k.bindings.some((b) => b.id === Number(bindingMatch[1])), + ); + if (!key) return fail("绑定不存在", 404); + key.name = String(payload.name ?? ""); + for (const binding of key.bindings) binding.name = key.name; + saveDemoState(state); + return ok(key.bindings.find((b) => b.id === Number(bindingMatch[1]))); + } + if (bindingMatch && method === "DELETE") { + const binding = cpa.keys.keys + .flatMap((k) => k.bindings) + .find((b) => b.id === Number(bindingMatch[1])); + if (!binding) return fail("绑定不存在", 404); + binding.ended_at ??= state.clock; + state.revision++; + saveDemoState(state); + return ok(binding); + } + if (pathname === "cpa/claims/preview" && method === "POST") { + const start = String(payload.started_at); + const end = String(payload.ended_at); + const key = cpa.keys.keys.find((k) => k.id === Number(payload.key_id)); + if (!key || !(Date.parse(start) < Date.parse(end))) + return fail("请选择有效时间范围"); + if ( + key.bindings.some( + (b) => + Date.parse(b.started_at) < Date.parse(end) && + (!b.ended_at || Date.parse(b.ended_at) > Date.parse(start)), + ) + ) + return fail("此范围已有归属"); + const events = cpa.events.filter( + (e) => + e.key_id === key.id && + Date.parse(e.occurred_at) >= Date.parse(start) && + Date.parse(e.occurred_at) < Date.parse(end), + ); + if (!events.length) return fail("此范围没有已采集请求"); + const plan = { + id: `demo-${cpa.claims.length + 1}`, + key_id: key.id, + participant_id: Number(payload.participant_id), + started_at: start, + ended_at: end, + expires_at: new Date(Date.now() + 900000).toISOString(), + applied_at: null, + revision: state.revision, + historical_contract_policy: "演示:缺少历史份额时不补造历史权益。", + accounts: [ + { + account_id: 3, + account_name: "CPA 拼车账号", + usage_usd: events.reduce((v, e) => v + e.usage_usd, 0), + request_count: events.length, + token_count: events.reduce((v, e) => v + e.total_tokens, 0), + unpriced_request_count: 0, + coverage: { complete: true, uncertain_end: false, gaps: [] }, + }, + ], + }; + cpa.claims.push(plan); + saveDemoState(state); + return ok(plan, 201); + } + const claimMatch = /^cpa\/claims\/([^/]+)\/apply$/.exec(pathname); + if (claimMatch && method === "POST") { + const plan = cpa.claims.find((p) => p.id === claimMatch[1]); + if (!plan) return fail("预览不存在", 404); + if (plan.applied_at) return ok(plan); + if ( + plan.revision !== state.revision || + Date.parse(plan.expires_at) <= Date.now() + ) + return fail("预览已变化,请重新预览"); + for (const item of cpa.events) + if ( + item.key_id === plan.key_id && + Date.parse(item.occurred_at) >= Date.parse(plan.started_at) && + Date.parse(item.occurred_at) < Date.parse(plan.ended_at) + ) + item.participant_id = plan.participant_id; + const key = cpa.keys.keys.find((k) => k.id === plan.key_id)!; + key.bindings.push({ + id: + Math.max( + 0, + ...cpa.keys.keys.flatMap((k) => k.bindings.map((b) => b.id)), + ) + 1, + key_id: key.id, + hint: key.hint, + name: key.name, + participant_id: plan.participant_id, + participant_name: + state.participants.find((p) => p.id === plan.participant_id)?.name ?? + "", + started_at: plan.started_at, + ended_at: plan.ended_at, + }); + plan.applied_at = state.clock; + state.revision++; + saveDemoState(state); + return ok(plan); + } + return fail("CPA 演示接口不存在", 404); +} diff --git a/frontend/src/demo/fixtures.ts b/frontend/src/demo/fixtures.ts index dc0ce3d..536dea1 100644 --- a/frontend/src/demo/fixtures.ts +++ b/frontend/src/demo/fixtures.ts @@ -369,7 +369,7 @@ function initializeState(): DemoState { aggregateParticipant(participant); } return { - version: 15, + version: 16, clock: iso(DEMO_ANCHOR), nextParticipantId: 4, nextPoolId: 2, @@ -380,13 +380,20 @@ function initializeState(): DemoState { participants, monitoredAccounts, quotaPools, - sub2apiUsers: participants.map((participant) => ({ - id: participant.sub2api_user_id, - email: participant.sub2api_email, - username: participant.sub2api_username, - status: "active", - role: participant.is_owner ? "admin" : "user", - })), + sub2apiUsers: participants + .filter( + ( + participant, + ): participant is Participant & { sub2api_user_id: number } => + participant.sub2api_user_id != null, + ) + .map((participant) => ({ + id: participant.sub2api_user_id, + email: participant.sub2api_email, + username: participant.sub2api_username, + status: "active", + role: participant.is_owner ? "admin" : "user", + })), systemUsers: [ { id: 1, diff --git a/frontend/src/demo/handlers/dashboard.ts b/frontend/src/demo/handlers/dashboard.ts index 6222119..b8fd171 100644 --- a/frontend/src/demo/handlers/dashboard.ts +++ b/frontend/src/demo/handlers/dashboard.ts @@ -1,3 +1,4 @@ +import { demoCPASummary } from "../cpa"; import type { QuotaAllocationData, QuotaAllocationWritePool, @@ -13,9 +14,12 @@ import { } from "../state"; import { participantBreakdowns } from "./participants"; -function quotaAllocationData(state: DemoState): QuotaAllocationData { +function quotaAllocationData( + state: DemoState, + provider = "sub2api", +): QuotaAllocationData { return { - accounts: state.monitoredAccounts, + accounts: state.monitoredAccounts.filter((a) => a.provider === provider), participants: state.participants.map((participant) => ({ id: participant.id, name: participant.name, @@ -26,7 +30,13 @@ function quotaAllocationData(state: DemoState): QuotaAllocationData { is_owner: participant.is_owner, enabled: participant.enabled, })), - pools: state.quotaPools, + pools: state.quotaPools.filter((p) => + p.account_ids.some((id) => + state.monitoredAccounts.some( + (a) => a.id === id && a.provider === provider, + ), + ), + ), }; } @@ -42,13 +52,23 @@ function quotaPoolSignature(pool: { return JSON.stringify([pool.name, accountIds, allocations]); } -function applyQuotaAllocation(state: DemoState, value: unknown): string | null { +function applyQuotaAllocation( + state: DemoState, + value: unknown, + provider = "sub2api", +): string | null { if (!Array.isArray(value)) return "额度池列表格式无效"; - const accountIds = new Set(state.monitoredAccounts.map((item) => item.id)); + const accountIds = new Set( + state.monitoredAccounts + .filter((a) => a.provider === provider) + .map((item) => item.id), + ); const participantIds = new Set(state.participants.map((item) => item.id)); const existingPools = new Map( - state.quotaPools.map((pool) => [pool.id, pool]), + state.quotaPools + .filter((p) => p.account_ids.some((id) => accountIds.has(id))) + .map((pool) => [pool.id, pool]), ); const seenAccountIds = new Set(); const seenPoolIds = new Set(); @@ -174,7 +194,12 @@ function applyQuotaAllocation(state: DemoState, value: unknown): string | null { }; }); - state.quotaPools = nextPools; + state.quotaPools = [ + ...state.quotaPools.filter( + (p) => !p.account_ids.some((id) => accountIds.has(id)), + ), + ...nextPools, + ]; for (const pool of nextPools) { for (const accountId of pool.account_ids) { const account = state.monitoredAccounts.find( @@ -184,22 +209,30 @@ function applyQuotaAllocation(state: DemoState, value: unknown): string | null { } } for (const participant of state.participants) { - participant.pool_allocations = nextPools.flatMap((pool) => { - const allocation = pool.allocations.find( - (item) => item.participant_id === participant.id, - ); - return allocation - ? [ - { - pool_id: pool.id, - pool_name: pool.name, - share_percent: allocation.share_percent, - account_ids: [...pool.account_ids], - account_count: pool.account_ids.length, - }, - ] - : []; - }); + participant.pool_allocations = state.quotaPools + .filter((pool) => + pool.account_ids.some((id) => + state.monitoredAccounts.some( + (a) => a.id === id && a.provider === "sub2api", + ), + ), + ) + .flatMap((pool) => { + const allocation = pool.allocations.find( + (item) => item.participant_id === participant.id, + ); + return allocation + ? [ + { + pool_id: pool.id, + pool_name: pool.name, + share_percent: allocation.share_percent, + account_ids: [...pool.account_ids], + account_count: pool.account_ids.length, + }, + ] + : []; + }); participant.account_breakdowns = participantBreakdowns( state, participant.id, @@ -221,7 +254,10 @@ export function handleDashboard({ }: DemoRequestContext): Response | null { if (method === "GET" && pathname === "dashboard") { const accountId = Number(url.searchParams.get("account_id")); - return ok(dashboardData(state, accountId)); + return ok({ + ...dashboardData(state, accountId), + cpa_summary: demoCPASummary(state, accountId), + }); } if (pathname === "monitor/run" && method === "GET") { const enabledAccounts = state.monitoredAccounts.filter( @@ -293,13 +329,21 @@ export function handleDashboard({ return ok({ applied: true }); } if (method === "GET" && pathname === "quota-allocation") { - return ok(quotaAllocationData(state)); + return ok( + quotaAllocationData(state, url.searchParams.get("provider") ?? "sub2api"), + ); } if (method === "PUT" && pathname === "quota-allocation") { - const validationError = applyQuotaAllocation(state, payload.pools); + const validationError = applyQuotaAllocation( + state, + payload.pools, + String(payload.provider ?? "sub2api"), + ); if (validationError) return fail(validationError); saveDemoState(state); - return ok(quotaAllocationData(state)); + return ok( + quotaAllocationData(state, String(payload.provider ?? "sub2api")), + ); } return null; } diff --git a/frontend/src/demo/handlers/observations.ts b/frontend/src/demo/handlers/observations.ts index 1970a42..b0aa66c 100644 --- a/frontend/src/demo/handlers/observations.ts +++ b/frontend/src/demo/handlers/observations.ts @@ -90,29 +90,31 @@ function fastCorrectionData( observation.legacy_fast_only ?? observation.fast_correction_calculated, corrected_fast_cost_usd: fastCost * 3, collection_error: "", - users: state.participants.map((participant, index) => { - const requestCount = Math.round( - totalRequests * [0.39, 0.35, 0.26][index], - ); - const participantFast = Math.round(requestCount * 0.34); - const billed = fastCost * [0.39, 0.35, 0.26][index] * 2; - return { - sub2api_user_id: participant.sub2api_user_id, - username: participant.sub2api_username, - email: participant.sub2api_email, - display_name: participant.name, - request_count: requestCount, - fast_request_count: participantFast, - non_fast_request_count: requestCount - participantFast, - fast_billed_cost_usd: billed, - correction_usd: billed * 0.5, - fast_correction_usd: billed * 0.5, - correction_total_usd: billed * 0.5, - long_context_correction_usd: 0, - model_correction_usd: 0, - corrected_fast_cost_usd: billed * 1.5, - }; - }), + users: state.participants + .filter((p) => p.sub2api_user_id != null) + .map((participant, index) => { + const requestCount = Math.round( + totalRequests * [0.39, 0.35, 0.26][index], + ); + const participantFast = Math.round(requestCount * 0.34); + const billed = fastCost * [0.39, 0.35, 0.26][index] * 2; + return { + sub2api_user_id: participant.sub2api_user_id!, + username: participant.sub2api_username, + email: participant.sub2api_email, + display_name: participant.name, + request_count: requestCount, + fast_request_count: participantFast, + non_fast_request_count: requestCount - participantFast, + fast_billed_cost_usd: billed, + correction_usd: billed * 0.5, + fast_correction_usd: billed * 0.5, + correction_total_usd: billed * 0.5, + long_context_correction_usd: 0, + model_correction_usd: 0, + corrected_fast_cost_usd: billed * 1.5, + }; + }), }; } diff --git a/frontend/src/demo/handlers/participants.ts b/frontend/src/demo/handlers/participants.ts index b971a62..5db8cdc 100644 --- a/frontend/src/demo/handlers/participants.ts +++ b/frontend/src/demo/handlers/participants.ts @@ -66,7 +66,10 @@ export function handleParticipants({ id, name: String(payload.name ?? `演示参与者 ${id}`), email: String(payload.email ?? `participant-${id}@example.test`), - sub2api_user_id: Number(payload.sub2api_user_id ?? 100 + id), + sub2api_user_id: + payload.sub2api_user_id == null + ? null + : Number(payload.sub2api_user_id), sub2api_username: String(payload.sub2api_username ?? `demo-user-${id}`), sub2api_email: String( payload.sub2api_email ?? `participant-${id}@example.test`, @@ -96,9 +99,10 @@ export function handleParticipants({ Object.assign(participant, { name: String(payload.name ?? participant.name), email: String(payload.email ?? participant.email), - sub2api_user_id: Number( - payload.sub2api_user_id ?? participant.sub2api_user_id, - ), + sub2api_user_id: + payload.sub2api_user_id === null + ? null + : Number(payload.sub2api_user_id ?? participant.sub2api_user_id), sub2api_username: String( payload.sub2api_username ?? participant.sub2api_username, ), diff --git a/frontend/src/demo/handlers/reporting.ts b/frontend/src/demo/handlers/reporting.ts index d169b7e..3979ac1 100644 --- a/frontend/src/demo/handlers/reporting.ts +++ b/frontend/src/demo/handlers/reporting.ts @@ -1,3 +1,4 @@ +import { demoCPAKeySeries, demoCPASummary } from "../cpa"; import type { AccountStatusData } from "@/types/accounts"; import type { Observation } from "@/types/observations"; import type { ParticleTrajectoryData } from "@/types/particleTrajectory"; @@ -87,7 +88,10 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { state.monitoredAccounts.find((item) => item.enabled) ?? state.monitoredAccounts[0]!; if (account.provider === "cpa") { + const summary = demoCPASummary(state, account.id); + const cycle = summary?.accounts[0]; return { + cpa_summary: summary, account: { id: account.id, provider: account.provider, @@ -99,7 +103,35 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { capacity_series: [], fast_correction_enabled: false, capacity_summary: { - cycle: null, + cycle: cycle + ? { + estimate_usd: 2000, + raw_estimate_usd: 2000, + start_cost_usd: 0, + start_cost_breakdown: { + sub2api_cost_usd: 0, + fast_correction_usd: 0, + total_cost_usd: 0, + }, + start_percent: 0, + end_cost_usd: cycle.usage_usd, + end_cost_breakdown: { + sub2api_cost_usd: cycle.usage_usd, + fast_correction_usd: 0, + total_cost_usd: cycle.usage_usd, + }, + end_percent: cycle.usage_usd / 20, + cost_usd: cycle.usage_usd, + used_percent: cycle.usage_usd / 20, + effective_usd_per_percent: 20, + calculation_model: "endpoint_ratio", + rate_calculated: true, + confidence: "中", + observed_at: state.clock, + starts_at: cycle.cycle_started_at, + resets_at: cycle.resets_at!, + } + : null, today: { estimate_usd: null, minimum_usd: null, @@ -117,14 +149,19 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { observed_to: null, min_percent_span: 3, sufficient: false, - reason: "连接后尚无 CPA 观测记录", + reason: "演示数据的日内百分比跨度不足", }, }, usage_days: usageDays, usage_precision: usagePrecision, sample_interval_minutes: Number(state.settings.local_poll_minutes), participant_series: [], - cpa_api_key_series: [], + cpa_api_key_series: demoCPAKeySeries( + state, + account.id, + usageDays, + usagePrecision, + ), }; } const now = Date.parse(state.clock); @@ -307,13 +344,13 @@ function statisticsData(state: DemoState, url: URL): StatisticsData { participant_series: account.provider === "sub2api" ? state.participants - .filter((item) => item.enabled) + .filter((item) => item.enabled && item.sub2api_user_id != null) .map((participant) => ({ participant_id: participant.id, participant_name: participant.name, account_id: account.id, external_account_id: account.external_account_id!, - sub2api_user_id: participant.sub2api_user_id, + sub2api_user_id: participant.sub2api_user_id!, points: participantUsagePoints( state, participant.id, diff --git a/frontend/src/demo/selectors.ts b/frontend/src/demo/selectors.ts index 6204c34..d0737c8 100644 --- a/frontend/src/demo/selectors.ts +++ b/frontend/src/demo/selectors.ts @@ -217,7 +217,7 @@ export function apiUsageData( return { participant_id: participant.id, participant_name: participant.name, - sub2api_user_id: participant.sub2api_user_id, + sub2api_user_id: participant.sub2api_user_id!, starts_at: period.startedAt, observed_to: latest.observed_at, cost_basis: "actual", diff --git a/frontend/src/demo/state.ts b/frontend/src/demo/state.ts index cefa0e7..a51eaf8 100644 --- a/frontend/src/demo/state.ts +++ b/frontend/src/demo/state.ts @@ -1,3 +1,4 @@ +import { initializeCPADemo, type DemoCPAState } from "./cpa"; import type { PagePermission } from "@/config/pagePermissions"; import type { MonitoredAccount } from "@/types/accounts"; import type { Observation } from "@/types/observations"; @@ -53,7 +54,8 @@ export interface DemoPeriod { } export interface DemoState { - version: 15; + cpa?: DemoCPAState; + version: 16; clock: string; nextParticipantId: number; nextPoolId: number; @@ -81,12 +83,16 @@ export function loadDemoState(): DemoState { if (stored) { try { const parsed = JSON.parse(stored) as DemoState; - if (parsed.version === 15) return parsed; + if (parsed.version === 16) { + initializeCPADemo(parsed); + return parsed; + } } catch { sessionStorage.removeItem(DEMO_STATE_KEY); } } const initial = initializeState(); + initializeCPADemo(initial); saveDemoState(initial); return initial; } @@ -98,6 +104,7 @@ export function saveDemoState(state: DemoState): void { export function resetDemoState(): DemoState { sessionStorage.removeItem(DEMO_STATE_KEY); const state = initializeState(); + initializeCPADemo(state); saveDemoState(state); window.dispatchEvent(new CustomEvent("sub2pool:demo-reset")); return state; diff --git a/frontend/src/demo/trajectoryFixtures.ts b/frontend/src/demo/trajectoryFixtures.ts index 5138fd9..76acb16 100644 --- a/frontend/src/demo/trajectoryFixtures.ts +++ b/frontend/src/demo/trajectoryFixtures.ts @@ -98,7 +98,8 @@ function buildPeriods(participants: Participant[]): { } { const observations: Observation[] = []; const periods: DemoPeriod[] = []; - const counts = [188, 216, 203, 224, 207]; + // Leave room in sessionStorage for CPA requests and interactive demo edits. + const counts = [94, 108, 102, 112, 104]; let observationId = 1; for (let periodIndex = 0; periodIndex < counts.length; periodIndex += 1) { diff --git a/frontend/src/types/cpa.ts b/frontend/src/types/cpa.ts new file mode 100644 index 0000000..a642942 --- /dev/null +++ b/frontend/src/types/cpa.ts @@ -0,0 +1,124 @@ +import type { CPACollectorStatus } from "./settings"; + +export interface CPATotals { + usage_usd: number; + request_count: number; + token_count: number; + unpriced_request_count: number; +} +export interface CPACoverage { + complete: boolean; + uncertain_end: boolean; + gaps: { started_at: string; ended_at: string }[]; +} +export interface CPAMember extends CPATotals { + participant_id: number; + participant_name: string; + is_self: boolean; + share_percent: number | null; + quota_available: boolean; + is_overused: boolean; + expected_entitlement_usd: number | null; + consumed_entitlement_usd: number | null; + remaining_entitlement_usd: number | null; + account_breakdowns: { + account_id: number; + quota_available: boolean; + quota_as_of: string | null; + charged_percent: number | null; + remaining_share_percent: number | null; + usage_usd: number; + estimated_capacity_usd: number | null; + expected_entitlement_usd: number | null; + consumed_entitlement_usd: number | null; + remaining_entitlement_usd: number | null; + }[]; +} +export interface CPAPoolSummary { + pool_id: number; + pool_name: string; + selected_account_id: number; + partial_scope: boolean; + accounts: (CPATotals & { + account_id: number; + account_name: string; + selected: boolean; + quota_as_of: string | null; + requests_as_of: string | null; + cycle_started_at: string; + resets_at: string | null; + coverage: CPACoverage; + quota_available: boolean; + })[]; + members: CPAMember[]; + unattributed: CPATotals; + collector: CPACollectorStatus; + cost_estimate: boolean; + enforcement_enabled: boolean; + generated_at: string; +} +export interface CPABinding { + id: number; + key_id: number; + hint: string; + name: string; + participant_id: number; + participant_name: string; + started_at: string; + ended_at: string | null; +} +export interface CPAKeys { + keys: { + id: number; + name: string; + hint: string; + observed_hash: string; + bindings: CPABinding[]; + }[]; + unregistered: { observed_hash: string; hint: string }[]; +} +export interface CPAClaim { + id: string; + key_id: number; + participant_id: number; + started_at: string; + ended_at: string; + expires_at: string; + applied_at: string | null; + historical_contract_policy: string; + accounts: (CPATotals & { + account_id: number; + account_name: string; + coverage: CPACoverage; + })[]; +} +export interface CPARequest { + id: number; + occurred_at: string; + request_id: string; + api_key_hint: string; + model: string; + endpoint: string; + input_tokens: number; + cached_input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + total_tokens: number; + failed: boolean; + latency_ms: number; + ttft_ms: number; + usage_usd: number; + unpriced: boolean; + requested_service_tier: string; + response_service_tier: string; +} +export interface CPARequests { + account_id: number; + items: CPARequest[]; + total: number; + page: number; + page_size: number; + keys: { id: number; name: string; hint: string }[]; + models: string[]; + generated_at: string; +} diff --git a/frontend/src/types/dashboard.ts b/frontend/src/types/dashboard.ts index e5a2721..af6e672 100644 --- a/frontend/src/types/dashboard.ts +++ b/frontend/src/types/dashboard.ts @@ -1,3 +1,4 @@ +import type { CPAPoolSummary } from "./cpa"; import type { MonitoredAccount } from "./accounts"; import type { CostBreakdown } from "./common"; import type { Participant } from "./participants"; @@ -35,6 +36,7 @@ export interface ModelDiagnostics { }; } export interface DashboardData { + cpa_summary?: CPAPoolSummary | null; configured: boolean; monitoring_enabled: boolean; accounts: MonitoredAccount[]; diff --git a/frontend/src/types/participants.ts b/frontend/src/types/participants.ts index 94f2507..902c6f9 100644 --- a/frontend/src/types/participants.ts +++ b/frontend/src/types/participants.ts @@ -1,9 +1,10 @@ import type { MonitoredAccount } from "./accounts"; export interface Snapshot { + cpa_contract_known?: boolean; participant_id: number; participant_name: string; - source_sub2api_user_id?: number; + source_sub2api_user_id?: number | null; quota_pool_id?: number | null; quota_pool_name?: string; pool_contract_revision?: number | null; @@ -14,7 +15,7 @@ export interface Snapshot { charged_cycle_percent: number; charged_percent_lower: number | null; charged_percent_upper: number | null; - remaining_share_percent: number; + remaining_share_percent: number | null; current_balance_usd: number | null; recommended_balance_usd: number | null; recommended_balance_min_usd: number | null; @@ -102,7 +103,7 @@ export interface Participant { id: number; name: string; email: string; - sub2api_user_id: number; + sub2api_user_id: number | null; sub2api_username: string; sub2api_email: string; sub2api_identity: string; @@ -118,7 +119,7 @@ export interface Participant { export interface QuotaAllocationParticipant { id: number; name: string; - sub2api_user_id: number; + sub2api_user_id: number | null; sub2api_username: string; sub2api_email: string; sub2api_identity: string; @@ -138,6 +139,7 @@ export interface QuotaPoolAllocation { total_share_percent: number; } export interface QuotaAllocationData { + provider?: "sub2api" | "cpa"; accounts: MonitoredAccount[]; participants: QuotaAllocationParticipant[]; pools: QuotaPoolAllocation[]; @@ -149,6 +151,7 @@ export interface QuotaAllocationWritePool { allocations: QuotaPoolAllocationEntry[]; } export interface QuotaAllocationWrite { + provider?: "sub2api" | "cpa"; pools: QuotaAllocationWritePool[]; } export interface Sub2APIUserOption { diff --git a/frontend/src/types/statistics.ts b/frontend/src/types/statistics.ts index 24616e6..0d47fb1 100644 --- a/frontend/src/types/statistics.ts +++ b/frontend/src/types/statistics.ts @@ -1,3 +1,4 @@ +import type { CPAPoolSummary } from "./cpa"; import type { CorrectionBreakdown } from "./common"; import type { MonitoredAccount } from "./accounts"; import type { CostBreakdown } from "./common"; @@ -140,6 +141,7 @@ export interface APIUsageBreakdown extends CorrectionBreakdown { api_keys: APIKeyUsageItem[]; } export interface StatisticsData { + cpa_summary?: CPAPoolSummary | null; account: Pick< MonitoredAccount, "id" | "provider" | "source_account_id" | "external_account_id" | "name" diff --git a/frontend/src/views/index/Index.vue b/frontend/src/views/index/Index.vue index ef40484..a287a02 100644 --- a/frontend/src/views/index/Index.vue +++ b/frontend/src/views/index/Index.vue @@ -1,6 +1,7 @@ + + diff --git a/frontend/src/views/statistics/components/ParticipantUsageCard.vue b/frontend/src/views/statistics/components/ParticipantUsageCard.vue index c1359ad..8f5eb31 100644 --- a/frontend/src/views/statistics/components/ParticipantUsageCard.vue +++ b/frontend/src/views/statistics/components/ParticipantUsageCard.vue @@ -65,7 +65,7 @@ const usageIntervalLabel = computed(