# User Points & Chat Data Protocol This protocol defines the canonical data contract for user profile, points account, points ledger, chat session, and chat messages. Protocol verification status: - Last audited migration: `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py` - Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py` - Current status: aligned with register bonus moved to application service ## Scope - `profiles` - `user_points` - `points_ledger` - `points_audit_ledger` - `register_bonus_claims` - `sessions` - `messages` ## Compatibility strategy - Current strategy: additive evolution. - Breaking changes (drop/rename/type change on core fields) require explicit migration + rollback notes. - `points_ledger.metadata.schema_version` is mandatory and current value is `1`. ## Runtime charging policy (chat) - Charge unit: `20` points per successful run. - Charge timing: deduct after worker run succeeds (`RUN_FINISHED` path). - Failure behavior: failed/canceled runs do not deduct points. - Precheck: before accepting a run, backend must verify `available = balance - frozen_balance >= 20`. - Session follow-up cap: one session allows at most 2 user runs total (initial divination + 1 follow-up). - Billing idempotency key for per-run consume: `chat.run.success:{sha1(session_id:run_id)}`. - Failed/canceled runs do not deduct user points. If real provider cost is observed, audit record is written with `billed_to='platform'`. ## Points Change Types (change_type) | Type | Direction | Meaning | biz_type | Description | |------|-----------|---------|----------|-------------| | `register` | +1 | 注册奖励 | `null` | 新用户注册赠送积分 | | `consume` | -1 | 消费扣减 | `chat` | 用户占卜消耗积分 | | `adjust` | ±1 | 手动调整 | `null` | 系统或管理员手动调整积分,通用调整不绑定业务场景 | | `purchase` | +1 | 购买入账 | `payment` | 用户支付购买积分 | | `refund` | -1 | 退款扣回 | `payment` | 退款后扣回积分 | ## Points Business Types (biz_type) | Type | Meaning | Associated change_type | |------|---------|------------------------| | `chat` | 聊天/占卜业务 | `consume` | | `payment` | 支付业务 | `purchase`, `refund` | Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`). ## Table contract ### profiles - PK: `id` (`auth.users.id`, `on delete cascade`) - Core fields: `username`, `avatar_url`, `bio`, `settings`, `referred_by`, `created_at`, `updated_at`, `deleted_at` - Constraints: - `username` not empty - Indexes: - `ix_profiles_username` - `ix_profiles_settings_gin` - Notes: - `referred_by` is FK to `profiles.id` (`on delete set null`) for invite/referral tracking - `settings` stores `ProfileSettingsV1` JSON including `preferences`, `privacy`, `notification`, `divination_tutorial` ### user_points - PK: `user_id` (`auth.users.id`, `on delete cascade`) - Core fields: `balance`, `frozen_balance`, `lifetime_earned`, `lifetime_spent`, `version`, `created_at`, `updated_at` - Constraints: - all numeric totals must be non-negative - `frozen_balance <= balance` ### points_ledger - PK: `id` - FK: - `user_id -> auth.users.id` (`on delete cascade`) - `biz_id -> sessions.id` (`on delete restrict`, nullable) — only for `biz_type='chat'` - `operator_id -> auth.users.id` (`on delete set null`) - Core fields: `direction`, `amount`, `balance_after`, `change_type`, `biz_type`, `biz_id`, `event_id`, `operator_id`, `metadata`, `created_at`, `updated_at` - Constraints: - `amount > 0` - `direction in (1, -1)` - `balance_after >= 0` - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` - `biz_type is null or biz_type in ('chat', 'payment')` - biz binding: - `register => biz_type is null and biz_id is null` - `consume => biz_type='chat' and biz_id not null` - `adjust => biz_type is null and biz_id is null` (通用调整,不绑定业务场景) - `purchase/refund => biz_type='payment' and biz_id not null` (biz_id references `apple_iap_transactions.id` as logical FK, not database FK) - direction and change_type coupling: - `register/purchase => direction = 1` - `consume/refund => direction = -1` - `adjust => direction in (1, -1)` - idempotency: `unique (user_id, event_id)` ### points_audit_ledger - PK: `id` - No FK to `auth.users` for `user_id_snapshot` to avoid cascade delete and preserve audit retention - Core fields: `event_id`, `user_id_snapshot`, `user_email_snapshot`, `change_type`, `biz_type`, `biz_id`, `direction`, `amount`, `balance_after`, `billed_to`, `run_id`, `request_id`, `input_tokens`, `output_tokens`, `cost`, `metadata`, `created_at`, `updated_at` - Constraints: - `amount >= 0` - `direction in (1, 0, -1)` - `balance_after >= 0` - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` - `biz_type is null or biz_type in ('chat', 'payment')` - `billed_to in ('user', 'platform')` - metadata must be object - idempotency: `unique (event_id)` ### register_bonus_claims - PK: `id` - Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `has_purchased_starter_pack`, `created_at`, `updated_at` - Constraints: - `email_hash` unique - `grant_event_id` unique - Notes: - `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`) - key source: backend config `points_policy.register_bonus_hmac_key` - `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery - `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits) #### points_ledger.metadata (schema_version=1) Canonical shape: ```json { "schema_version": 1, "operator_type": "user|system|admin", "run_id": "string", "request_id": "string|null", "charge": { "message_id": "uuid", "message_seq": 1, "model_code": "string", "input_tokens": 0, "output_tokens": 0, "cost": "0.000000" }, "ext": {} } ``` JSON constraints: - Common: - must be object - `schema_version = 1` - `operator_type in (user, system, admin)` - `run_id` non-empty - if present, `ext` must be object - Per `change_type`: - `register`: no `charge`, and no chat binding (`biz_type/biz_id` both null) - `consume`: requires `charge` object with required fields - `adjust`: requires `ext.reason` non-empty (通用调整,系统或管理员均可操作,不绑定业务) - `purchase`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id` - `refund`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id`, `ext.original_event_id` ## Signup initialization contract - DB trigger (`auth.users` after insert): - Function: `public.initialize_profile_and_invite_code_on_signup()` - Side effects: profile init + invite code init - Application service (in `POST /auth/email-session`): - `grant_register_bonus_if_eligible()` restores `balance_snapshot` first when present; otherwise grants register bonus via `register_bonus_claims` - Bonus amount from `config.points_policy.register_bonus_points` ### sessions - PK: `id` - FK: `user_id -> auth.users.id` - Core fields: `session_type`, `job_id`, `title`, `status`, `last_activity_at`, `message_count`, `total_tokens`, `total_cost`, `state_snapshot`, `created_at`, `updated_at`, `deleted_at` - Constraints: - `session_type in ('chat', 'automation')` - `status in ('pending', 'running', 'completed', 'failed')` - `message_count/total_tokens/total_cost` non-negative ### messages - PK: `id` - FK: `session_id -> sessions.id` (`on delete cascade`) - Core fields: `seq`, `role`, `content`, `model_code`, `tool_name`, `input_tokens`, `output_tokens`, `cost`, `latency_ms`, `visibility_mask`, `metadata`, `created_at`, `updated_at`, `deleted_at` - Constraints: - `unique (session_id, seq)` - `seq > 0` - `role in ('user', 'assistant', 'system', 'tool')` - token/cost non-negative - `latency_ms` null or non-negative ## Security and ownership - Backend service must derive owner identity from verified auth context. - Client must not be trusted for `user_id`/`operator_id` ownership semantics. - `metadata` and `settings` must not include secrets. ## Application initialization contract Application initialization is split into two separate concerns: deterministic seed data and dynamic notification template sync. These are intentionally decoupled to avoid non-deterministic side effects during bootstrap. ### Seed data (deterministic) Managed by `python -m core.runtime.cli init-data` (called by `dev-migrate.sh bootstrap`): - `initialize_llm_catalog()` — seeds `llm_factory` and `llms` tables with known model definitions - `initialize_system_agents()` — seeds `system_agents` table These are idempotent and version-locked to code; they do not depend on runtime state or user data. ### Notification templates (declarative sync) Managed by `python -m core.runtime.cli sync-notifications [flags]`: - Reads notification definitions from YAML files under `backend/src/core/config/static/notification/notifications/` - Syncs to `notifications` table; links existing users via `user_notifications` when `--reconcile-targets` is set - Supports flags: - `--dry-run` — validate without writing - `--prune` — remove static notifications no longer present in YAML files - `--reconcile-targets` — insert `user_notifications` entries for all existing users matching each notification's `target` predicate - `--source-key ` — sync only the notification with the matching `source_key` Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects. ## Points Ledger API ### GET /api/v1/points/ledger Returns the authenticated user's points ledger in reverse chronological order. **Request:** - Auth: Required (JWT) - Query: - `limit`: integer, `1..100`, default `20` - `cursor`: optional ISO 8601 datetime returned by the previous response `nextCursor` **Response:** ```json { "items": [ { "id": "9cfd5d1d-0dd8-4b30-88ce-6e4a63d22d76", "direction": 1, "amount": 60, "balanceAfter": 160, "changeType": "purchase", "createdAt": "2026-04-28T08:30:00+00:00" } ], "nextCursor": "2026-04-28T08:30:00+00:00", "hasMore": true } ``` **Fields:** - `items`: ledger rows ordered by `createdAt desc` - `direction`: `1` for income, `-1` for spending/deduction - `amount`: positive points delta - `balanceAfter`: account balance after the ledger event - `changeType`: one of `register`, `purchase`, `consume`, `adjust`, `refund` - `createdAt`: ISO 8601 datetime for display and pagination - `nextCursor`: last returned row `createdAt` when `hasMore=true`; otherwise `null` - `hasMore`: whether another page is available **Errors:** - `POINTS_INVALID_CURSOR` (`422`): `cursor` is not a valid ISO 8601 datetime ## Packages API ### GET /api/v1/points/packages Returns available purchase packages for the current user, including starter pack eligibility. **Request:** - Auth: Required (JWT) - Headers: `Authorization: Bearer ` **Response:** ```json { "packages": [ { "productCode": "new_user_pack", "appStoreProductId": "com.meeyao.qianwen.new_user_pack", "type": "starter", "credits": 60, "isStarter": true, "starterEligible": true, "sortOrder": 0 }, { "productCode": "starter_pack", "appStoreProductId": "com.meeyao.qianwen.starter_pack", "type": "regular", "credits": 100, "isStarter": false, "starterEligible": false, "sortOrder": 10 } ] } ``` **Fields:** - `packages`: List of available packages - `productCode`: Unique product identifier (e.g., `new_user_pack`, `starter_pack`, `popular_pack`, `premium_pack`) - `appStoreProductId`: Apple App Store product identifier used for StoreKit purchase - `type`: "starter" (new user pack) or "regular" - `credits`: Number of credits - `isStarter`: Whether this is a starter pack - `starterEligible`: Whether user is eligible to purchase starter pack - `sortOrder`: Display order (ascending) **Business Logic:** 1. Load package mapping from `backend/src/core/config/static/packages/mapping.yaml` 2. Check starter pack eligibility: - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response - Otherwise, include starter pack with `starterEligible: true` **Configuration Files:** - Path: `backend/src/core/config/static/packages/` - Format: YAML - Example: `mapping.yaml` ```yaml product_mappings: new_user_pack: app_store_product_id: com.meeyao.qianwen.new_user_pack credits: 60 type: starter sort_order: 0 enabled: true starter_pack: app_store_product_id: com.meeyao.qianwen.starter_pack credits: 100 type: regular sort_order: 10 enabled: true ``` **Compatibility Note:** - Previous protocol version documented `region` and `currency` fields that were never implemented. These have been removed from the specification. - Strategy: `backward-compatible` — clients that expect these fields should handle their absence gracefully.