diff --git a/.hermes/plans/2026-05-04_032321-invite-code-validity-and-admin-confirmation.md b/.hermes/plans/2026-05-04_032321-invite-code-validity-and-admin-confirmation.md new file mode 100644 index 00000000..4a72ef2a --- /dev/null +++ b/.hermes/plans/2026-05-04_032321-invite-code-validity-and-admin-confirmation.md @@ -0,0 +1,271 @@ +# 邀请码有效期与后台二次确认实施计划 + +> **For Hermes:** 按 plan 模式,仅输出并保存实施计划,不直接改业务代码。 + +**Goal:** 为邀请码新增开始日期与截止日期,并让后台所有会修改数据的操作在提交前增加二次确认,降低误操作风险。 + +**Architecture:** +邀请码仍作为“用户稳定邀请身份码”保留,不做停用删除;在数据层增加 `starts_at` / `expires_at`,前台填写邀请码时按时间窗校验,后台列表与编辑页展示状态。后台所有写操作统一先弹二次确认,再真正调用 API,避免对兑换码、邀请码、任务配置等管理动作误触发。 + +**Tech Stack:** +Rust / SpacetimeDB / Axum / shared-contracts / TS + React 的 admin-web。 + +--- + +## 当前上下文 + +- 邀请码当前只有 `user_id`、`invite_code`、`metadata_json`、`created_at`、`updated_at`,没有状态字段。 +- 目前后台存在邀请码管理入口,但没有停用能力,也没有有效期概念。 +- 邀请码用于 `redeem_profile_referral_invite_code` 时的实时校验,适合增加“时间窗”而不是“禁用删除”。 +- 后台已存在兑换码、任务配置等可写操作;本次要求把所有后台操作统一加二次确认,包括新增、编辑、禁用、删除等写入口。 + +--- + +## 设计原则 + +1. **邀请码不做软删除**:保留历史记录和邀请链路。 +2. **有效期由时间窗推导**: + - `starts_at` 为空表示立即生效。 + - `expires_at` 为空表示长期有效。 +3. **前台只拒绝新绑定**:已绑定关系不回溯修改。 +4. **后台写操作统一确认**:所有会触发 POST / PATCH / DELETE 的管理动作,在真正提交前必须弹出二次确认。 +5. **尽量少改接口语义**:优先在现有 admin upsert/list 体系内扩展字段,而不是新增一套并行 API。 + +--- + +## 方案概要 + +### 邀请码时间窗 + +新增字段: +- `starts_at: Option` +- `expires_at: Option` + +校验规则: +- 当前时间 `< starts_at`:返回“邀请码未生效” +- 当前时间 `>= expires_at`:返回“邀请码已过期” +- 其他情况允许填写 + +建议把状态展示为: +- 未生效 +- 有效 +- 已过期 +- 长期有效(两个字段都为空或仅无截止) + +### 后台二次确认 + +对 admin-web 所有管理动作统一加确认弹窗/对话框,至少覆盖: +- 兑换码新增/更新 +- 兑换码停用 +- 邀请码新增/更新 +- 任务配置新增/更新 +- 任务配置停用 +- 其他后续新增的后台写操作 + +确认文案要求: +- 显示对象标识(如 code / inviteCode / taskId) +- 显示操作类型(新增 / 更新 / 停用) +- 明确提醒“该操作会立即影响线上数据” +- 允许取消返回,不调用 API + +--- + +## 预期修改文件 + +### 1. 服务端领域与契约 +- `server-rs/crates/spacetime-module/src/runtime/profile.rs` + - `ProfileInviteCode` 表结构新增开始/截止字段 + - 邀请码 upsert 逻辑写入时间窗 + - 邀请码 redeem 逻辑增加时间窗校验 + - 邀请中心快照补充时间窗/状态 +- `server-rs/crates/spacetime-module/src/migration.rs` + - 兼容旧表数据,给旧邀请码补默认空值 +- `server-rs/crates/shared-contracts/src/runtime*.rs` 或对应生成/手写契约文件 + - `AdminUpsertProfileInviteCodeRequest` 扩展字段 + - `ProfileInviteCodeAdminResponse` 扩展字段 + - 如需要,增加时间窗相关状态枚举或派生字段 +- `server-rs/crates/spacetime-client/src/module_bindings/*` + - 重新生成 bindings + - mapper 补齐新字段 + +### 2. API Server +- `server-rs/crates/api-server/src/runtime_profile.rs` + - 接收/转发邀请码时间窗参数 + - 返回新增字段给后台 + - 如需要,调整校验错误文案 +- `server-rs/crates/api-server/src/app.rs` + - 若有新路由或错误码需挂接,在此统一登记 + +### 3. Admin Web +- `apps/admin-web/src/api/adminApiTypes.ts` + - 增加邀请码时间窗字段 + - 如有需要,增加后台操作请求结构字段 +- `apps/admin-web/src/api/adminApiClient.ts` + - 透传新的请求/响应字段 +- `apps/admin-web/src/app/adminRoutes.ts` + - 不一定需要改,但如果新增独立页面/子面板,需要在此登记 +- `apps/admin-web/src/styles/admin.css` + - 确认弹窗与时间窗展示样式 +- `apps/admin-web/src/**` 实际管理页面组件 + - 邀请码编辑表单 + - 邀请码列表状态展示 + - 所有写操作前的二次确认弹窗 + +### 4. 文档 +- `docs/technical/` 或 `docs/design/` + - 补一份邀请码时间窗与后台确认交互说明 +- 如现有文档已经覆盖后台管理规范,则优先补充现有文档,不重复造新说明页 + +--- + +## 分步实施计划 + +### Task 1: 明确数据模型与契约扩展 +**Objective:** 定义邀请码开始/截止日期字段及其在响应中的展示方式。 + +**要点:** +- 确认字段名采用 `starts_at` / `expires_at`,避免与现有字段语义冲突。 +- 确认时间类型统一用 `Timestamp` / 毫秒微秒整数转换策略。 +- 明确返回给后台的字段是否需要附带派生状态(如 `status`)。 + +**产出:** +- 契约字段定义 +- 状态枚举/派生规则 + +--- + +### Task 2: 更新 SpacetimeDB 表与迁移 +**Objective:** 让邀请码表可保存有效期,并兼容旧数据。 + +**要点:** +- 修改 `ProfileInviteCode` 表结构。 +- 更新迁移逻辑,旧记录默认无开始/截止。 +- 检查是否需要补充索引或查询辅助字段。 + +**验证:** +- 旧数据能正常读取。 +- 新数据能写入开始/截止。 + +--- + +### Task 3: 实现邀请填写时的时间窗校验 +**Objective:** 在邀请码被填写时正确拒绝未生效或已过期的邀请码。 + +**要点:** +- 在 `redeem_profile_referral_invite_code_record` 内增加开始/截止校验。 +- 保持“自己的邀请码不能填”“邀请码不存在”等原有错误优先级清晰。 +- 保留历史绑定关系不受影响。 + +**验证:** +- 未到开始时间时返回明确错误。 +- 超过截止时间时返回明确错误。 +- 正常区间可绑定成功。 + +--- + +### Task 4: 扩展后台邀请码管理接口 +**Objective:** 让后台可以创建/修改邀请码时间窗,并在列表中查看状态。 + +**要点:** +- 扩展 `AdminUpsertProfileInviteCodeRequest`。 +- 扩展 `ProfileInviteCodeAdminResponse`。 +- `api-server` 接口负责接收新字段并转发。 +- 列表接口返回可读时间字段与状态。 + +**验证:** +- 后台表单提交后,返回结果包含时间窗信息。 +- 列表页能看到状态与时间。 + +--- + +### Task 5: 给后台所有写操作加二次确认 +**Objective:** 统一拦截所有后台写动作,避免误点直接生效。 + +**覆盖范围建议:** +- 邀请码新增/更新 +- 兑换码新增/更新/停用 +- 任务配置新增/更新/停用 +- 后续新增的管理写操作 + +**实现要求:** +- 在真正调用 API 之前弹出确认框。 +- 确认框需要展示对象名、操作类型、影响范围。 +- 取消后不发送请求。 +- 尽量抽象出通用确认组件/通用 action 包装函数,避免每个页面重复写。 + +**验证:** +- 点击“保存”不会直接提交,需先确认。 +- 点击“取消”不会发请求。 +- 所有后台写入口行为一致。 + +--- + +### Task 6: 补充文档与交互说明 +**Objective:** 把新规则写进项目文档,避免后续实现偏差。 + +**要点:** +- 记录邀请码时间窗语义。 +- 记录后台二次确认规范。 +- 说明哪些动作属于“必须确认”的写操作。 + +--- + +## 测试与验证 + +### 服务端 +- 邀请码时间窗单测 / 集成测试 +- 邀请码 redeem 流程回归测试 +- 旧数据兼容测试 + +### API / 前端 +- 管理后台列表展示正确 +- 表单提交能回传新字段 +- 二次确认取消后不请求接口 +- 二次确认确认后正常提交 + +### 推荐验证命令 +- 视项目现有脚本执行对应后端测试 +- 前端按 admin-web 构建/测试脚本验证 +- 如涉及生成绑定,先确认生成产物无漏字段 + +--- + +## 风险与权衡 + +1. **时间字段格式不统一** + - 风险:前后端对时间单位理解不一致。 + - 处理:在契约层明确是 ISO 字符串还是微秒整数,并全链路统一。 + +2. **后台“所有操作”范围过大** + - 风险:遗漏某些写入口。 + - 处理:先枚举现有写 API,再做统一确认封装。 + +3. **邀请码过期后历史链接解释成本** + - 风险:用户误以为历史邀请码失效影响已绑定关系。 + - 处理:文案明确“仅影响新填写,不影响已绑定记录”。 + +4. **契约与生成绑定联动较多** + - 风险:字段变更后生成文件数量较多。 + - 处理:先改源契约与服务端,再统一重生成 bindings。 + +--- + +## 待确认问题 + +1. `starts_at` / `expires_at` 在接口里要返回 **ISO 字符串** 还是 **微秒整数**? +2. 后台二次确认是否统一用一个全局弹窗组件,还是页面级本地实现? +3. 邀请码列表是否需要直接展示“状态标签”还是只展示时间字段由前端推导? +4. 现有后台所有写操作里,是否还要覆盖调试类接口,还是仅覆盖业务管理接口? + +--- + +## 建议执行顺序 + +1. 先确认时间字段格式与确认弹窗范围。 +2. 再改服务端契约与迁移。 +3. 再改 redeem 校验与后台接口。 +4. 最后统一改 admin-web 的二次确认与表单展示。 + +--- + +**结论:** 这是一个适合分阶段落地的改动,建议先做“邀请码时间窗 + 后台统一二次确认”的基础能力,再补交互细节。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index cd7bc74c..60be224b 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -122,6 +122,8 @@ export interface AdminUpsertProfileRedeemCodeRequest { export interface AdminUpsertProfileInviteCodeRequest { inviteCode: string; metadata?: Record; + startsAt?: string | null; + expiresAt?: string | null; } export interface AdminDisableProfileRedeemCodeRequest { @@ -166,6 +168,9 @@ export interface ProfileInviteCodeAdminResponse { userId: string; inviteCode: string; metadata: Record; + startsAt?: string | null; + expiresAt?: string | null; + status: 'pending' | 'active' | 'expired'; createdAt: string; updatedAt: string; } diff --git a/apps/admin-web/src/components/useAdminWriteConfirm.tsx b/apps/admin-web/src/components/useAdminWriteConfirm.tsx new file mode 100644 index 00000000..6f62abfe --- /dev/null +++ b/apps/admin-web/src/components/useAdminWriteConfirm.tsx @@ -0,0 +1,105 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; + +interface AdminWriteConfirmOptions { + action: string; + target: string; +} + +interface PendingConfirm extends AdminWriteConfirmOptions { + resolve: (confirmed: boolean) => void; +} + +export function useAdminWriteConfirm() { + const [pendingConfirm, setPendingConfirm] = useState(null); + const cancelButtonRef = useRef(null); + + const confirmWrite = useCallback((options: AdminWriteConfirmOptions) => { + return new Promise((resolve) => { + setPendingConfirm((current) => { + if (current) { + current.resolve(false); + } + return {...options, resolve}; + }); + }); + }, []); + + const closeConfirm = useCallback( + (confirmed: boolean) => { + const current = pendingConfirm; + if (!current) { + return; + } + setPendingConfirm(null); + current.resolve(confirmed); + }, + [pendingConfirm], + ); + + useEffect(() => { + if (!pendingConfirm) { + return; + } + + cancelButtonRef.current?.focus(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeConfirm(false); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeConfirm, pendingConfirm]); + + const confirmDialog = pendingConfirm ? ( +
{ + if (event.target === event.currentTarget) { + closeConfirm(false); + } + }} + > +
+
+

