前端: - 集成 in_app_purchase 插件,实现 IAP 支付流程 - 添加支付模块 (payments/) 处理产品获取、购买、验证 - 积分中心页面集成 Apple Pay 购买入口 - 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面 - 修复欢迎引导页滚动检测阈值问题 - 修复解卦结果页 iOS 侧滑返回手势被阻止的问题 - 邀请码绑定按钮临时禁用(待后端实现) 后端: - 新增 apple_iap_transactions 表记录交易 - 实现 Apple 服务器端验证 (App Store Server API) - 支付成功后自动发放积分 - 支持 Sandbox/Production 环境切换 - 添加退款处理和交易状态机 协议: - 更新积分流水协议,支持 purchase/refund 类型 - 新增 PAYMENT_* 错误码
12 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.
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:
{
"region": "US",
"currency": "USD",
"packages": [
{
"productCode": "new_user_pack",
"type": "starter",
"price": "0.99",
"credits": 60,
"badge": null,
"isStarter": true,
"starterEligible": true,
"sortOrder": 0
},
{
"productCode": "basic_pack",
"type": "regular",
"price": "4.99",
"credits": 100,
"badge": null,
"isStarter": false,
"starterEligible": false,
"sortOrder": 10
}
]
}
Fields:
region: ISO 3166-1 alpha-2 country code (e.g., "US", "CN")currency: ISO 4217 currency code (e.g., "USD")packages: List of available packagesproductCode: Unique product identifier (e.g.,new_user_pack,basic_pack,popular_pack,premium_pack)type: "starter" (new user pack) or "regular"price: Price in the response currency (decimal string, for display reference only; actual payment uses StoreKit price)credits: Number of creditsbadge: Optional badge text (e.g., "Popular")isStarter: Whether this is a starter packstarterEligible: Whether user is eligible to purchase starter packsortOrder: Display order (ascending)
Business Logic:
- Determine user's region from
profile.settings.preferences.country(default: "US") - Load package configuration from
backend/src/core/config/static/packages/{country}.yaml(fallback todefault.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:
us.yaml
region: US
currency: USD
packages:
- product_code: new_user_pack
type: starter
price: "0.99"
credits: 60
badge: null
sort_order: 0
enabled: true
- product_code: basic_pack
type: regular
price: "4.99"
credits: 100
badge: null
sort_order: 10
enabled: true
Country/Region Codes:
- Uses ISO 3166-1 alpha-2 standard
- Default:
US(United States) - Examples:
CN(China),TW(Taiwan),HK(Hong Kong),JP(Japan)