86062d5e78
- 移除 router 中对 result.region、result.currency、pkg.price 的访问 - 修正 pkg.type.value 为 pkg.type (type 是 Literal 不是 Enum) - 更新协议文档以反映实际实现 - 新增 Apple IAP 协议文档 - 标记未使用的错误码为 RESERVED
352 lines
13 KiB
Markdown
352 lines
13 KiB
Markdown
# 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:
|
|
|
|
```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
|
|
- `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:**
|
|
```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
|
|
|
|
## 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:**
|
|
```json
|
|
{
|
|
"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`
|
|
|
|
```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.
|