2026-04-03 16:56:47 +08:00
# 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:
2026-04-13 11:28:58 +08:00
- Last audited migration: `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py`
2026-04-10 12:28:18 +08:00
- 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`
2026-04-10 16:45:45 +08:00
- Current status: aligned with register bonus moved to application service
2026-04-03 16:56:47 +08:00
## Scope
- `profiles`
- `user_points`
- `points_ledger`
2026-04-10 12:28:18 +08:00
- `points_audit_ledger`
- `register_bonus_claims`
2026-04-03 16:56:47 +08:00
- `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` .
2026-04-08 17:23:02 +08:00
- Session follow-up cap: one session allows at most 2 user runs total (initial divination + 1 follow-up).
2026-04-10 12:28:18 +08:00
- 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'` .
2026-04-03 16:56:47 +08:00
2026-04-28 10:45:29 +08:00
## 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` ).
2026-04-03 16:56:47 +08:00
## Table contract
### profiles
- PK: `id` (`auth.users.id` , `on delete cascade` )
2026-04-16 10:51:08 +08:00
- Core fields: `username` , `avatar_url` , `bio` , `settings` , `referred_by` , `created_at` , `updated_at` , `deleted_at`
2026-04-03 16:56:47 +08:00
- Constraints:
- `username` not empty
- Indexes:
- `ix_profiles_username`
- `ix_profiles_settings_gin`
2026-04-16 10:51:08 +08:00
- 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`
2026-04-03 16:56:47 +08:00
### 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` )
2026-04-28 10:45:29 +08:00
- `biz_id -> sessions.id` (`on delete restrict` , nullable) — only for `biz_type='chat'`
2026-04-03 16:56:47 +08:00
- `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`
2026-04-28 10:45:29 +08:00
- `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')`
- `biz_type is null or biz_type in ('chat', 'payment')`
2026-04-03 16:56:47 +08:00
- biz binding:
- `register => biz_type is null and biz_id is null`
2026-04-28 10:45:29 +08:00
- `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)
2026-04-03 16:56:47 +08:00
- direction and change_type coupling:
2026-04-28 10:45:29 +08:00
- `register/purchase => direction = 1`
- `consume/refund => direction = -1`
2026-04-03 16:56:47 +08:00
- `adjust => direction in (1, -1)`
- idempotency: `unique (user_id, event_id)`
2026-04-10 12:28:18 +08:00
### 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`
2026-04-28 10:45:29 +08:00
- `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')`
- `biz_type is null or biz_type in ('chat', 'payment')`
2026-04-10 12:28:18 +08:00
- `billed_to in ('user', 'platform')`
- metadata must be object
- idempotency: `unique (event_id)`
### register_bonus_claims
- PK: `id`
2026-04-16 16:11:09 +08:00
- Core fields: `email_hash` , `user_email_snapshot` , `first_user_id_snapshot` , `balance_snapshot` , `grant_event_id` , `has_purchased_starter_pack` , `created_at` , `updated_at`
2026-04-10 12:28:18 +08:00
- 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`
2026-04-13 11:28:58 +08:00
- `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery
2026-04-16 16:11:09 +08:00
- `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits)
2026-04-10 12:28:18 +08:00
2026-04-03 16:56:47 +08:00
#### 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
2026-04-28 10:45:29 +08:00
- `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`
2026-04-03 16:56:47 +08:00
2026-04-10 16:45:45 +08:00
## Signup initialization contract
2026-04-10 12:28:18 +08:00
2026-04-10 16:45:45 +08:00
- DB trigger (`auth.users` after insert):
2026-04-10 12:28:18 +08:00
- Function: `public.initialize_profile_and_invite_code_on_signup()`
2026-04-10 16:45:45 +08:00
- Side effects: profile init + invite code init
- Application service (in `POST /auth/email-session` ):
2026-04-13 11:28:58 +08:00
- `grant_register_bonus_if_eligible()` restores `balance_snapshot` first when present; otherwise grants register bonus via `register_bonus_claims`
2026-04-10 16:45:45 +08:00
- Bonus amount from `config.points_policy.register_bonus_points`
2026-04-03 16:56:47 +08:00
### 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.
2026-04-13 11:35:15 +08:00
## 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.
2026-04-16 16:11:09 +08:00
2026-04-28 17:21:14 +08:00
## 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
2026-04-16 16:11:09 +08:00
## Packages API
### GET /api/v1/points/packages
2026-04-28 17:31:24 +08:00
Returns available purchase packages for the current user, including starter pack eligibility.
2026-04-16 16:11:09 +08:00
**Request: **
- Auth: Required (JWT)
- Headers: `Authorization: Bearer <token>`
**Response: **
``` json
{
"packages" : [
{
2026-04-28 10:45:29 +08:00
"productCode" : "new_user_pack" ,
2026-04-28 17:21:14 +08:00
"appStoreProductId" : "com.meeyao.qianwen.new_user_pack" ,
2026-04-16 16:11:09 +08:00
"type" : "starter" ,
"credits" : 60 ,
"isStarter" : true ,
"starterEligible" : true ,
"sortOrder" : 0
} ,
{
2026-04-28 17:21:14 +08:00
"productCode" : "starter_pack" ,
"appStoreProductId" : "com.meeyao.qianwen.starter_pack" ,
2026-04-16 16:11:09 +08:00
"type" : "regular" ,
"credits" : 100 ,
"isStarter" : false ,
"starterEligible" : false ,
"sortOrder" : 10
}
]
}
```
**Fields: **
- `packages` : List of available packages
2026-04-28 17:21:14 +08:00
- `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)
2026-04-16 16:11:09 +08:00
**Business Logic: **
2026-04-28 17:21:14 +08:00
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`
2026-04-16 16:11:09 +08:00
**Configuration Files: **
- Path: `backend/src/core/config/static/packages/`
- Format: YAML
2026-04-28 17:21:14 +08:00
- Example: `mapping.yaml`
2026-04-16 16:11:09 +08:00
``` yaml
2026-04-28 17:21:14 +08:00
product_mappings :
new_user_pack :
app_store_product_id : com.meeyao.qianwen.new_user_pack
2026-04-16 16:11:09 +08:00
credits : 60
2026-04-28 17:21:14 +08:00
type : starter
2026-04-16 16:11:09 +08:00
sort_order : 0
enabled : true
2026-04-28 17:21:14 +08:00
starter_pack :
app_store_product_id : com.meeyao.qianwen.starter_pack
2026-04-16 16:11:09 +08:00
credits : 100
2026-04-28 17:21:14 +08:00
type : regular
2026-04-16 16:11:09 +08:00
sort_order : 10
enabled : true
```
2026-04-28 17:31:24 +08:00
**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.