- 简化套餐配置结构,删除冗余的 default.yaml 和 us.yaml - 优化 Apple IAP 服务和验证逻辑 - 更新套餐数据模型和协议文档 - 添加支付相关测试用例
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
profilesuser_pointspoints_ledgerpoints_audit_ledgerregister_bonus_claimssessionsmessages
Compatibility strategy
- Current strategy: additive evolution.
- Breaking changes (drop/rename/type change on core fields) require explicit migration + rollback notes.
points_ledger.metadata.schema_versionis mandatory and current value is1.
Runtime charging policy (chat)
- Charge unit:
20points per successful run. - Charge timing: deduct after worker run succeeds (
RUN_FINISHEDpath). - 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:
usernamenot empty
- Indexes:
ix_profiles_usernameix_profiles_settings_gin
- Notes:
referred_byis FK toprofiles.id(on delete set null) for invite/referral trackingsettingsstoresProfileSettingsV1JSON includingpreferences,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 forbiz_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 > 0direction in (1, -1)balance_after >= 0change_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 nullconsume => biz_type='chat' and biz_id not nulladjust => biz_type is null and biz_id is null(通用调整,不绑定业务场景)purchase/refund => biz_type='payment' and biz_id not null(biz_id referencesapple_iap_transactions.idas logical FK, not database FK)
- direction and change_type coupling:
register/purchase => direction = 1consume/refund => direction = -1adjust => direction in (1, -1)
- idempotency:
unique (user_id, event_id)
points_audit_ledger
- PK:
id - No FK to
auth.usersforuser_id_snapshotto 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 >= 0direction in (1, 0, -1)balance_after >= 0change_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_hashuniquegrant_event_idunique
- Notes:
email_hashmust be HMAC-SHA256 over normalized email (trim + lower)- key source: backend config
points_policy.register_bonus_hmac_key balance_snapshotstores the latest pre-delete account balance for same-email re-registration recoveryhas_purchased_starter_packtracks 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 = 1operator_type in (user, system, admin)run_idnon-empty- if present,
extmust be object
- Per
change_type:register: nocharge, and no chat binding (biz_type/biz_idboth null)consume: requireschargeobject with required fieldsadjust: requiresext.reasonnon-empty (通用调整,系统或管理员均可操作,不绑定业务)purchase: requiresext.source,ext.platform,ext.product_code,ext.transaction_idrefund: requiresext.source,ext.platform,ext.product_code,ext.transaction_id,ext.original_event_id
Signup initialization contract
- DB trigger (
auth.usersafter insert):- Function:
public.initialize_profile_and_invite_code_on_signup() - Side effects: profile init + invite code init
- Function:
- Application service (in
POST /auth/email-session):grant_register_bonus_if_eligible()restoresbalance_snapshotfirst when present; otherwise grants register bonus viaregister_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_costnon-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 > 0role in ('user', 'assistant', 'system', 'tool')- token/cost non-negative
latency_msnull 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_idownership semantics. metadataandsettingsmust 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()— seedsllm_factoryandllmstables with known model definitionsinitialize_system_agents()— seedssystem_agentstable
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
notificationstable; links existing users viauser_notificationswhen--reconcile-targetsis set - Supports flags:
--dry-run— validate without writing--prune— remove static notifications no longer present in YAML files--reconcile-targets— insertuser_notificationsentries for all existing users matching each notification'stargetpredicate--source-key <key>— sync only the notification with the matchingsource_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, default20cursor: optional ISO 8601 datetime returned by the previous responsenextCursor
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 bycreatedAt descdirection:1for income,-1for spending/deductionamount: positive points deltabalanceAfter: account balance after the ledger eventchangeType: one ofregister,purchase,consume,adjust,refundcreatedAt: ISO 8601 datetime for display and paginationnextCursor: last returned rowcreatedAtwhenhasMore=true; otherwisenullhasMore: whether another page is available
Errors:
POINTS_INVALID_CURSOR(422):cursoris not a valid ISO 8601 datetime
Packages API
GET /api/v1/points/packages
Returns available purchase packages for the current user's region, 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 packagesproductCode: Unique product identifier (e.g.,new_user_pack,starter_pack,popular_pack,premium_pack)appStoreProductId: Apple App Store product identifier used for StoreKit purchasetype: "starter" (new user pack) or "regular"credits: Number of creditsisStarter: Whether this is a starter packstarterEligible: Whether user is eligible to purchase starter packsortOrder: Display order (ascending)
Business Logic:
- Load package mapping from
backend/src/core/config/static/packages/mapping.yaml - 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
- If
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