Files
eryao/docs/protocols/common/user-points-chat-data-protocol.md
T
ZL-Q 87f92987b2 feat: 实现 iOS Apple Pay 内购支付功能
前端:
- 集成 in_app_purchase 插件,实现 IAP 支付流程
- 添加支付模块 (payments/) 处理产品获取、购买、验证
- 积分中心页面集成 Apple Pay 购买入口
- 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面
- 修复欢迎引导页滚动检测阈值问题
- 修复解卦结果页 iOS 侧滑返回手势被阻止的问题
- 邀请码绑定按钮临时禁用(待后端实现)

后端:
- 新增 apple_iap_transactions 表记录交易
- 实现 Apple 服务器端验证 (App Store Server API)
- 支付成功后自动发放积分
- 支持 Sandbox/Production 环境切换
- 添加退款处理和交易状态机

协议:
- 更新积分流水协议,支持 purchase/refund 类型
- 新增 PAYMENT_* 错误码
2026-04-28 10:45:29 +08:00

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

  • 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:

{
  "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.

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 packages
    • productCode: 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 credits
    • badge: Optional badge text (e.g., "Popular")
    • isStarter: Whether this is a starter pack
    • starterEligible: Whether user is eligible to purchase starter pack
    • sortOrder: Display order (ascending)

Business Logic:

  1. Determine user's region from profile.settings.preferences.country (default: "US")
  2. Load package configuration from backend/src/core/config/static/packages/{country}.yaml (fallback to default.yaml)
  3. 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: 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)