16 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/20260521_0002_invite_referrals_and_redeem_codes.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/invite_referral.py,backend/src/models/redeem_code_batch.py,backend/src/models/redeem_code.py,backend/src/models/system_audit_log.py,backend/src/models/agent_chat_session.py,backend/src/models/agent_chat_message.py - Current status: aligned with referral rewards and redeem code activation
Scope
profilesuser_pointspoints_ledgerpoints_audit_ledgerregister_bonus_claimsinvite_referralsredeem_code_batchesredeem_codessystem_audit_logssessionsmessages
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.- Invite reward amount is configured by backend env
ERYAO_POINTS_POLICY__INVITE_REWARD_POINTS.
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).
adjust metadata ext.reason conventions
For referral rewards and redeem codes, change_type='adjust' must use one of:
invite_reward_inviterinvite_reward_inviteeredeem_code_activation
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)
invite_referrals
- PK:
id - FK:
inviter_user_id -> profiles.idinvitee_user_id -> profiles.idinvite_code_id -> invite_codes.idfirst_creem_transaction_id -> creem_transactions.id(on delete set null)
- Core fields:
invite_code_snapshotbound_atfirst_creem_paid_atinviter_reward_event_idinvitee_reward_event_idinviter_reward_granted_atinvitee_reward_granted_atcreated_atupdated_at
- Constraints:
- one invitee can only appear once
- inviter cannot equal invitee
- invite code snapshot is immutable after bind
- Notes:
- historical bindings should be backfilled from
profiles.referred_by - reward grant is idempotent per side via unique event ids
- historical bindings should be backfilled from
redeem_code_batches
- PK:
id - Core fields:
batch_keycreated_bynotescreated_atupdated_at
- Constraints:
batch_keyunique
redeem_codes
- PK:
id - FK:
batch_id -> redeem_code_batches.idredeemed_by_user_id -> auth.users.id(on delete set null)
- Core fields:
codepackage_product_codepackage_typepackage_name_snapshotcreditssort_orderstatusredeemed_atredeem_event_idcreated_atupdated_at
- Constraints:
codeuniquestatus in ('active', 'redeemed', 'disabled')- redeemed rows must have
redeemed_at,redeemed_by_user_id,redeem_event_id
- Notes:
- only regular packages are eligible for batch generation in this feature
- redeem codes do not qualify as CREEM payments for invite binding rewards
system_audit_logs
- PK:
id - Core fields:
actor_user_idtarget_user_idactionentity_typeentity_idmetadatacreated_atupdated_at
- Constraints:
- metadata must be object
- Notes:
- used for invite bind, invite reward grant, redeem code batch generation, redeem code activation
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 - referral binding remains write-once after signup via API or historical trigger snapshot
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
}
Invite and redeem integration notes
- Invite binding is write-once, cannot be unbound, and is allowed regardless of previous completed
creem_transactionsrows. - Referral reward qualification is based on the first completed CREEM payment after the invite binding has been created.
- Invite rewards are credited as
adjustledger rows with reasoninvite_reward_inviter/invite_reward_invitee. - Redeem code activation is credited as an
adjustledger row with reasonredeem_code_activation.
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, 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
Compatibility Note:
- Previous protocol version documented
regionandcurrencyfields that were never implemented. These have been removed from the specification. - Strategy:
backward-compatible— clients that expect these fields should handle their absence gracefully.