# 资料兑换码模块落地设计 ## 1. 目标 本轮在现有“我的”资料与钱包 projection 上新增兑换码能力。用户兑换成功后直接增加光点余额,写入 `profile_wallet_ledger`,并同步刷新 `profile_dashboard_state.wallet_balance`。 管理侧本轮只提供后端 API,不新增管理后台页面。私有兑换码创建时支持内部 `userId` 与公开百梦号两类输入,后端创建阶段统一解析成内部 `userId` 存储。 ## 2. 兑换码类型 `RuntimeProfileRedeemCodeMode` 固定为三种: | 类型 | 规则 | | --- | --- | | `Public` | 任意用户可兑换,`max_uses` 按用户独立计算。 | | `Unique` | 任意用户可兑换,`max_uses` 全局共用。 | | `Private` | 仅 `allowed_user_ids` 中的用户可兑换,`max_uses` 全局共用。 | 兑换码入库前必须 `trim + uppercase`。空兑换码、奖励为 0、次数为 0 均拒绝。 ## 3. 表结构 ### 3.1 `profile_redeem_code` | 字段 | 类型 | 说明 | | --- | --- | --- | | `code` | `String` | 主键,标准化后的兑换码。 | | `mode` | `RuntimeProfileRedeemCodeMode` | 兑换码模式。 | | `reward_points` | `u64` | 单次到账光点。 | | `max_uses` | `u32` | 公共码为单用户上限,唯一码/私有码为全局上限。 | | `global_used_count` | `u32` | 全局已使用次数。公共码也记录总使用次数,但不参与公共码上限判断。 | | `enabled` | `bool` | 是否启用。 | | `allowed_user_ids` | `Vec` | 私有码允许用户;公共/唯一码存空数组。 | | `created_by` | `String` | 管理员用户 ID。 | | `created_at` | `Timestamp` | 创建时间。 | | `updated_at` | `Timestamp` | 更新时间。 | ### 3.2 `profile_redeem_code_usage` | 字段 | 类型 | 说明 | | --- | --- | --- | | `usage_id` | `String` | 主键,格式 `redeem:{code}:{user_id}:{micros}:{sequence}`。 | | `code` | `String` | 兑换码。 | | `user_id` | `String` | 兑换用户。 | | `amount_granted` | `u64` | 到账光点。 | | `created_at` | `Timestamp` | 兑换时间。 | 索引:`code`、`user_id`、`(code, user_id)`。 ## 4. SpacetimeDB 过程 ### 4.1 用户兑换 `redeem_profile_reward_code(input: RuntimeProfileRewardCodeRedeemInput) -> RuntimeProfileRewardCodeRedeemProcedureResult` 流程: 1. 标准化 code。 2. 校验兑换码存在、启用、奖励大于 0。 3. 按模式校验使用范围与次数。 4. 同一事务内写入 `profile_redeem_code_usage`、增加钱包余额、写入 `profile_wallet_ledger`,最后更新 `profile_redeem_code.global_used_count`。 5. 返回 `walletBalance`、`amountGranted` 与本次 `ledgerEntry`。 ### 4.2 管理创建/更新 `admin_upsert_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminUpsertInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` 私有码必须至少解析出一个内部用户 ID。公共码与唯一码忽略 allowed 列表并存空数组。 ### 4.3 管理停用 `admin_disable_profile_redeem_code(input: RuntimeProfileRedeemCodeAdminDisableInput) -> RuntimeProfileRedeemCodeAdminProcedureResult` 只更新 `enabled=false` 与 `updated_at`,不存在时返回“兑换码不存在”。 ## 5. Axum API 用户接口: - `POST /api/profile/redeem-codes/redeem` - `POST /api/runtime/profile/redeem-codes/redeem` 请求:`{ "code": "WELCOME2026" }` 成功返回: ```json { "walletBalance": 130, "amountGranted": 100, "ledgerEntry": { "id": "redeem:WELCOME2026:user:1777392000000000:0", "amountDelta": 100, "balanceAfter": 130, "sourceType": "redeem_code_reward", "createdAt": "2026-04-28T00:00:00Z" } } ``` 管理员接口: - `POST /admin/api/profile/redeem-codes` - `POST /admin/api/profile/redeem-codes/disable` 管理员接口复用现有 `require_admin_auth`。 ## 6. 错误文案 | 场景 | message | | --- | --- | | 空 code | `兑换码不能为空` | | 不存在 | `兑换码不存在` | | 停用 | `兑换码已停用` | | 奖励为 0 | `兑换码奖励无效` | | 次数耗尽 | `兑换次数已用完` | | 私有码账号不匹配 | `该兑换码不适用于当前账号` | | 私有码无允许用户 | `私有兑换码必须指定可兑换用户` | ## 7. 前端交互 “我的”页头像右侧入口由 `会员充值` 改为 `兑换码`。点击打开独立模态窗口,窗口内只保留输入框、兑换按钮和后端返回提示,不展示兑换规则说明。 成功后展示 `已到账 X 光点`,并刷新 profile dashboard。失败后直接展示后端 `message`。 ## 8. 测试矩阵 - Rust/module-runtime:覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。 - Axum:覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。 - 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。 - 验证命令:`cargo test`、目标前端测试、`npm run api-server`、`npm run check:encoding`。