Files
eryao/docs/protocols/common/user-points-chat-data-protocol.md
T
ZL-Q 86062d5e78 fix: 修复 packages 接口访问不存在字段导致的运行时错误
- 移除 router 中对 result.region、result.currency、pkg.price 的访问
- 修正 pkg.type.value 为 pkg.type (type 是 Literal 不是 Enum)
- 更新协议文档以反映实际实现
- 新增 Apple IAP 协议文档
- 标记未使用的错误码为 RESERVED
2026-04-28 17:31:24 +08:00

13 KiB

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:

{
  "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 <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:

{
  "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 <token>

Response:

{
  "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
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.