确认操作

+ {pendingConfirm.action} +
+
+
+
操作
+
{pendingConfirm.action}
+
+
+
对象
+
{pendingConfirm.target}
+
+
+
该操作会立即影响线上数据
+
+ + +
+
+
+ ) : null; + + return {confirmWrite, confirmDialog}; +} diff --git a/apps/admin-web/src/pages/AdminDebugHttpPage.tsx b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx index a5ac8ad1..1dd48c54 100644 --- a/apps/admin-web/src/pages/AdminDebugHttpPage.tsx +++ b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx @@ -7,6 +7,7 @@ import type { AdminDebugHttpMethod, AdminDebugHttpResponse, } from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; import {formatUnknownJson, handlePageError} from './pageUtils'; interface AdminDebugHttpPageProps { @@ -33,6 +34,7 @@ export function AdminDebugHttpPage({ const [result, setResult] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); const jsonPreview = useMemo( () => formatUnknownJson(result?.bodyJson), @@ -46,6 +48,16 @@ export function AdminDebugHttpPage({ } setErrorMessage(''); + if (method !== 'GET') { + const confirmed = await confirmWrite({ + action: `${method} 调试请求`, + target: path.trim(), + }); + if (!confirmed) { + return; + } + } + setIsSubmitting(true); try { const response = await debugAdminHttp(token, { @@ -209,6 +221,7 @@ export function AdminDebugHttpPage({ )} + {confirmDialog} ); } diff --git a/apps/admin-web/src/pages/AdminInviteCodePage.tsx b/apps/admin-web/src/pages/AdminInviteCodePage.tsx index a507ef4d..dfbf119a 100644 --- a/apps/admin-web/src/pages/AdminInviteCodePage.tsx +++ b/apps/admin-web/src/pages/AdminInviteCodePage.tsx @@ -5,7 +5,11 @@ import { listProfileInviteCodes, upsertProfileInviteCode, } from '../api/adminApiClient'; -import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes'; +import type { + AdminUpsertProfileInviteCodeRequest, + ProfileInviteCodeAdminResponse, +} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; import {handlePageError} from './pageUtils'; interface AdminInviteCodePageProps { @@ -22,12 +26,15 @@ export function AdminInviteCodePage({ onResultChange, }: AdminInviteCodePageProps) { const [inviteCode, setInviteCode] = useState(''); + const [startsAt, setStartsAt] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); const [metadataText, setMetadataText] = useState('{}'); const [errorMessage, setErrorMessage] = useState(''); const [listErrorMessage, setListErrorMessage] = useState(''); const [entries, setEntries] = useState([]); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); + const {confirmWrite, confirmDialog} = useAdminWriteConfirm(); useEffect(() => { void refreshInviteCodes(); @@ -54,12 +61,29 @@ export function AdminInviteCodePage({ } setErrorMessage(''); + const validityError = validateValidityWindow(startsAt, expiresAt); + if (validityError) { + setErrorMessage(validityError); + return; + } + + const confirmed = await confirmWrite({ + action: '保存邀请码', + target: inviteCode.trim(), + }); + if (!confirmed) { + return; + } + setIsSaving(true); try { - const response = await upsertProfileInviteCode(token, { + const payload: AdminUpsertProfileInviteCodeRequest = { inviteCode: inviteCode.trim(), metadata: parseMetadata(metadataText), - }); + startsAt: startsAt ? toIsoDateTime(startsAt) : null, + expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, + }; + const response = await upsertProfileInviteCode(token, payload); onResultChange(response); upsertEntry(response); fillForm(response); @@ -89,9 +113,13 @@ export function AdminInviteCodePage({ function fillForm(entry: ProfileInviteCodeAdminResponse) { setInviteCode(entry.inviteCode); + setStartsAt(toDateTimeLocalValue(entry.startsAt)); + setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); setMetadataText(JSON.stringify(entry.metadata, null, 2)); } + const validityError = validateValidityWindow(startsAt, expiresAt); + return (
@@ -127,6 +155,25 @@ export function AdminInviteCodePage({ /> +
+ + +
+