feat: add invite code validity controls

- Add invite code starts/expires fields across contracts, API, Spacetime bindings, and admin UI
- Enforce pending/expired invite code redemption behavior and expose admin status
- Add admin write-operation confirmation guard and documentation
- Add invite code contract/runtime tests
This commit is contained in:
2026-05-04 12:29:33 +08:00
parent 1142e90a35
commit 9f3e34e81a
27 changed files with 1465 additions and 97 deletions

View File

@@ -192,6 +192,8 @@ export interface AdminDisableProfileRedeemCodeRequest {
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
startsAt?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown>;
}
@@ -215,6 +217,9 @@ export interface ProfileRedeemCodeAdminListResponse {
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
startsAt: string | null;
expiresAt: string | null;
status: 'pending' | 'active' | 'expired';
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
@@ -376,6 +381,9 @@ export interface ProfileInviteCodeAdminListResponse {
{
"userId": "admin:root:SPRING2026",
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"status": "active",
"metadata": {
"batch": "spring"
},
@@ -393,6 +401,8 @@ export interface ProfileInviteCodeAdminListResponse {
```json
{
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"metadata": {
"batch": "spring"
}
@@ -405,6 +415,9 @@ export interface ProfileInviteCodeAdminListResponse {
{
"userId": "admin",
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"status": "active",
"metadata": {
"batch": "spring"
},
@@ -415,6 +428,118 @@ export interface ProfileInviteCodeAdminListResponse {
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。
#### 4.8.1 邀请码有效期语义
邀请码仍然是“用户稳定邀请身份码”,不做删除或软删除。本轮只增加时间窗字段,用于控制**新填写邀请码**是否允许绑定:
1. `startsAt` / 后端 `starts_at`:邀请码开始生效时间;为空表示立即生效。
2. `expiresAt` / 后端 `expires_at`:邀请码截止时间;为空表示长期有效。
3. 两个字段都为空时,邀请码视为长期有效。
4. `expiresAt` 采用左闭右开语义:当前时间 `>= expiresAt` 时视为已过期。
5. 时间字段在管理 API JSON 中统一使用 ISO 8601 UTC 字符串或 `null`SpacetimeDB 内部仍按 `Timestamp` 存储,契约层负责转换,前端不得自行假设微秒/毫秒整数。
6. 有效期只影响用户之后调用填写邀请码接口建立新邀请关系;已绑定的邀请关系、历史奖励、统计和审计记录不回溯修改。
字段合法性要求:
1. `startsAt``expiresAt` 均允许为空。
2. 若两者都存在,必须满足 `startsAt < expiresAt`;相等或开始晚于截止应由后端拒绝,前端可提前提示但不能替代后端校验。
3. 后台编辑已有邀请码时,空值代表清空该边界;不要用空字符串写入契约。
#### 4.8.2 用户填写邀请码的错误优先级与校验逻辑
填写邀请码时,后端是唯一业务真相。前端只展示后端错误,不复制完整业务规则。推荐校验优先级如下:
1. **请求身份与输入基础校验**:未登录、空邀请码、格式不合法等请求级错误优先返回。
2. **用户自身状态校验**:用户不存在、用户资料不可用、已绑定过邀请关系等与当前用户直接相关的错误优先于邀请码时间窗。
3. **邀请码查找**:按标准化后的邀请码查找记录;不存在时返回“邀请码不存在或不可用”。
4. **自邀请校验**:邀请码归属用户等于当前用户时,返回“不能填写自己的邀请码”。
5. **时间窗校验**
- `starts_at` 存在且当前时间 `< starts_at`,返回“邀请码未生效”。
- `expires_at` 存在且当前时间 `>= expires_at`,返回“邀请码已过期”。
6. **绑定写入与奖励发放**:只有以上校验全部通过,才写入邀请绑定、奖励或相关流水。
该顺序的目标是避免用“未生效/已过期”泄露不该暴露的用户状态,同时保证用户看到的错误与实际阻断原因一致。若后续新增风控、封禁、黑名单等规则,应在写入前补入,并在本节同步明确优先级。
#### 4.8.3 后台邀请码列表状态展示规则
后台列表状态可由后端返回 `status`,也可在前端用同一规则从 `startsAt` / `expiresAt` 派生;如果两者同时存在,列表展示以后端 `status` 为准,并仅把前端派生结果用于兜底。
| 条件 | 状态值 | 中文标签 | 展示建议 |
| --- | --- | --- | --- |
| `startsAt` 存在且当前时间 `< startsAt` | `pending` | 未生效 | 展示开始时间,提示尚不能被新用户填写 |
| `expiresAt` 存在且当前时间 `>= expiresAt` | `expired` | 已过期 | 展示截止时间,提示不再允许新绑定 |
| 其他情况 | `active` | 有效 | 正常高亮展示 |
补充展示规则:
1. 两个字段都为空时状态为 `active`,中文可展示为“长期有效”。
2. `startsAt` 为空、`expiresAt` 未来存在时状态为 `active`,中文可展示为“有效至 YYYY-MM-DD HH:mm”。
3. `startsAt` 未来、`expiresAt` 为空时状态为 `pending`中文可展示为“YYYY-MM-DD HH:mm 生效”。
4. 列表至少展示邀请码、状态、开始时间、截止时间、更新时间metadata 可保留折叠/摘要展示,避免挤占移动端宽度。
5. 列表状态只用于运营理解,不作为安全边界;真正是否可填写仍以后端 redeem 校验为准。
### 4.9 后台写操作二次确认规范
后台所有会修改线上数据的操作,在真正调用 API 前必须二次确认;取消确认时不得发送任何请求。该规范覆盖当前和未来新增的管理写入口,不限于 profile 模块。
必须二次确认的操作包括但不限于:
1. 创建/更新兑换码:`POST /admin/api/profile/redeem-codes`
2. 停用兑换码:`POST /admin/api/profile/redeem-codes/disable`
3. 创建/更新邀请码:`POST /admin/api/profile/invite-codes`
4. 创建/更新个人任务配置:`POST /admin/api/profile/tasks`
5. 停用个人任务配置:`POST /admin/api/profile/tasks/disable`
6. 后续任何 `POST` / `PATCH` / `PUT` / `DELETE` 管理接口,只要会修改数据、触发任务、写审计或影响线上配置,均默认纳入确认。
交互要求:
1. 确认弹窗必须在 API 调用前出现,确认后才进入 loading 和提交状态。
2. 弹窗必须展示操作类型(新增、更新、停用、删除、发布等)、对象标识(如 `code``inviteCode``taskId`)和影响说明。
3. 默认按钮顺序为“取消 / 确认”,取消不应有危险色;危险操作(停用、删除、覆盖线上配置)确认按钮使用警示样式。
4. 弹窗文案统一提示“该操作会立即影响线上数据”,但不要在页面常驻展示大段规则说明。
5. 支持键盘和移动端Esc 或取消按钮关闭;移动端弹窗宽度自适应,不遮挡关键对象信息。
6. loading 期间锁定确认按钮和原页面提交按钮,避免重复写入。
7. 成功后按现有页面规则刷新列表或合并返回记录;失败时展示后端错误,不能静默关闭为成功。
建议抽象通用确认能力,例如 `confirmAdminWriteAction({ actionLabel, targetLabel, riskLevel, onConfirm })` 或通用 `AdminConfirmDialog`,页面只传入对象与回调,避免每个页面重复实现不同交互。
#### 4.9.1 二次确认文案模板
```text
标题:确认{操作类型}{对象类型}
正文:即将{操作类型}「{对象标识}」。该操作会立即影响线上数据。
取消按钮:取消
确认按钮:确认{操作类型}
```
示例:
1. `确认更新邀请码`即将更新「SPRING2026」的有效期与 metadata。该操作会立即影响线上数据。
2. `确认停用兑换码`即将停用「WELCOME2026」。该操作会立即影响线上数据。
3. `确认更新任务配置`即将更新「daily_login」。该操作会立即影响线上数据。
### 4.10 邀请码有效期与二次确认改动范围
实现本设计时预期改动范围如下,未列出的层级不要擅自承接业务规则:
1. `server-rs/crates/spacetime-module/src/runtime/profile.rs`邀请码表结构、upsert、redeem 时间窗校验与后台列表投影。
2. `server-rs/crates/spacetime-module/src/migration.rs`:旧邀请码记录迁移,默认 `starts_at = None``expires_at = None`
3. `server-rs/crates/shared-contracts/src/**`:管理请求/响应 DTO 增加 `startsAt``expiresAt``status` 等字段。
4. `server-rs/crates/spacetime-client/src/module_bindings/**` 与 mapper按表结构变更重新生成/补齐绑定字段。
5. `server-rs/crates/api-server/src/runtime_profile.rs`:接收、校验、转发并返回邀请码时间窗字段;保持错误 envelope 兼容后台读取逻辑。
6. `apps/admin-web/src/api/adminApiTypes.ts``adminApiClient.ts`:同步契约字段,不在 client 层写业务判断。
7. `apps/admin-web/src/pages/AdminInviteCodePage.tsx`:有效期表单、列表状态展示、保存前确认。
8. `apps/admin-web/src/pages/AdminRedeemCodePage.tsx``AdminTaskConfigPage.tsx` 及后续写页面:统一接入写操作二次确认。
9. `apps/admin-web/src/styles/admin.css`:状态标签、确认弹窗与移动端样式。
验证建议:
1. 服务端单测覆盖:未生效邀请码拒绝、已过期邀请码拒绝、有效时间窗可绑定、空时间窗长期有效、已绑定关系不受后续过期影响。
2. 管理 API 覆盖upsert 能写入/清空 `startsAt``expiresAt`;列表返回状态正确;`startsAt >= expiresAt` 被拒绝。
3. 前端交互覆盖:点击保存/停用不会直接请求,取消确认不请求,确认后只请求一次,失败展示后端错误。
4. 回归兑换码与任务配置页面,确认所有写操作均有统一二次确认。
5. 修改后端时按项目约束运行对应 Rust 测试、`npm run api-server` 联调和 `/healthz`;修改前端时运行 `npm run admin-web:typecheck``npm run admin-web:build`;文档或中文改动后运行 `npm run check:encoding`
## 5. 鉴权与会话
1. token key 固定为 `genarrative_admin_token`

View File

@@ -24,7 +24,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| --- | --- |
| 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `analytics_date_dimension`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` |
@@ -158,6 +158,20 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
```
### `analytics_date_dimension`
- 作用:分析日期维表,每个北京时间业务自然日一行,用于把日桶映射到周、月、季度和年。
- 结构:`date_key PK: i64`, `calendar_date: String`, `weekday: u8`, `iso_week_key: i32`, `week_start_date_key: i64`, `week_end_date_key: i64`, `month_key: i32`, `month_start_date_key: i64`, `month_end_date_key: i64`, `quarter_key: i32`, `quarter_start_date_key: i64`, `quarter_end_date_key: i64`, `year_key: i32`, `year_start_date_key: i64`, `year_end_date_key: i64`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `date_key``iso_week_key``month_key``quarter_key``year_key`
- 写入口:`ensure_analytics_date_dimension_for_date({ date_key })` 幂等补单日;`seed_analytics_date_dimensions({ start_date, end_date })``YYYY-MM-DD` 闭区间幂等批量补种,单次最多 `3660` 天。
- 口径:`date_key` 沿用当前埋点日桶 `floor((occurred_at_micros + 8h) / 1d)``calendar_date` 是该北京时间业务日的公历日期。
```sql
SELECT * FROM analytics_date_dimension WHERE date_key = <date_key>;
SELECT * FROM analytics_date_dimension WHERE iso_week_key = 202501 ORDER BY date_key;
SELECT * FROM analytics_date_dimension WHERE month_key = 202402 ORDER BY date_key;
```
### `tracking_event`
- 作用:埋点原始事件表,保存整站、作品、模块和用户层的原始事实。