This commit is contained in:
2026-05-08 22:07:05 +08:00
61 changed files with 4364 additions and 202 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ temp*build*/
/target/
/logs
.worktrees/
.env.secrets.local

View File

@@ -0,0 +1,584 @@
# 我的页签反馈入口与反馈页 Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 在平台“我的”页签中新增“反馈”入口,点击后进入独立反馈路由,并按用户提供的参考图落地反馈页面 UI。
**Architecture:** 复用现有前端单页路由体系:`SelectionStage` 负责页面阶段,`appPageRoutes.ts` 负责 URL 映射,`PlatformEntryFlowShellImpl` 负责按阶段渲染视图。“我的”页签只增加一个入口回调,不在当前面板下方展开内容;反馈页作为独立页面组件挂到新阶段。首版先做前端静态表单与本地提交成功态,不新增后端表结构或 SpacetimeDB 写入,除非产品补充明确要求持久化反馈。
**Tech Stack:** React 19、TypeScript、Tailwind utility class、lucide-react、现有 Genarrative 平台入口组件体系。
---
## Current context / assumptions
## Reference image
![帮助与反馈参考图](assets/profile-feedback-reference-2026-05-08.png)
参考图是一张移动端“帮助与反馈”页面,视觉和信息结构如下:
- 页面整体:浅灰背景,白色圆角卡片,黑/深灰标题文字,浅灰 placeholder蓝色主按钮与蓝色文本链接。
- 顶部栏:白色导航/header左侧为小 home 图标,中间标题为“帮助与反馈”,右侧为胶囊形更多/控制区。项目实现时可按现有平台导航规范简化为返回按钮 + 居中标题;若需要完全贴近图片,可使用 home 图标作为返回到“我的”页签的按钮。
- 内容区 section label左上灰色文字“反馈问题”。
- 第一张表单卡标题“问题描述”大文本输入区域placeholder 为“请填写10个字以上的问题描述以便我们提供更好的帮助温馨提醒您请勿填写身份证号等个人隐私信息。”右下角字数统计“0/200”。
- 第二张表单卡:标题“上传凭证(提供问题截图)”,左侧虚线边框上传方块,内含图片/上传 + 加号图标,文字“上传凭证”“(最多四张)”。
- 第三张表单卡标题“联系电话”placeholder 为“选填,如您填写则将会同步开发者与您联系”。
- 底部操作:大号蓝色圆角按钮“提交”,下方居中蓝色链接“查看反馈与投诉记录”。
实现约束:
- 反馈页面应命名为“帮助与反馈”,但“我的”页签入口可显示为“反馈”或“帮助与反馈”,优先以清爽短入口为准。
- 问题描述最少 10 个字、最多 200 个字,并实时显示 `当前字数/200`
- 上传凭证首版如不接后端,可先支持前端选择/预览最多 4 张图片,提交时仅进入成功态;如无法快速安全实现预览,可先保留上传占位并在文档中标注待接入。
- 联系电话为选填。
- “查看反馈与投诉记录”首版无后端记录时可以先禁用、隐藏,或点击后给出轻量提示;若保留可见,应在计划/PRD 标明记录页不在首版范围。
1. 当前工作区是 `/home/dsk/workspace/Genarrative/.worktrees/hermes-19e77eb0`,不要额外拼接 `Genarrative/`
2. 平台首页复用 `src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryHomeView.tsx` 只是 re-export。
3. “我的”页签的常用功能区域位于 `src/components/rpg-entry/RpgEntryHomeView.tsx:3958-4000`,现有入口包括“每日任务 / 邀请好友 / 填邀请码 / 玩家社区”。
4. 当前页面阶段类型位于 `src/components/platform-entry/platformEntryTypes.ts:16-38`;路由映射位于 `src/routing/appPageRoutes.ts:7-27`
5. `src/App.tsx:60-63` 调用 `pushAppHistoryPath(resolvePathForSelectionStage(stage))`,所以新增阶段必须同步 `APP_STAGE_ROUTES`
6. `PlatformEntryFlowShellImpl``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx:5081+` 根据 `selectionStage` 渲染不同页面,平台首页在 `selectionStage === 'platform'` 分支。
7. 参考图片已保存到 `.hermes/plans/assets/profile-feedback-reference-2026-05-08.png`,计划与实现均以该图片内容为主要 UI 依据。
8. 按项目约束,工程修改需同步文档;若没有更具体 PRD需要先补一份简洁落地文档到 `docs/`
## Proposed approach
新增一个轻量前端反馈页面阶段:
- 路由:`/profile/feedback`
- 阶段:`profile-feedback`
- 组件:`src/components/platform-entry/PlatformFeedbackView.tsx`
- “我的”页签入口:在常用功能区增加“反馈”按钮,点击调用新 prop `onOpenFeedback`
- 页面行为:
- 顶部返回按钮返回 `platform` 阶段,并切回 `profile` 页签。
- 未登录用户点击入口时,优先弹登录;如果产品允许匿名反馈,可改为允许进入。
- 表单字段首版只在前端维护:问题描述、上传凭证图片、联系电话。
- 提交后显示成功态,不做 API 请求;后续如要持久化,再补 `shared-contracts + api-server + SpacetimeDB` 方案。
## Step-by-step plan
### Task 1: 补充反馈页落地文档
**Objective:** 先把反馈入口和页面边界写清楚,避免编码时需求漂移。
**Files:**
- Create: `docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`
**Step 1: 新建 PRD 文档**
写入内容建议包含:
```markdown
# 我的页签反馈入口 PRD
## 目标
- 在“我的”页签提供反馈入口。
- 点击入口进入独立反馈路由 `/profile/feedback`
- 反馈页移动端优先,桌面端居中卡片展示。
## 首版范围
- 前端表单:问题描述、上传凭证占位/前端图片预览、联系电话。
- 问题描述 10-200 字,显示实时字数统计。
- 提交后显示成功态。
- 不新增后端存储,不修改 SpacetimeDB 表结构。
## 交互
- 已登录用户:点击“反馈”进入反馈页。
- 未登录用户:点击入口触发登录弹窗。
- 返回:回到平台首页并定位“我的”页签。
## UI
-`.hermes/plans/assets/profile-feedback-reference-2026-05-08.png` 为准,落地“帮助与反馈”移动端表单。
- 不在 UI 中堆叠说明性长文案。
- 入口是独立页面导航,不在“我的”面板下方展开。
## 验收
- `/profile/feedback` 可被浏览器前进/后退访问。
- “我的”页签反馈入口可进入该路由。
- 移动端和桌面端均不溢出。
- `npm run check:encoding``npm run typecheck` 通过。
```
**Step 2: 验证文档编码**
Run: `npm run check:encoding`
Expected: PASS无中文编码错误。
**Step 3: Commit**
```bash
git add docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md
git commit -m "docs: add profile feedback entry prd"
```
### Task 2: 扩展页面阶段与路由映射
**Objective:**`/profile/feedback` 成为主应用可识别的独立路由。
**Files:**
- Modify: `src/components/platform-entry/platformEntryTypes.ts`
- Modify: `src/routing/appPageRoutes.ts`
**Step 1: 修改 SelectionStage 类型**
`SelectionStage` union 中追加:
```ts
| 'profile-feedback'
```
推荐放在 `'platform'` 附近或末尾,保持字面量清晰。
**Step 2: 修改 STAGE_ROUTE_ENTRIES**
`src/routing/appPageRoutes.ts``STAGE_ROUTE_ENTRIES` 中追加:
```ts
['profile-feedback', '/profile/feedback'],
```
建议放在 `['platform', '/']` 后面,表示平台个人页子路由。
**Step 3: 验证类型推导**
Run: `npm run typecheck`
Expected: 若还未创建渲染组件,可能只通过路由类型;若出现 exhaustive 相关错误,留到后续任务处理。
**Step 4: Commit**
```bash
git add src/components/platform-entry/platformEntryTypes.ts src/routing/appPageRoutes.ts
git commit -m "feat: add profile feedback route stage"
```
### Task 3: 新建反馈页面组件
**Objective:** 创建移动端优先的独立反馈页面。
**Files:**
- Create: `src/components/platform-entry/PlatformFeedbackView.tsx`
**Step 1: 创建组件 props**
组件接口建议:
```ts
export type PlatformFeedbackViewProps = {
onBack: () => void;
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<void>;
};
export type PlatformFeedbackPayload = {
description: string;
contactPhone: string;
evidenceFiles: File[];
};
```
**Step 2: 实现 UI 状态**
使用 `useState` 管理:
- `description`
- `contactPhone`
- `evidenceFiles`
- `evidencePreviewUrls`
- `error`
- `isSubmitting`
- `submitted`
**Step 3: 实现页面结构**
建议结构:
```tsx
import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send } from 'lucide-react';
import { useEffect, useState } from 'react';
const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200;
const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10;
const MAX_FEEDBACK_EVIDENCE_COUNT = 4;
```
页面外壳建议复用现有视觉变量:
```tsx
<div className="platform-page-stage platform-remap-surface min-h-0 min-w-0 overflow-y-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4">
<header className="platform-surface platform-surface--soft rounded-[1.6rem] px-4 py-4">
<button type="button" onClick={onBack} ...>
<ArrowLeft ... />
</button>
<h1></h1>
</header>
...
</div>
</div>
```
注意:不要写大段“功能说明类文案”;字段 label 简短即可。
**Step 4: 表单校验**
提交时:
- `description.trim().length < 10`提示“请填写10个字以上的问题描述”
- `description.trim().length > 200`:提示“问题描述不能超过 200 字”
- `contactPhone.trim().length > 40`:提示“联系电话不能超过 40 字”
- 上传凭证最多 4 张;超出时提示“最多上传四张凭证”
**Step 5: 提交行为**
首版无后端时:
```ts
await onSubmit?.({
description: description.trim(),
contactPhone: contactPhone.trim(),
evidenceFiles,
});
setSubmitted(true);
```
如果没有传 `onSubmit`,也显示成功态。代码注释说明:
```ts
// 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。
```
**Step 6: Commit**
```bash
git add src/components/platform-entry/PlatformFeedbackView.tsx
git commit -m "feat: add platform feedback view"
```
### Task 4: 在“我的”页签增加反馈入口 prop
**Objective:** 让 Profile 页面能触发反馈路由,同时保持组件职责清晰。
**Files:**
- Modify: `src/components/rpg-entry/RpgEntryHomeView.tsx`
- Modify: `src/components/platform-entry/PlatformEntryHomeView.tsx`通常无需改re-export 类型会自动带出)
**Step 1: 扩展 Props**
`RpgEntryHomeViewProps` 中新增:
```ts
onOpenFeedback?: () => void;
```
**Step 2: 从 props 解构**
`RpgEntryHomeView` 函数参数解构区新增:
```ts
onOpenFeedback,
```
**Step 3: 增加入口按钮**
`profileContent` 的常用功能 grid 中,建议在“玩家社区”后追加:
```tsx
<ProfileShortcutButton
label="反馈"
subLabel="问题与建议"
icon={MessageCircle}
onClick={onOpenFeedback}
/>
```
如果参考图中入口位置不同,按参考图调整;但仍必须进入独立路由。
**Step 4: 未提供回调时行为**
`ProfileShortcutButton` 已允许 `onClick` 为空;此处传 `onOpenFeedback` 即可。若希望按钮始终可点,应在父组件必传。
**Step 5: 验证类型**
Run: `npm run typecheck`
Expected: PASS 或只剩父组件未传 prop 的问题。
**Step 6: Commit**
```bash
git add src/components/rpg-entry/RpgEntryHomeView.tsx src/components/platform-entry/PlatformEntryHomeView.tsx
git commit -m "feat: add feedback shortcut to profile tab"
```
### Task 5: 接入 PlatformEntryFlowShellImpl 渲染与导航
**Objective:** 点击“反馈”进入 `/profile/feedback`,返回后回到“我的”页签。
**Files:**
- Modify: `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
**Step 1: 导入组件**
在 imports 中新增:
```ts
import { PlatformFeedbackView } from './PlatformFeedbackView';
```
**Step 2: 创建打开反馈页函数**
`const { setPlatformTab } = platformBootstrap;` 附近新增:
```ts
const openProfileFeedback = useCallback(() => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setPlatformTab('profile');
setSelectionStage('profile-feedback');
}, [authUi, setPlatformTab, setSelectionStage]);
```
如产品允许匿名反馈,则移除登录判断。
**Step 3: 给首页传入入口回调**
`PlatformEntryHomeView` props 中加入:
```tsx
onOpenFeedback={openProfileFeedback}
```
**Step 4: 增加渲染分支**
`selectionStage === 'platform'` 分支后、详情页分支前新增:
```tsx
{selectionStage === 'profile-feedback' && (
<motion.div
key="platform-profile-feedback"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<PlatformFeedbackView
onBack={() => {
setPlatformTab('profile');
setSelectionStage('platform');
}}
/>
</motion.div>
)}
```
**Step 5: 直接访问路由的 tab 同步**
为处理用户直接访问 `/profile/feedback` 后返回,返回逻辑已 `setPlatformTab('profile')`。如需要进入反馈页时也设置 tab可加 effect
```ts
useEffect(() => {
if (selectionStage === 'profile-feedback') {
setPlatformTab('profile');
}
}, [selectionStage, setPlatformTab]);
```
**Step 6: Commit**
```bash
git add src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
git commit -m "feat: wire profile feedback navigation"
```
### Task 6: 增加路由与反馈页基础测试
**Objective:** 用自动化测试覆盖新路由映射和反馈页核心交互。
**Files:**
- Create or Modify: `src/routing/appPageRoutes.test.ts`
- Create: `src/components/platform-entry/PlatformFeedbackView.test.tsx`
**Step 1: 路由测试**
如果已有 `appPageRoutes.test.ts`,追加;否则创建:
```ts
import { describe, expect, it } from 'vitest';
import {
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './appPageRoutes';
describe('appPageRoutes', () => {
it('resolves profile feedback route', () => {
expect(resolveSelectionStageFromPath('/profile/feedback')).toBe('profile-feedback');
expect(resolvePathForSelectionStage('profile-feedback')).toBe('/profile/feedback');
});
});
```
**Step 2: 反馈页测试**
测试重点:
- 渲染“帮助与反馈”标题。
- 问题描述过短时提交显示错误。
- 输入有效问题描述后提交显示成功态。
- 字数统计随输入更新。
- 上传凭证入口最多接受 4 张图片。
- 点击返回调用 `onBack`
示例:
```tsx
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PlatformFeedbackView } from './PlatformFeedbackView';
describe('PlatformFeedbackView', () => {
it('validates content before submit', () => {
render(<PlatformFeedbackView onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: '提交' }));
expect(screen.getByText('请填写10个字以上的问题描述')).toBeInTheDocument();
});
});
```
注意检查项目当前 test setup 是否已引入 jest-dom matcher若没有使用 truthy DOM 节点断言:
```ts
expect(screen.getByText('请补充反馈内容')).toBeTruthy();
```
**Step 3: 运行定向测试**
Run:
```bash
npm run test -- src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
```
Expected: PASS。
**Step 4: Commit**
```bash
git add src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
git commit -m "test: cover profile feedback route and form"
```
### Task 7: 全量前端验证与移动端 smoke
**Objective:** 确认新增页面不破坏编码、类型和基础交互。
**Files:**
- No code changes unless validation finds issues.
**Step 1: 编码检查**
Run: `npm run check:encoding`
Expected: PASS。
**Step 2: ESLint**
Run: `npm run lint:eslint`
Expected: PASS。
**Step 3: TypeScript**
Run: `npm run typecheck`
Expected: PASS。
**Step 4: 测试**
Run: `npm run test -- src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx`
Expected: PASS。
**Step 5: 本地页面 smoke**
Run: `npm run dev:web`
手动验证:
1. 打开 `http://127.0.0.1:3000/`
2. 登录后进入“我的”页签。
3. 点击“反馈”。
4. 地址变为 `/profile/feedback`
5. 页面显示反馈表单。
6. 提交空内容出现错误。
7. 输入有效内容后显示成功态。
8. 点击返回后回到首页“我的”页签。
9. 直接打开 `http://127.0.0.1:3000/profile/feedback` 能显示反馈页。
10. 使用移动端视口(如 390×844确认按钮和表单不溢出。
**Step 6: Commit validation fixes if any**
```bash
git add <fixed-files>
git commit -m "fix: polish profile feedback validation"
```
## Files likely to change
- `docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`:新增反馈入口落地文档。
- `src/components/platform-entry/platformEntryTypes.ts`:新增 `profile-feedback` 阶段。
- `src/routing/appPageRoutes.ts`:新增 `/profile/feedback` 路由映射。
- `.hermes/plans/assets/profile-feedback-reference-2026-05-08.png`:反馈页参考图。
- `src/components/platform-entry/PlatformFeedbackView.tsx`:新增反馈页面。
- `src/components/rpg-entry/RpgEntryHomeView.tsx`:新增“我的”页签反馈入口和 `onOpenFeedback` prop。
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`:接入反馈页打开与返回导航。
- `src/routing/appPageRoutes.test.ts`:新增路由映射测试。
- `src/components/platform-entry/PlatformFeedbackView.test.tsx`:新增反馈页交互测试。
## Tests / validation
Minimum required:
```bash
npm run check:encoding
npm run typecheck
npm run test -- src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
```
Recommended before merge:
```bash
npm run lint:eslint
npm run test
npm run build
```
Manual smoke:
- 登录后“我的”页签显示“反馈”入口。
- 点击入口进入 `/profile/feedback`
- 浏览器后退和页面返回按钮行为符合预期。
- 移动端视口无横向溢出。
- 页面没有把反馈表单展开在“我的”页签下方。
## Risks, tradeoffs, and open questions
1. **参考图落地风险:** 参考图是浅色移动端表单,而项目现有平台 UI 可能偏游戏化/深色变量;实现时需要优先复刻信息结构与交互,不要为了完全一致而破坏现有主题适配。
2. **反馈是否需要后端存储:** 本计划首版不新增后端,只做前端收集和成功态。若产品要求真实提交,需要新增后端方案:`shared-contracts` DTO、`api-server` 路由、SpacetimeDB 表/迁移、后台查看入口,并按 SpacetimeDB skills 执行。
3. **登录要求:** 计划默认未登录用户点击入口弹登录。若希望匿名反馈,应取消该限制,并在 payload 中允许无用户身份。
4. **入口位置:** 当前建议放在“我的”页签常用功能 grid 中。若参考图明确是列表项或设置区入口,应按图调整,但仍进入独立路由。
5. **图标复用:** 可先用 `MessageCircle``MessageSquareText`,避免引入新依赖。
6. **现有大文件风险:** `RpgEntryHomeView.tsx` 很大,实施时必须局部补丁,避免整文件重写导致中文编码或格式大范围变化。
## Implementation notes
- 所有中文注释和文案保持 UTF-8。
- 不要新增 `.env.local``.gitignore`
- 不要把反馈页做成“我的”页签内部展开面板。
- 不要新增后端或数据库,除非用户确认反馈必须持久化。
- 若后续接入后端,必须先补技术文档,再按 DDD 与 SpacetimeDB 约束落地。

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -14,7 +14,13 @@
- 关联:相关文件、文档、提交或 Issue
```
---
## OSS V4 签名时间和 bucket/object_key 兼容
- 现象OSS V4 私有读签名在部分时间点失败,可能出现 `OSS V4 签名时间格式化失败` 或服务端判定签名格式错误;排查用例中 bucket 为 `xushi-dev`object_key 为 `generated-square-hole-assets/.../image.png`
- 原因:旧逻辑依赖 `time::Time::to_string()` 再去掉冒号,小时小于 10 时输出不稳定补零;同时排查时容易把 bucket 名误当成 object_key 的一部分。
- 处理OSS V4 `x-oss-date` 使用固定宽度 `yyyyMMdd'T'HHmmss'Z'` 格式化;调用读签名或 `HEAD Object` 时只传 object_key不要传 `bucket/object_key` 拼接路径。
- 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并用 bucket=`xushi-dev`、object_key=`generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png` 覆盖签名生成。
- 关联:`server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/platform-oss/README.md`
## 中文乱码与编码风险

View File

@@ -0,0 +1,233 @@
---
name: genarrative-admin-backoffice
short_description: 在 Genarrative/百梦后台新增或修改管理页、后台只读/写接口、导出能力时使用。
description: 在 Genarrative/百梦后台新增或修改管理页、后台 BFF 接口、shared-contracts/admin DTO、admin-web 路由导航、Excel/表格导出与验证发布时使用。
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Genarrative, 百梦后台, admin-web, 后台接口, Excel导出, Rust, Axum, SpacetimeDB]
related_skills: [genarrative-play-type-integration]
---
# Genarrative / 百梦后台管理功能接入流程
用于在 Genarrative 项目中新增或修改百梦后台管理端能力,包括后台页面、后台 API、管理端 DTO、导航路由、表格明细、导出、鉴权与验证。
## 适用场景
- 新增百梦后台页面或导航项,例如“埋点数据”“任务配置”“邀请码”。
- 新增 `/admin/api/*` 接口。
- 修改 `apps/admin-web` 的后台页面、API client、路由、Shell 导航。
- 在后台展示 SpacetimeDB 表明细或统计数据。
- 新增“总览 → 单表查询”这类表统计跳转与查询页联动能力时,优先复用现有总览页的表统计作为入口,不另造第二套表目录。
- 后台导出 CSV / Excel / `.xls` 表格文件。
- 后台数据页中与业务事件、任务、登录等链路相关的问题,不能只看后台页面;要追到对应前台/API/reducer 写入点,确认“数据何时产生”。例如排查 `daily_login` 时,不要假设它一定由认证登录接口写入;先核对当前分支实现。历史实现曾在 `GET /api/profile/tasks` 打开任务中心时写入、`POST /api/profile/tasks/{task_id}/claim` 领奖时兜底写入后续方案A把“任务中心读取写埋点”拆出为独立 procedure任务中心只读取/刷新进度,登录成功链路应显式调用每日登录埋点入口。
## 标准落地顺序
### 0. 先确认现有后台入口
在新增后台页或回答“后台某个数据在哪里”前,先核对是否已有入口,避免重复造页:
- 数据库表统计当前在后台“总览”页,不是独立页面:`apps/admin-web/src/pages/AdminOverviewPage.tsx` 的“表统计”面板。
- 表统计行可直接跳转到表查询页:点击后设置 `window.location.hash = #tables?table=<tableName>`,由单独的 `#tables` 页接收参数并查询。
- `#tables` 页应在首次加载和 `hashchange` 时都重新读取 `table` 参数,避免只在初次 mount 时生效。
- 前端通过 `apps/admin-web/src/api/adminApiClient.ts``getAdminOverview(token)` 请求 `GET /admin/api/overview`
- 后端路由在 `server-rs/crates/api-server/src/app.rs` 挂载 `/admin/api/overview`handler 为 `admin_overview`
- 表统计逻辑在 `server-rs/crates/api-server/src/admin.rs``fetch_database_overview`:先读 SpacetimeDB schema 表名,再逐表执行 `SELECT COUNT(*) AS row_count FROM {table_name}`private 或当前身份不可见会显示“不可统计private 或当前身份不可见)”。
- DTO 在 `server-rs/crates/shared-contracts/src/admin.rs``AdminOverviewResponse` / `AdminDatabaseOverviewPayload` / `AdminDatabaseTableStatPayload`,前端对应类型在 `apps/admin-web/src/api/adminApiTypes.ts`
- 如果本次需求是“每张表都能查”,优先新增 `GET /admin/api/database/tables``GET /admin/api/database/tables/{tableName}/rows` 两个只读接口,并在前端新建统一的表查询页,而不是把查询逻辑塞回总览页。
### 1. 先补技术方案文档
项目要求工程修改前先检查/补充落地文档。若没有明确文档,先写到 `docs/technical/`,至少说明:
- 后台页面目标。
- 后端接口路径、鉴权、query/body、response。
- 数据来源和是否修改 SpacetimeDB schema。
- 前端页面字段、筛选项、导出格式。
- 验收命令。
示例参考:
- `references/admin-tracking-events-export-2026-05-07.md`
- `references/admin-database-table-query-2026-05-08.md`
### 2. 后端 DTO 放 shared-contracts/admin
文件:
- `server-rs/crates/shared-contracts/src/admin.rs`
做法:
- 新增 request/query/response DTO。
- 使用 `#[serde(rename_all = "camelCase")]`
- 添加中文注释。
- 字段名与前端管理端类型保持一致。
如果 `apps/admin-web` 当前没有直接消费 Rust shared-contracts 生成物,还要同步:
- `apps/admin-web/src/api/adminApiTypes.ts`
### 3. 后端 handler 放 api-server/admin.rs
文件:
- `server-rs/crates/api-server/src/admin.rs`
- `server-rs/crates/api-server/src/app.rs`
要求:
- Handler 使用 `Extension(_admin): Extension<AuthenticatedAdmin>`,并在 router 中套 `require_admin_auth`
- 只读接口也必须走后台鉴权。
- query 参数使用 `Query<T>`
- 返回 `json_success_body(Some(&request_context), payload)`
-`app.rs` 挂到 `/admin/api/...`
### 4. 读取 SpacetimeDB 表明细时优先 HTTP SQL 只读
适合后台只读运营页:
- 不改表结构。
- 不新增 reducer。
- API Server 通过 SpacetimeDB HTTP SQL 读取真实数据。
注意:
- SQL 字段固定白名单,不要 `SELECT *`
- 用户输入只允许有限筛选字段,手动 trim、白名单枚举、字符串转义。
- limit 必须 clamp例如默认 200、最大 1000。
- SpacetimeDB 2.2 HTTP SQL 不支持 `ORDER BY`如果后台需要倒序展示明细SQL 中不要拼 `ORDER BY`,先查有限 `LIMIT`,再在 api-server 内按时间字段排序,否则会返回 `HTTP 400 Unsupported: SELECT ... ORDER BY ... LIMIT ...`
- 如果 HTTP SQL 返回 `no such table ... If the table exists, it may be marked private`,不要急着改表名或新增 reducer先确认本地 CLI 是否以当前 standalone 的 identity/token 登录。清空本地数据库或重建 standalone 后,旧 CLI token 可能看不到 private table。按“本地 private table SQL 权限修复”流程用 `/v1/identity` 获取 token`spacetime login --token` 登录。
- SQL 解析要兼容 SpacetimeDB HTTP SQL 的 statement array + rows 形态。
- SpacetimeDB HTTP SQL 读取 private table 时enum / Option / Timestamp 可能以 SATS 原始 JSON 返回,例如 `scope_kind=[3,[]]``Some("user")=[0,"user"]``None=[1,[]]``Timestamp=[1778207451731746]`。后台列表、详情弹窗和 Excel 导出不要直接展示这些原始形态;应在 api-server 解析层或前端展示层转换为人可读值enum 映射为业务字符串Option 的 None 显示 `-`,微秒级 Timestamp 格式化为本地可读时间。
- 可复用已有 `/v1/database/{db}/sql` 请求风格和 token 配置。
### 5. 前端接入 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/app/AdminShell.tsx`
- `apps/admin-web/src/app/AdminApp.tsx`
- `apps/admin-web/src/pages/<AdminXxxPage>.tsx`
- `apps/admin-web/src/styles/admin.css`
接入步骤:
1.`adminApiTypes.ts` 增加 query/entry/list 类型。
2.`adminApiClient.ts` 增加 API 方法;用 `URLSearchParams` 拼非空 query。
3.`adminRoutes.ts` 增加 route id、label、hash。
4.`AdminShell.tsx` 增加 route icon`routeIcons` 必须覆盖全部 `AdminRouteId`
5.`AdminApp.tsx` import 并按 routeId 渲染页面。
6. 新增页面组件,保持 UI 简洁,不写大段规则说明。
7. 如果页面通过 hash 携带子参数,路由解析和页内参数解析要分开:`resolveAdminRoute()` 只负责路由片段,页面组件自己解析 `?table=` 之类的查询参数;同时要监听 `hashchange`,避免切页后参数不同步。
8. 列表行点击跳转优先用 hash不要额外引入全局路由库或重新发明一套页面状态系统。
## Excel 导出推荐做法
后台运营导出不一定要引入 `xlsx` 依赖;简单表格可用浏览器端 HTML table + `.xls`
- Blob MIME`application/vnd.ms-excel;charset=utf-8`
- 文件扩展名:`.xls`
- 文本前加 UTF-8 BOM / `<meta charset="UTF-8">`
- 所有单元格做 HTML escape。
- ID、大数字、日期类字段使用 `mso-number-format:'\@';` 保持文本格式,避免 Excel 科学计数法。
- 导出当前筛选结果,避免后端新增 Excel 库依赖。
## 本地启动与联调
后台改完后如需本地查看页面和接口,优先按本次联调范围选择脚本:
```bash
# 只看后台页面 + api-server不要求 SpacetimeDB 真实数据
npm run api-server
npm run admin-web:dev -- --host 127.0.0.1
# 完整 Rust 本地栈SpacetimeDB + 发布模块 + api-server + 主站 + 后台
npm run dev
```
验证地址通常为:
- `npm run api-server` 单独启动api-server `http://127.0.0.1:3100/healthz`,后台前端 `http://127.0.0.1:5173/admin/`
- `npm run dev` 完整栈SpacetimeDB `http://127.0.0.1:3101/v1/ping`api-server `http://127.0.0.1:8082/healthz`,主站 `http://127.0.0.1:3000/`,后台 `http://127.0.0.1:3102/admin/`
注意:
- `npm run api-server` 首次启动可能先编译 Rust后台进程短时间内无完整日志等待编译完成后再查端口。
- 不要默认用 `3200` 验证 api-server当前脚本环境变量常见为 `GENARRATIVE_API_PORT=3100`。不确定时用 `ss -ltnp | grep api-server` 或读取进程环境核对,敏感值输出必须打码。
- `admin-web``/` 可能返回 302 跳转到 `/admin/`;验证前端时直接请求 `/admin/`
- api-server 启动日志中 SpacetimeDB `127.0.0.1:3101` 连接被拒绝,不一定代表 api-server 没起来;只表示依赖的本地 SpacetimeDB 不可用。后台中需要读 SpacetimeDB 的页面(如埋点明细、表查询)要等 SpacetimeDB 可用后才能返回真实数据。
- `npm run dev` 依赖 `spacetime` CLI先用 `command -v spacetime && spacetime --version` 确认可用。
- WSL/Linux 下 SpacetimeDB CLI 2.2.0 使用项目内 `--root-dir`standalone 可能会回调 `${root_dir}/bin/current/spacetimedb-cli`。如果报 `It seems like the spacetime version set as current may not exist``exec failed for .../.spacetimedb/local/bin/current/spacetimedb-cli`,把用户级安装同步到项目 root-dir
```bash
rm -rf server-rs/.spacetimedb/local/bin
mkdir -p server-rs/.spacetimedb/local/bin
cp -a ~/.local/share/spacetime/bin/2.2.0 server-rs/.spacetimedb/local/bin/2.2.0
ln -sfn 2.2.0 server-rs/.spacetimedb/local/bin/current
server-rs/.spacetimedb/local/bin/current/spacetimedb-cli --version
```
- `scripts/dev-rust-stack.sh` 默认 `api timeout: 300s`. 合并 master 后首次 Rust 依赖/工作区重编译可能超过 300s导致完整 `npm run dev` 在 api-server 就绪前超时并回收 SpacetimeDB。先让 Rust 编译完成,或临时用 `bash scripts/dev-rust-stack.sh --skip-spacetime --skip-publish --api-timeout-seconds 900` 预热 api-server 编译;之后再重新跑完整 `npm run dev`
- 用户贴出的 Hermes background watch 通知可能来自已退出的旧 session。先用 `process poll` 查该 session 状态,再判断是否需要处理;不要把旧失败误判成当前服务失败。
## 测试与验证
常用命令:
```bash
# Rust 格式化检查
cd server-rs
cargo fmt -p api-server -p shared-contracts --check
# 后端相关测试,按测试名过滤
cargo test -p api-server admin_tracking -- --nocapture
# 前端后台类型检查 / 构建
cd ..
npm run admin-web:typecheck
npm run admin-web:build
# 中文/编码检查
npm run check:encoding
# diff 空白检查
git diff --check
```
如果 `npm run admin-web:typecheck``Cannot find module .../node_modules/typescript/bin/tsc`,说明当前 worktree 未安装 npm 依赖;先运行:
```bash
npm install
```
不要把该错误误判成 TypeScript 代码错误。
## 常见坑
1. 只在 `app.rs` import handler 不够,必须实际 `.route(...)` 挂载,并套 `require_admin_auth`
2. `cargo fmt --manifest-path server-rs/Cargo.toml` 在该 workspace 可能报 `Failed to find targets`;进入 `server-rs` 后用 `cargo fmt --all``cargo fmt -p api-server -p shared-contracts --check`
3. `cargo fmt --all` 可能格式化不相关 Rust 文件;提交前用 `git status` 检查并 revert 非本任务文件。
4. patch 工具对 Rust 单文件 lint 可能用 Rust 2015 edition 误报 `async fn is not permitted in Rust 2015`;以 `cargo test/check` 为准。
5. `adminRoutes` 新增 route id 后,`AdminShell.routeIcons` 必须同步,否则 TypeScript 会因 `satisfies Record<AdminRouteId, ...>` 报错。
- 后台页面中的中文和 JSON 预览要避免整文件重写导致编码问题;修改后运行 `npm run check:encoding`
- 后台数据页移动端要保证表格横向滚动,不要让整页布局撑坏。
- 若用户追问“之前不是说要把 npm run dev 修好吗”这类已承诺的 dev 启动问题,不要只解释;先复现 `npm run dev`再按启动日志修脚本并验证到服务就绪。WSL/Linux 下 `spacetime start --root-dir=server-rs/.spacetimedb/local` 可能需要把用户级 SpacetimeDB 版本目录同步到项目 root-dir 的 `bin/<version>` 并建立 `bin/current`,详见 `references/dev-rust-stack-startup-2026-05-08.md`
- 涉及敏感配置、token、密码、连接串时输出和文档中统一写 `[REDACTED]`
## 参考资料
- `references/admin-database-table-query-2026-05-08.md`:本次后台数据库表查询接入的实现要点、校验规则与验证结果。
- `references/admin-tracking-events-export-2026-05-07.md`本次新增后台“埋点数据”页、SpacetimeDB HTTP SQL 只读明细、前端 `.xls` 导出的实现细节。
- `references/private-table-sql-token-refresh.md`:本地清库/重建 standalone 后,用 `/v1/identity` + `spacetime login --token` 刷新 CLI token以便 HTTP SQL 读取 private table。
- `references/spacetimedb-http-sql-sats-display.md`:通过 HTTP SQL 读取 private table 时enum / Option / Timestamp 的 SATS 原始 rows 如何转换为后台列表、详情和 Excel 可读值。
- `references/daily-login-tracking-trigger-points.md`:排查后台 `daily_login` 埋点为何不是登录接口写入,而是任务中心读取/领奖兜底写入的触发点记录。
- `references/daily-login-auth-closure.md`将方案A拆出的每日登录埋点入口接入真实认证成功链路时的推荐接入点、非阻断语义、测试和提交注意事项。
- `references/dev-rust-stack-startup-2026-05-08.md``npm run dev` / `scripts/dev-rust-stack.sh` 在 WSL/Linux 下同步 SpacetimeDB root-dir 安装、避免 `bin/current/spacetimedb-cli` 缺失和冷编译超时的修复记录。

View File

@@ -0,0 +1,29 @@
# 本次后台表查询接入的可复用经验
## 需求落点
- 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=<name>`
- 新增独立 `#tables` 页承载表选择、关键词搜索、JSON filters、limit、行详情弹窗。
## 后端实现要点
- 新增只读接口:
- `GET /admin/api/database/tables`
- `GET /admin/api/database/tables/{table_name}/rows`
- 表名必须来自 schema 白名单;再加一层 identifier 校验,避免任意 SQL 表名注入。
- `limit` 必须 clamp本次实现使用默认 100、最大 500。
- `search` / `filters` 不进入 SQL 字符串:
- SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}`
- 返回后在 api-server 内存中过滤
- `filters` 仅接受 JSON object按列名匹配非 object 直接 400
- SpacetimeDB HTTP SQL 返回可能是 statement array + rows解析时要兼容这一层结构。
## 前端实现要点
- `adminRoutes` 必须新增 `tables``AdminShell.routeIcons` 也要同步覆盖。
- `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`
- worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。
## 验证结果
- `cargo test -p api-server admin_database -- --nocapture` 通过。
- `cargo fmt --manifest-path Cargo.toml -p api-server -p shared-contracts --check` 通过。
- `npm run admin-web:typecheck` 通过。
- `npm run admin-web:build` 通过。
- `npm run check:encoding` 通过。

View File

@@ -0,0 +1,53 @@
# 后台埋点数据页与本地启动验证记录2026-05-07
## 背景
本次在 Genarrative/百梦后台新增“埋点数据”页:
- 后端新增 `GET /admin/api/tracking/events`
- shared-contracts 新增 admin tracking query/list/entry DTO。
- 前端新增 `#tracking` 路由、导航、表格、详情面板与 `.xls` 导出。
- 导出使用浏览器端 HTML table + Excel MIME不引入 `xlsx` 依赖。
## 关键实现点
- 后台只读接口仍必须套 `require_admin_auth`
- SpacetimeDB 明细读取使用 HTTP SQL不新增 reducer、不改 schema。
- SQL 固定白名单列,不用 `SELECT *`
- Query 只允许 `eventKey/userId/scopeKind/scopeId/limit`
- `scopeKind` 只允许 `site/work/module/user`
- limit 默认 200最大 1000。
- SpacetimeDB HTTP SQL 响应要兼容 statement array + `rows`Option 可能表现为 `{ "some": value }`
- 前端导出 `.xls` 时给单元格加 `mso-number-format:'\\@';`,防止 Excel 把 ID 转科学计数法。
## 验证命令
```bash
cd <repo-root>
npm install # 若 node_modules 缺失
npm run admin-web:typecheck
npm run admin-web:build
npm run check:encoding
cd server-rs
cargo fmt -p api-server -p shared-contracts --check
cargo test -p api-server admin_tracking -- --nocapture
```
## 本地启动观察
启动命令:
```bash
cd <repo-root>
npm run api-server
npm run admin-web:dev -- --host 127.0.0.1
```
实际验证:
- api-server 监听 `127.0.0.1:3100`,健康检查为 `http://127.0.0.1:3100/healthz`
- admin-web 监听 `127.0.0.1:5173`,后台地址为 `http://127.0.0.1:5173/admin/`
- 请求 `http://127.0.0.1:5173/` 会 302 到 `/admin/`
- 不能默认用 3200 检查 api-server本地脚本通过 `GENARRATIVE_API_PORT=3100` 启动。
- 如果启动日志出现 SpacetimeDB `127.0.0.1:3101` connection refusedapi-server 仍可能已正常监听;这是依赖的本地 SpacetimeDB 未启动,埋点页读真实数据会受影响。

View File

@@ -0,0 +1,55 @@
# 真实登录成功链路接入每日登录埋点2026-05-08
## 背景
后台“埋点数据”页要能看到真实登录产生的 `daily_login`。此前已完成方案 A把“读取任务中心时顺手写每日登录埋点”拆成独立 SpacetimeDB procedure/client 方法,避免后台查看或刷新任务中心污染登录数据。
闭环时不要再把写入点放回任务中心读取流程;应在认证成功且会话签发后显式调用每日登录埋点入口。
## 推荐接入点
`api-server` 认证成功路径中,先创建/签发会话,再非阻断记录埋点,再同步认证快照并返回:
1. `create_auth_session` / `create_password_auth_session` 成功。
2. 调用统一 helper`record_daily_login_tracking_event_after_auth_success(...)`
3. helper 调用 `state.spacetime_client().record_daily_login_tracking_event(user_id.to_string()).await`
4. 成功写 `info`,失败写 `warn`,不能把埋点失败返回给用户。
已验证的真实登录链路包括:
- 手机验证码登录:`server-rs/crates/api-server/src/phone_auth.rs``phone_login`
- 密码登录入口:`server-rs/crates/api-server/src/password_entry.rs``password_entry`
- 重置密码后自动登录:`server-rs/crates/api-server/src/password_management.rs``reset_password`
- 微信 OAuth 回调登录:`server-rs/crates/api-server/src/wechat_auth.rs``handle_wechat_callback`
- 微信绑定手机号后自动登录:`server-rs/crates/api-server/src/wechat_auth.rs``bind_wechat_phone`
- refresh cookie 续期:`server-rs/crates/api-server/src/refresh_session.rs``refresh_session`。在 `rotate_session` 成功并签发新 access token 后记录,`login_method` 应使用 `rotated.session.issued_by_provider.clone()`,不要固定写成 Password。
## 关键实现约束
- 埋点是运营数据必须保持非阻断SpacetimeDB 调用失败只记录日志,不影响登录成功返回。
- helper 建议放在 `auth_session.rs`,避免各登录 handler 重复错误处理。
- refresh cookie 续期也被产品视为一次每日登录触发;接入 `refresh_session.rs` 时必须放在 token rotate 和 access token 签发成功之后,且保持非阻断,避免刷新失败或缺 cookie 时误写埋点。
- `handle_wechat_callback` 如果要记录 `request_id/operation`,需要在 handler 参数中补 `Extension<RequestContext>`;确认路由层已注入 RequestContext。
- 单元测试默认不启动 SpacetimeDB。若直接调用真实 `spacetime_client` 会让既有认证测试依赖外部服务;可在 `#[cfg(test)]` 下让 helper no-op仅用编译和现有登录成功测试覆盖调用点不破坏返回。
- 后续如需要严格断言“helper 被调用”,应优先为 Spacetime client 引入可注入 trait/mock而不是让 API 单测连接真实 SpacetimeDB。
## 验证命令
```bash
cd server-rs
cargo fmt -p api-server --check
cargo check -p api-server
cargo check -p spacetime-client
cargo test -p api-server auth_session -- --nocapture
cargo test -p api-server refresh_session_rotates_cookie_and_returns_new_access_token -- --nocapture
cargo test -p api-server password_entry_logs_in_existing_phone_user_and_sets_refresh_cookie -- --nocapture
cargo test -p api-server phone_login_creates_user_and_sets_refresh_cookie -- --nocapture
cd ..
npm run check:encoding
git diff --check
```
## 提交注意
- 不要提交 `.env.local``.env.secrets.local` 或任何 token/密码/连接串。
- 若工作区里有本地敏感文件,只提交明确改动的 Rust 文件和 `docs/technical/*` 文档。

View File

@@ -0,0 +1,97 @@
# Genarrative daily_login 埋点触发点排查记录
## 背景
用户在后台“埋点数据”页看到 `daily_login` 事件后询问:为什么每日登录埋点看起来只有在用户领取每日登录任务奖励后才记录,而不是登录时记录。
## 结论
当前代码口径里,`daily_login` 不是认证登录成功瞬间写入的事件。它挂在个人任务链路:
- `GET /api/profile/tasks`:读取任务中心时会记录当日 `daily_login`,并刷新任务进度。
- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励时,如果任务配置是 daily_login会兜底记录当日 `daily_login`
因为 `record_daily_login_tracking_event``daily-login:<user_id>:<day_key>` 作为 event id并先查重所以同一用户同一北京自然日最多写一条。
## 关键文件
- `docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`
- 第 47 行左右写明:用户打开任务中心时后端幂等记录当日 `daily_login`;点击领取时校验进度和领奖记录。
- 接口说明中写明 `GET /api/profile/tasks` 会读取任务中心并记录当日登录埋点。
- `server-rs/crates/api-server/src/runtime_profile.rs`
- `get_profile_task_center` 调用 `state.spacetime_client().get_profile_task_center(user_id)`
- `claim_profile_task_reward` 调用 `state.spacetime_client().claim_profile_task_reward(user_id, task_id)`
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- `get_profile_task_center_snapshot(..., record_login_event: bool)``record_login_event` 为 true 时调用 `record_daily_login_tracking_event`
- `claim_profile_task_reward_record` 对 daily_login 任务调用 `record_daily_login_tracking_event` 作为兜底。
- `record_daily_login_tracking_event` 负责生成 event id、查重、写入 `tracking_event` 和更新 `tracking_daily_stat`
- `server-rs/crates/api-server/src/phone_auth.rs`
- 手机号登录成功后做验证码校验、新用户奖励、邀请码绑定、session 签发、认证快照同步;当前没有写入 `daily_login`,也没有调用任务中心接口。
## 排查方法
1. 不要只看后台埋点页。先搜事件 key 和任务接口:
```bash
git grep -n "daily_login\|tracking_event\|get_profile_task_center\|claim_profile_task_reward" -- server-rs apps docs
```
2. 对照设计文档中的事件口径:
```bash
sed -n '35,58p' docs/technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md
```
3. 追 API handler 到 SpacetimeDB reducer
- `api-server/src/runtime_profile.rs`
- `spacetime-client` 对应 procedure wrapper
- `spacetime-module/src/runtime/profile.rs`
4. 再看真实登录接口是否写入同一事件。手机号登录入口是:
- `server-rs/crates/api-server/src/phone_auth.rs::phone_login`
## 常见误判
- 后台只是在展示 `tracking_event`,不是事件产生点。
- “每日登录”这个中文名容易让人以为它必然在 auth 登录成功时写入;当前实现不是这样。
- 如果用户登录后没有打开“我的/任务中心”,只在领奖时触发 claim 接口,就会表现为“领奖时才出现埋点”。
- 领取接口里的写入是兜底,避免用户直接点击领取时因为未先打开任务中心而无法完成每日任务。
## 后续方案A落地记录
在后续修复中采用“方案A”把“读取任务中心时顺手记录每日登录埋点”拆成独立 SpacetimeDB procedure使任务中心读取只负责读取/刷新进度,避免后台查看或刷新任务中心时污染埋点数据。
关键变化:
- `server-rs/crates/module-runtime/src/domain.rs`
- 新增 `RuntimeTrackingEventProcedureResult { ok, error_message }`,用于返回纯事件写入结果。
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 新增 `record_daily_login_tracking_event_and_return(ctx, input)` procedure。
- `get_profile_task_center` 注释和行为调整为只读取/刷新任务进度,不再作为每日登录埋点产生点。
- `server-rs/crates/spacetime-client/src/runtime.rs`
- 新增 `record_daily_login_tracking_event(user_id)` client 方法,调用新 procedure。
- `server-rs/crates/spacetime-client/src/mapper.rs`
- 新增 `map_runtime_tracking_event_procedure_result`,把 `ok=false` 映射为 `procedure_failed`
落地注意:
- 这一步只拆出后端写入入口,不等于所有登录方式已经接入;接入手机号/微信/密码等认证成功链路前,需确认产品口径:统计登录成功是否应覆盖所有登录方式,以及事件失败是否阻断登录。
- 修改 `spacetime-module` procedure 后,通常需要重新生成/同步 SpacetimeDB 绑定;若直接手补 `spacetime-client/src/module_bindings`,要非常谨慎,因为该目录声明为自动生成。
- patch 工具可能对 Rust 单文件使用 2015 edition lint看到 `async fn is not permitted in Rust 2015` 时不要立即按该误报改代码,应以 `cd server-rs && cargo test/check ...` 为准。
验证记录:
```bash
cd server-rs
cargo test -p module-runtime runtime_profile_task_status_matches_progress_and_claim -- --nocapture
```
该测试通过可验证任务中心领域进度/领取逻辑未被破坏;完整接入认证链路后还应补 api-server 层登录成功埋点测试。
## 后续设计建议
如果产品口径要求“登录成功就算每日登录”,应把 `daily_login` 写入点前移到统一 auth 登录成功链路,并覆盖手机号/微信/密码等登录方式;任务中心只读取进度或最多保留幂等兜底。
如果需要同时分析真实登录和任务完成,建议新增独立事件,例如 `auth_login_success` / `user_login_success`,让 `daily_login` 继续表示每日任务完成条件。

View File

@@ -0,0 +1,46 @@
# `npm run dev` / `scripts/dev-rust-stack.sh` 启动修复记录
## 症状
- 多个 worktree 同时本地开发时SpacetimeDB 数据库名可能相同,早期用项目级 `--root-dir` 隔离 CLI 状态来规避冲突。
- 实测后确认:真正需要隔离的是 standalone 的 `data-dir`,不需要把 publish 也绑到项目级 root-dir。
- 早期脚本曾通过把用户级 SpacetimeDB 可执行文件目录同步到 `server-rs/.spacetimedb/local/bin/current` 来满足 standalone 回调需求,但这会把整套可执行文件复制进项目本地目录,维护成本高,也容易和用户级 CLI 版本漂移。
- 多个 worktree 同时启动时SpacetimeDB 端口可能冲突CLI 会询问是否使用最近的可用端口。
-`npm run dev` 从仓库根目录直接执行 `spacetime publish --module-path server-rs/crates/spacetime-module`,内部 Cargo 不一定读取 `server-rs/.cargo/config.toml`,可能绕过 sccache/linker/target 缓存策略,表现为 spacetime-module 每次像全量重编译。
- `api-server` 首次冷编译时,默认 300 秒超时不够,容易在就绪前被回收。
## 当前方案
1. SpacetimeDB 可执行文件继续使用用户环境里的 `spacetime` 命令
- 启动 standalone 时不再复制 `spacetimedb-cli`、版本目录或 `bin/current`
- `spacetime start` 不再通过工程内 `--root-dir` 寻找可执行文件。
2. 数据目录显式指定到项目本地
- 默认 `SPACETIME_DATA_DIR=${SERVER_RS_DIR}/.spacetimedb/local/data`
- 启动命令使用 `spacetime start --data-dir "${SPACETIME_DATA_DIR}" --listen-addr ...`
- 如需临时切换数据目录,可传 `--spacetime-data-dir <path>`
3. 端口冲突时自动接受 SpacetimeDB 建议端口
- 启动时不传 `--non-interactive`
- 脚本向 `spacetime start` 发送回车,接受“最近可用端口”的默认建议。
- 随后从启动日志中的 `Starting SpacetimeDB listening on ...` 解析实际端口。
- 解析出的实际端口会覆盖 `SPACETIME_SERVER`,后续 publish、api-server、前端代理统一使用这个端口。
4. publish 不再使用项目级 root-dir但要从 `server-rs` 目录执行
- 发布模块改为在 `server-rs` 下执行 `spacetime publish ... --server "${SPACETIME_SERVER}" ...`
- 这样 publish 使用用户级 CLI 默认身份/配置,不再依赖 worktree 内 root-dir。
- 同时确保内部 Cargo 能读取 `server-rs/.cargo/config.toml`,复用项目级 sccache/linker/target 缓存策略,避免 `npm run dev` 比手动 publish 更容易触发慢速重编译。
5. 提高 api-server 就绪等待时间
- `API_SERVER_TIMEOUT_SECONDS` 保持 600降低首次冷编译误判失败概率。
## 复现 / 验证
- 运行脚本语法检查:`bash -n scripts/dev-rust-stack.sh`
- 运行帮助检查:`bash scripts/dev-rust-stack.sh --help`,确认有 `--spacetime-data-dir`
- 运行 `npm run dev` 后观察日志:
- 输出 `spacetime data: .../server-rs/.spacetimedb/local/data`
- 不再出现同步/复制本机 SpacetimeDB 安装到项目 root-dir 的日志。
- SpacetimeDB 能正常监听,并输出 `spacetime actual: http://127.0.0.1:<实际端口>`
- 若默认端口被占用,脚本应自动接受 SpacetimeDB 建议端口,并用实际端口发布模块、启动 api-server 和前端代理。
- 模块发布成功。
- 第二次无改动 publish 应接近手动 `cd server-rs && spacetime publish ...` 的增量速度。
- api-server 进入健康检查等待并最终可访问 `/healthz`
## 相关文件
- `scripts/dev-rust-stack.sh`
- `server-rs/.spacetimedb/local/data/`
- `server-rs/.cargo/config.toml`

View File

@@ -0,0 +1,37 @@
# 本地 private table SQL 权限修复
场景:
- 后台或 api-server 通过 SpacetimeDB HTTP SQL 读取 `tracking_event` 这类 private table。
- 本地清库、重建 standalone 或重新发布模块后,原 CLI token 失效SQL 可能报 `no such table ... If the table exists, it may be marked private`
操作步骤:
1. 清空本地 SpacetimeDB 数据目录
- 使用:`spacetime --root-dir=<root> server clear --yes`
- 只清本地开发环境,不要误伤远端或其他 worktree。
2. 启动本地 standalone
- 用项目约定的 `scripts/dev-rust-stack.sh` 或等价命令启动 `spacetime`
- 确认 `/v1/ping` 可访问后再取 identity。
3. 通过 `/v1/identity` 获取新 token 和 identity
- 使用 `POST http://127.0.0.1:3101/v1/identity`
- 只记录 identity不要在日志中打印 token 明文。
4. 用新 token 登录 CLI
- 运行:`spacetime --root-dir=<root> login --token <token>`
- 这会把 token 写到本地 CLI 配置,后续 HTTP SQL 可读 private table。
5. 重新验证 SQL
- 使用带 token 的 `POST /v1/database/<db>/sql`
- 先尝试 `SELECT ... FROM tracking_event LIMIT 1`
- 若成功,再让 api-server 走同样 token。
6. 如果 api-server 需要复用 token
- 优先读取项目内本地 CLI 配置中的 token而不是硬编码或回填到 `.env`
- 输出日志时统一 `[REDACTED]`
排查要点:
- `ORDER BY` 和 private table 是两个独立问题,先分开修。
- 清库后旧 token 很可能不再能看见 private table不代表表不存在。
-`/v1/identity` 返回的 token 没权限,再检查当前 standalone 是否就是刚启动的本地实例、database 名是否一致、模块是否已重新发布。

View File

@@ -0,0 +1,43 @@
# SpacetimeDB HTTP SQL SATS 值后台展示处理
本参考用于 Genarrative 后台通过 SpacetimeDB HTTP SQL 读取表明细并展示/导出时,处理 SQL rows 中的 SATS 原始 JSON 值。
## 典型现象
读取 private table例如 `tracking_event`HTTP SQL 可能返回如下原始形态:
- enum`RuntimeTrackingScopeKind::User` 返回 `[3, []]`
- `Option<String>::Some("user_00000001")` 返回 `[0, "user_00000001"]`
- `Option<String>::None` 返回 `[1, []]`
- `Timestamp` 返回 `[1778207451731746]`
如果直接 `value.to_string()` 展示,后台会出现 `[3,[]]``[0,"..."]``[1,[]]``[1778207451731746]`,运营不可读。
## 推荐处理
1. 后端解析层优先标准化:
- Option`[0, value] -> value``[1, []] -> None`
- enum按生成 binding 的 variant 顺序映射,例如 `RuntimeTrackingScopeKind``site/work/module/user`,索引 `0/1/2/3` 分别对应这些字符串
- Timestamp单元素数组 `[micros] -> "micros"`
2. 前端展示层再格式化时间:
- 纯数字时间戳按微秒处理:`Date(Math.floor(micros / 1000))`
- ISO 字符串用 `new Date(value)`
- 展示为 `YYYY-MM-DD HH:mm:ss`
3. 列表、详情弹窗、Excel 导出必须使用同一套格式化结果,避免导出仍残留 SATS 原始值。
4. 增加单测覆盖 SATS 原始 rows至少断言
- `[3, []] -> user`
- `[0, "user"] -> Some("user")`
- `[1, []] -> None`
- `[1778207451731746] -> "1778207451731746"`
## 验收建议
- `cargo test -p api-server admin_tracking -- --nocapture`
- `npm run admin-web:typecheck`
- `npm run admin-web:build`
- `npm run check:encoding`
- `git diff --check`
## 注意
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。

View File

@@ -0,0 +1,132 @@
---
name: genarrative-auth-session-flow
description: 在 Genarrative 中排查或修改登录、access token、refresh cookie、AuthGate 会话恢复、登录态刷新、认证埋点链路时使用。
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Genarrative, auth, session, cookie, refresh-token, AuthGate, tracking]
related_skills: [systematic-debugging, test-driven-development, genarrative-profile-features]
---
# Genarrative 认证会话与登录埋点链路
用于 Genarrative 中登录、会话恢复、refresh cookie 续期、access token 补票、AuthGate 恢复登录态,以及每日登录/认证相关埋点的排查与修改。
## 适用场景
- 用户反馈登录态、cookie、自动续期、刷新页面后状态异常。
- 修改 `AuthGate``apiClient``authService` 或 Rust `api-server` 认证接口。
- 排查“已登录但打开网页没有触发登录埋点”等 session restore 场景。
- 修改手机验证码登录、密码登录、微信登录、重置密码后自动登录、refresh session rotate。
- 需要判断某个前端动作是否真正调用了后端 refresh/session 或埋点 procedure。
## 关键代码路径
前端:
- `src/components/auth/AuthGate.tsx`
- 登录态 hydrate / restore 的入口。
- 监听 `AUTH_STATE_EVENT` 后重新 hydrate。
- 是否先 refresh、再 `/api/auth/me`,决定打开页面是否进入后端 refresh 链路。
- `src/services/apiClient.ts`
- access token 本地保存、`ensureStoredAccessToken()``refreshStoredAccessToken()``fetchWithApiAuth()`
- `ensureStoredAccessToken()` 有 token 时会直接复用,不一定触发后端 refresh。
- `refreshStoredAccessToken()` 应直接调用 refresh 接口,用于必须轮换 cookie / 写续期埋点的场景。
- `src/services/authService.ts`
- `getCurrentAuthUser()` 请求 `/api/auth/me`
- 登录、登出、账号安全相关 API client。
后端:
- `server-rs/crates/api-server/src/auth_session.rs`
- 创建 refresh cookie / access token。
- `record_daily_login_tracking_event_after_auth_success(...)` 统一写每日登录埋点;失败 warning不阻断认证流程。
- `server-rs/crates/api-server/src/refresh_session.rs`
- `POST /api/auth/session/refresh`
- rotate refresh session、签发新 access token、记录每日登录埋点。
- `server-rs/crates/api-server/src/auth_me.rs`
- `/api/auth/me` 只读取当前 access token 对应用户,不应假设它会触发 refresh 或登录埋点。
- `server-rs/crates/api-server/src/phone_auth.rs`
- `server-rs/crates/api-server/src/password_entry.rs`
- `server-rs/crates/api-server/src/password_management.rs`
- `server-rs/crates/api-server/src/wechat_auth.rs`
- 各真实认证成功入口。
- `server-rs/crates/spacetime-client/src/runtime.rs`
- `record_daily_login_tracking_event(user_id)` 调用 SpacetimeDB procedure。
- `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- `record_daily_login_tracking_event_and_return` procedure。
- 任务中心读取不应污染每日登录埋点;如看到 `get_profile_task_center` 顺手写 `daily_login`,优先复核是否回归。
## 调试顺序
1. 先明确用户场景属于哪类:
- 新登录成功。
- cookie/access token 已过期后的自动刷新。
- 已登录且 cookie/access token 未过期时打开网页。
- 只调用 `/api/auth/me` 或某个受保护业务接口。
2. 查前端实际调用链,不要只看后端埋点点位:
- `AuthGate` hydrate 是否调用 `refreshStoredAccessToken()`
- 是否只是 `ensureStoredAccessToken()` + `/api/auth/me`
- `fetchWithApiAuth()` 是否因为已有 access token 而跳过 refresh
3. 查后端实际埋点点位:
- 登录成功入口是否在 session 创建后调用 helper。
- refresh session 是否在 rotate 与 access token 签发成功后调用 helper。
- 失败策略是否只 warning、不阻断响应。
4. 如涉及 SpacetimeDB procedure/table/binding按项目 SpacetimeDB skills 与文档同步检查绑定生成、`migration.rs`、private table 限制。
5. 修改前补齐 `docs/technical/` 中对应方案/根因;修改后同步更新。
## 关键经验:已登录打开网页也要主动 refresh 才能写登录埋点
常见误判:后端已经在 refresh cookie 续期时写每日登录埋点,就以为“打开网页”会触发埋点。
实际链路中,如果用户已经登录且本地 access token 还有效:
1. `ensureStoredAccessToken()` 会直接返回已有 token。
2. `AuthGate` 随后请求 `/api/auth/me`
3. `/api/auth/me` 只校验/读取用户,不会 rotate refresh session。
4. 因此后端 refresh/session 埋点不会触发。
若产品要求“已登录且 cookie 没过期时打开网页也记录登录埋点”,`AuthGate` 的 restore/hydrate 应主动调用 `refreshStoredAccessToken()`,再调用 `getCurrentAuthUser()`
## 每日登录埋点原则
- 真实登录成功:在 refresh session / access token 创建成功后记录。
- cookie refresh 续期:在 rotate refresh session 成功且新 access token 签发成功后记录。
- 已登录打开网页:前端必须主动走 refresh 续期链路,不能只请求 `/api/auth/me`
- `login_method` 对于 refresh 场景使用 refresh session 保存的 `issued_by_provider`
- 埋点失败不阻断登录、续期、会话恢复或 token 返回,只记录 warning。
- 任务中心读取不应作为登录埋点来源,避免后台查看/刷新任务中心污染登录数据。
## 测试与验证命令
按改动范围选择:
```bash
npm run test -- AuthGate.test.tsx
npm run typecheck
cd server-rs && cargo test -p api-server auth_session -- --nocapture
cd server-rs && cargo test -p api-server refresh_session_rotates_cookie_and_returns_new_access_token -- --nocapture
cd server-rs && cargo check -p api-server
cd server-rs && cargo check -p spacetime-client
cd server-rs && cargo check -p spacetime-module
npm run check:encoding
git diff --check
```
注意Vitest 0.34 不支持 Jest 的 `--runInBand`;不要把 `--runInBand` 加到 `npm run test -- AuthGate.test.tsx` 后面。
## 常见坑
1.`/api/auth/me` 当作 refresh它只读当前 access token不会写 refresh 埋点。
2. 只在后端 refresh handler 加埋点,但前端有有效 access token 时根本不调用 refresh。
3. `ensureStoredAccessToken()` 有 token 时会直接返回;需要强制 refresh 时应使用 `refreshStoredAccessToken()`
4. 在埋点 helper 中返回错误并阻断登录/续期,会破坏认证主链路。
5. refresh 场景把 `login_method` 写死为 password会丢失手机/微信来源。
6. 修改中文文件后忘记 `npm run check:encoding`
7. `cargo fmt -p api-server` 或前端测试可能让 `.env.local``.gitignore` 出现非业务改动;提交前用 `git status --short` 检查并撤回无关敏感/环境文件。
## 参考资料
- `references/session-restore-daily-login-tracking-2026-05-08.md`:已登录且 cookie 未过期时打开网页未触发每日登录埋点的根因与修复案例。

View File

@@ -0,0 +1,75 @@
# Session restore 每日登录埋点案例2026-05-08
## 现象
用户反馈:已经登录且 cookie 没过期时,打开网页没有触发每日登录埋点。
## 根因
当本地 access token 仍有效时,前端 `AuthGate` 恢复登录态只会复用现有 token 并请求 `/api/auth/me``/api/auth/me` 只读取当前用户,不会进入 `POST /api/auth/session/refresh`,因此后端 refresh handler 中的每日登录埋点不会执行。
关键误判点:后端已经在 refresh cookie 续期写埋点,不等于“打开网页”一定会触发。前端必须实际调用 refresh/session 接口。
## 修复模式
1.`src/services/apiClient.ts` 暴露强制 refresh 方法,例如:
```ts
export async function refreshStoredAccessToken() {
return refreshAccessToken();
}
```
2.`src/components/auth/AuthGate.tsx` 的 hydrate/restore 中,使用:
```ts
await refreshStoredAccessToken();
const nextSession = await getCurrentAuthUser();
```
而不是只调用:
```ts
await ensureStoredAccessToken();
const nextSession = await getCurrentAuthUser();
```
3. 保留 `ensureStoredAccessToken()` 给普通受保护请求兜底;不要把所有请求都改成强制 refresh。
4. 确认 `server-rs/crates/api-server/src/refresh_session.rs` 在 rotate refresh session 成功且新 access token 签发成功后调用每日登录埋点 helper。
5. 确认 `server-rs/crates/spacetime-module/src/runtime/profile.rs``get_profile_task_center` 不再顺手写 `daily_login`,避免任务中心读取污染登录埋点。
## 测试
前端测试重点:
- `AuthGate` 会等待 `refreshStoredAccessToken()` 完成后才暴露已恢复用户内容。
- `AUTH_STATE_EVENT` 触发 hydrate 时仍保持已挂载平台内容和本地 tab 状态。
命令:
```bash
npm run test -- AuthGate.test.tsx
npm run typecheck
```
后端/SpacetimeDB 编译:
```bash
cd server-rs && cargo check -p spacetime-module
cd server-rs && cargo check -p api-server
```
全局检查:
```bash
npm run check:encoding
git diff --check
```
## 注意
- Vitest 0.34 不支持 Jest 的 `--runInBand` 参数;命令里不要加。
- 埋点失败只能 warning不能阻断登录态恢复。
- 如果后续发现打开页面产生过多 refresh 请求,需要在产品口径和埋点口径之间重新设计节流;但不能退回“只读 `/api/auth/me` 却期待写登录埋点”的状态。

View File

@@ -0,0 +1,348 @@
---
name: genarrative-play-type-integration
description: 在 Genarrative 中新增一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、结果页、可选 runtime 与作品架的顺序接入。
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Genarrative, 玩法接入, 创作入口, 前端, 后端, contracts, runtime]
related_skills: []
---
# Genarrative 新增玩法类型接入流程
用于在 Genarrative 中新增一个创作入口/玩法类型,而不是单纯说明用户如何从入口创建作品。
## 适用场景
- 新增一个游戏玩法入口
- 让某个玩法从“敬请期待”变为可创建
- 为新玩法补齐创作工作台、结果页、发布与试玩链路
- 将新玩法接入创作中心作品架与广场
## 先判断接入级别
### 1. 只做入口占位
只需要新增入口配置,不接 session/workspace/result/runtime。
适合:
- 敬请期待
- 灰度占位
### 2. 可进入创作工作台
需要补齐前端分流、session、工作台、结果页至少能生成草稿。
### 3. 完整玩法闭环
需要补齐:
- 创作入口
- 工作台
- 草稿生成
- 结果页
- 发布
- 试玩 runtime
- 作品架 / 广场 / 分享
## 推荐接入顺序
### Step 1: 先定玩法 ID 和能力边界
先明确:
- `id` 是什么
- 入口是否可见
- 是否可点击创建
- 是否需要对话式创作
- 是否需要生成中页面
- 是否需要 result/runtime/gallery/share
不要先随便起临时 ID 再改名。
### Step 2: 新增入口配置
文件:
- `src/config/newWorkEntryConfig.ts`
`NEW_WORK_ENTRY_CONFIG.creationTypes` 中新增或调整:
- `id`
- `title`
- `subtitle`
- `badge`
- `visible`
- `open`
字段语义:
- `visible: true`:在创作页签 / 新建作品入口中展示。
- `visible: false`:不在平台入口展示,但不删除既有玩法路由和能力。
- `open: true`:可点击进入创作流程。
- `open: false`:展示为锁定 / 敬请期待,不应进入创建流程。
如果只是占位:
- `visible: true`
- `open: false`
相关渲染与过滤位置:
- `src/components/platform-entry/platformEntryCreationTypes.ts`:将 `NEW_WORK_ENTRY_CONFIG.creationTypes` 映射为平台入口卡片,`getVisiblePlatformCreationTypes()` 会过滤隐藏项,并把可创建模板排在敬请期待模板前面。
- `src/components/custom-world-home/CustomWorldCreationStartCard.tsx`:创作页签首屏模板入口卡片的实际渲染位置。
- `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`:选择创作类型弹层的渲染位置。
注意:当前项目工作区通常已经是 `<repo-root>`,路径不要再额外拼接 `./Genarrative/`
### Step 3: 确认类型过滤逻辑
文件:
- `./Genarrative/src/components/platform-entry/platformEntryCreationTypes.ts`
检查:
- `getVisiblePlatformCreationTypes()` 是否能展示新类型
- `isPlatformCreationTypeVisible()` 是否能识别新类型
- `locked` / `hidden` 是否正确映射
### Step 4: 扩展页面阶段
文件:
- `./Genarrative/src/components/platform-entry/platformEntryTypes.ts`
为新玩法补充 `SelectionStage`
- `*-agent-workspace`
- `*-generating`(可选)
- `*-result`
- `*-runtime`(可选)
- `*-gallery-detail`(可选)
### Step 5: 在总流程中加类型分流
文件:
- `./Genarrative/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`handleCreationHubCreateType(type)` 中新增分支,确保:
- 能进入对应工作台
- 能设置对应 `selectionStage`
- 能关闭类型弹层
同时补:
- `open<Play>AgentWorkspace()`
- `leave<Play>Flow()`
- `submit<Play>Message()`(对话式玩法)
- `execute<Play>Action()`
### Step 6: 接入通用 Agent flow controller
文件:
- `./Genarrative/src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
如果是 Agent 型玩法,复用通用控制器:
- `createSession`
- `getSession`
- `streamMessage`
- `executeAction`
- `isBusy`
- `error`
- `streamingReplyText`
- `selectionStage` 切换
### Step 7: 定义 shared contracts
前端:
- `./Genarrative/packages/shared/src/contracts/`
后端:
- `./Genarrative/server-rs/crates/shared-contracts/src/`
至少补齐:
- session snapshot
- create session request/response
- message request/response
- action request/response
- draft/result 结构
- work summary / gallery 结构(如果需要)
- runtime 结构(如果需要)
### Step 8: 实现前端 service client
目录参考:
- `./Genarrative/src/services/`
按玩法补:
- creation client
- runtime client可选
- works client可选
- gallery client可选
建议保持和现有玩法一致的 API base 与命名风格。
### Step 9: 接后端 API
文件参考:
- `./Genarrative/server-rs/crates/api-server/src/puzzle.rs`
- `./Genarrative/server-rs/crates/api-server/src/puzzle_agent_turn.rs`
- `./Genarrative/server-rs/crates/api-server/src/match3d.rs`
通常需要:
- create session
- get session
- send message
- stream message
- execute action
- publish / save / delete
- runtime start / action可选
- gallery / detail可选
后端设计优先按 Genarrative 的 DDD 分层拆开不要把玩法规则、数据库事务、LLM 调用和 HTTP handler 混在一个文件里:
- `module-<play>`纯领域规则、状态机、draft/runtime 校验,不依赖 Axum、SpacetimeDB 或外部平台。
- `shared-contracts`:前后端 DTO、请求/响应、session snapshot、draft/result/runtime 结构。
- `spacetime-module`表定义、reducer/procedure、事务编排、migration表结构变化要同步生成绑定。
- `spacetime-client`api-server 到 SpacetimeDB 的 facade隐藏 reducer 调用细节。
- `api-server`Axum 路由、鉴权、SSE/stream、应用层编排。
- `platform-*`LLM、资产上传、鉴权、第三方服务等副作用。
建议按四条线设计后端能力:
- Agent 创作线session、turn、stream、compile action。
- Works 作品线:保存、发布、删除、草稿恢复。
- Gallery 广场线公开列表、详情、like/remix/share。
- Runtime 运行态线:开始试玩、提交动作、读取状态。
### Step 10: 新增工作台组件
目录建议:
- `./Genarrative/src/components/<play>-creation/<Play>AgentWorkspace.tsx`
两种形态:
#### 对话式
适合设定逐轮补齐。
参考:
- `BigFishAgentWorkspace.tsx`
- `Match3DAgentWorkspace.tsx`
#### 表单式
适合输入结构明确的玩法。
参考:
- `PuzzleAgentWorkspace.tsx`
### Step 11: 在渲染树中挂载新页面
文件:
- `./Genarrative/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
补齐:
- workspace 分支
- generating 分支(如需要)
- result 分支
- runtime 分支(如需要)
### Step 12: 新增结果页
目录建议:
- `./Genarrative/src/components/<play>-result/<Play>ResultView.tsx`
结果页至少支持:
- 展示 draft
- 返回编辑
- 发布
- 试玩
- 错误展示
### Step 13: 需要试玩就补 runtime
目录建议:
- `./Genarrative/src/components/<play>-runtime/<Play>RuntimeShell.tsx`
如果玩法是游戏类,建议补完整 runtime 闭环。
### Step 14: 接入作品架 / 广场 / 分享
需要改:
- `./Genarrative/src/components/custom-world-home/creationWorkShelf.ts`
- `./Genarrative/src/components/custom-world-home/CustomWorldCreationHub.tsx`
- `./Genarrative/src/services/publicWorkCode.ts`
如果玩法支持发布,还要补:
- public work code
- public detail
- publish share modal
- like/remix可选
### Step 15: 处理登录态与草稿恢复
要考虑:
- 刷新恢复草稿
- 退出登录清空私有状态
- result/draft 缺失时回退
- busy / generating / runtime 中断恢复
### Step 16: 补测试
至少覆盖:
- 入口展示
- 类型分流
- 工作台打开
- session 创建
- compile action
- result 页切换
- 发布后刷新作品架
- runtime 进入与退出
## 最小改动清单
### 只做占位
只改:
- `./Genarrative/src/config/newWorkEntryConfig.ts`
### 做到可进入工作台
至少改:
- `newWorkEntryConfig.ts`
- `platformEntryTypes.ts`
- `PlatformEntryFlowShellImpl.tsx`
- 新玩法 service client
- 新玩法工作台组件
- shared contracts
- 后端 API
### 做到完整闭环
还要补:
- result 页
- runtime
- works / gallery
- public code
- share
- 作品架聚合
- 测试
## 常见坑
1. 只加入口配置不够,类型分流和页面阶段也要补。
2. `SelectionStage` 不扩展,前端无法安全切页。
3. 新玩法如果要出现在作品架,必须改聚合逻辑,不只是加入口。
4. 发布后不刷新 works/gallery用户会看不到新作品。
5. 如果走 SpacetimeDB表结构变化要同步 migration 和绑定;`spacetime-client/src/module_bindings/` 通常是生成物,不要为了修编译或格式化而手改,优先改 module 源 schema/reducer/procedure 后重新生成。
6. 做 analytics/tracking 这类 runtime 能力时,不要只补 API DTO先在 `module-runtime` 写纯函数测试(例如 day/week/month/quarter/year bucket 聚合、scope/event 过滤RED 后再补领域类型与聚合函数。
7. 时间粒度聚合建议复用已有 date dimension 逻辑,把 daily stat 映射到 day/week/month/quarter/year bucketbucket 输出要有稳定排序,并显式携带 `bucketKey``bucketStartDateKey``bucketEndDateKey``value`
8. 后端 shared-contracts 与前端 `packages/shared/src/contracts/runtime.ts` 要同步补 request/response/type unionadmin-web 若有独立 `api/adminApiTypes.ts`,也要同步,避免共享包已更新但管理端本地类型缺失。
9. 退出登录时要清空新玩法私有状态,避免串用户。
10. 移动端入口卡片增多后要检查布局和滚动体验。
## 参考资料
- `references/genarrative-analytics-tracking-runtime.md`analytics/tracking runtime 粒度聚合、contracts 同步与 SpacetimeDB 生成物注意事项。
## 验证标准
一个玩法算真正接入成功,至少要满足:
- 入口能展示
- 能进入对应工作台
- 能创建 session
- 能生成草稿
- 能进入结果页
- 能返回编辑
- 如果需要,可试玩
- 如果需要,可发布
- 发布后能回到作品架 / 广场 / 分享链路

View File

@@ -0,0 +1,57 @@
# Genarrative analytics/tracking runtime 接入经验
本参考来自 analytics time dimension / tracking daily stat 粒度聚合实现与一次“只提交了 bindings/tests、后端链路未补齐”的纠偏。
## 推荐顺序
1. 先读仓库 `README.md``AGENTS.md`、相关 `/docs/technical``.hermes/plans`,确认当前阶段范围。
2. 遵循 TDD先在 `server-rs/crates/module-runtime/tests/` 写纯函数测试,验证缺失类型/函数导致 RED。
3.`module-runtime/src/domain.rs` 增加领域类型,例如:
- `AnalyticsGranularity``day | week | month | quarter | year`
- daily stat snapshot
- bucket metric
- query request/response/input
- Spacetime procedure result例如 `AnalyticsMetricQueryProcedureResult { ok, buckets, error_message }`),否则 module procedure 无法生成 client bindings。
4.`module-runtime/src/application.rs` 复用已有 `build_analytics_date_dimension_from_date_key`,实现 daily stat 到 day/week/month/quarter/year bucket 的聚合。
5. 输出 bucket 应稳定排序,可用 `BTreeMap`;聚合前按 `event_key + scope_kind + scope_id` 过滤。
6.`module-runtime/src/commands.rs` 增加 query input builder复用现有字段错误如 missing event key、missing scope id
7. 同步 contracts
- Rust`server-rs/crates/shared-contracts/src/runtime.rs`
- 前端共享:`packages/shared/src/contracts/runtime.ts`
- 管理端若有本地 API 类型,也同步 `apps/admin-web/src/api/adminApiTypes.ts`
8. 接 Spacetime module 源头:在 `server-rs/crates/spacetime-module/src/runtime/profile.rs` 增加只读 procedure例如 `query_analytics_metric`),从 `tracking_daily_stat` 读 rows映射成 `RuntimeAnalyticsDailyStatSnapshot` 后调用领域聚合函数。
9. 重新生成 `server-rs/crates/spacetime-client/src/module_bindings/`。如果当前环境没有 `spacetime`/`spacetimedb` CLI可临时按现有生成物风格手补 type/procedure/mod.rs但必须在文档和最终说明中标注“临时手补生成物”并要求后续在有 CLI 的机器用项目脚本重新生成覆盖。
10.`spacetime-client`
- `src/mapper.rs`module binding procedure result → `module_runtime` record/response补 tracking scope/granularity 映射。
- `src/runtime.rs`:新增 facade 方法(例如 `SpacetimeClient::query_analytics_metric`),调用生成的 procedure。
- `src/lib.rs`:若 facade/mapper 需要领域类型或 builder补导入注意同名 binding 类型会造成误解析。
11.`api-server`
- `src/runtime_profile.rs`Query params / parser / handler / response builder。
- `src/app.rs`:挂路由,例如 profile 或 admin analytics endpoint选择路径前确认产品定位。
12. 最后更新 docs/plan并确认 diff 不只是生成物。
## 验证命令示例
```bash
cd <repo-root>/server-rs
cargo test -p module-runtime --test analytics_granularity
cargo check -p shared-contracts
cargo check -p spacetime-module
cargo check -p spacetime-client
cargo check -p api-server
```
If terminal output is compacted by the tool, rerun the specific command directly (without `head`) or capture full output before concluding; `cargo check` exit code 0 with warnings is acceptable when warnings are pre-existing and documented.
## 坑
- 不要手改 `server-rs/crates/spacetime-client/src/module_bindings/` 生成物;若缺 procedure/type回源头改 Spacetime module 后重新生成。若当前环境没有 `spacetime`/`spacetimedb` CLI 且必须临时手补生成物,要在最终说明中明确这是临时替代,并尽快在有 CLI 的环境重新生成。
- `spacetime-client/src/runtime.rs` 同时能看到 `module_bindings::*` 和领域层 `module_runtime` 类型。新增 facade 方法参数要使用领域别名(如 `DomainRuntimeTrackingScopeKind``module_runtime::AnalyticsGranularity`),不要误用 binding 里的 `RuntimeTrackingScopeKind`;否则 `build_*_input` 会报 “expected module_runtime::..., found binding ...”。
- 如果缺少 SpacetimeDB CLI手补 bindings 的最小集合通常包括:`*_type.rs`input、enum、bucket、procedure result`*_procedure.rs``module_bindings/mod.rs``pub mod`/`pub use`/procedure re-export。完成后必须跑 `cargo check -p spacetime-client` 验证 SDK trait、procedure 名称与 result 字段是否匹配。
- `spacetime-client/src/mapper.rs` 新增 query 链路时通常要同时补四类映射:领域 input → binding input、binding enum 映射、binding procedure result → 领域 response、binding bucket/item → 领域 bucket/item。只在 `runtime.rs` 加 facade 会出现 unused import 或类型不匹配。
- 修改 Rust import 时注意 `serde::Deserialize``serde_json` 的排序/使用:如果只加了 `Query`/`Deserialize` 但 handler 尚未实现,`cargo check -p api-server` 会暴露 unused import不要把 import 作为完成信号。
- 只补 shared-contracts 不够;`packages/shared` 和 admin-web 本地类型可能各有一份。
- 周粒度应按 ISO week/date dimension而不是简单 `day_key / 7`
- bucket response 建议显式包含 start/end date key避免前端再推导时间边界。
- `spacetime-module` 可能能编译通过,但如果没有重新生成 bindings`spacetime-client` 仍不会有新 procedure 方法;必须以 client facade/API handler 可调用为完成标准。
- api-server 中新增 `Query``Deserialize` 等 import 后要立即补 handler否则容易留下 unused import。

View File

@@ -0,0 +1,99 @@
---
name: genarrative-profile-features
description: 在 Genarrative “我的”页签新增或修改个人中心入口、独立 profile 路由、反馈/记录/设置类页面时使用。
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Genarrative, profile, 我的页签, 前端, 路由, 反馈]
related_skills: [writing-plans, test-driven-development]
---
# Genarrative “我的”页签功能接入
用于在 Genarrative 平台“我的”页签新增或修改入口,以及把入口接到独立页面阶段/路由。例如:帮助与反馈、反馈记录、个人设置、账号相关轻量页面。
## 适用场景
- 在“我的”页签新增快捷入口或卡片按钮。
- 点击入口后进入独立页面,而不是在当前面板下方展开内容。
- 新增 `/profile/...` 路由或 `SelectionStage`
- 新增移动端优先的个人中心子页面组件。
- 修改 `RpgEntryHomeView``PlatformEntryFlowShellImpl``appPageRoutes` 等前端 profile 链路。
## 必读约束
1. 按项目约束:先检查/补齐文档,再落地工程修改。
2. UI 面板保持清爽,不要默认堆功能说明文案。
3. 点击按钮弹出/进入独立面板的设计,不要实现成在当前面板下方追加内容。
4. 移动端优先,同时兼顾网页端容器宽度。
5. 包含中文的文件优先局部补丁,修改后运行编码检查。
6. 非必要不新建系统;优先复用现有平台入口、阶段和路由机制。
## 代码接入路径
常见文件:
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
- “我的”页签 UI 主入口通常在此。
- 新增入口时优先扩展 props例如 `onOpenFeedback?: () => void`
- 在现有快捷入口区新增 `ProfileShortcutButton`保持图标、label、subLabel 风格一致。
- `src/components/platform-entry/platformEntryTypes.ts`
- 若需要独立页面阶段,扩展 `SelectionStage` union。
- 例如新增 `'profile-feedback'`
- `src/routing/appPageRoutes.ts`
-`STAGE_ROUTE_ENTRIES` 添加 `/profile/...` 路由映射。
- 验证 `resolveSelectionStageFromPath()``resolvePathForSelectionStage()` 双向一致。
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 引入新页面组件。
- 新增打开函数:必要时先检查登录态,未登录调用 `authUi?.openLoginModal()`
- 打开 profile 子页时同步 `setPlatformTab('profile')`,再 `setSelectionStage(...)`
-`selectionStage` 直接由路由进入 profile 子页时,用 `useEffect` 同步当前 tab 到 `profile`
- 在主渲染分支中为新阶段渲染独立 `<motion.div>` 页面;返回时回到 `platform` 阶段并保持 `profile` tab。
- `src/components/platform-entry/<FeatureView>.tsx`
- 页面组件可放在 platform-entry 下,与 shell 阶段渲染保持一致。
- 表单首版没有后端接口时,可通过可选 `onSubmit` prop 暴露提交 payload并在组件内展示成功/失败态;注释说明后续替换为 API 调用。
## 推荐实施顺序
1. 读取 `.hermes/plans/...` 或产品文档,确认入口、路由、页面行为。
2. 若现有文档不足,先在 `docs/prd/` 增加可编码落地的 PRD。
3. 增加 `SelectionStage``appPageRoutes` 映射,并先跑 `npm run typecheck`
4. 新建独立页面组件,尽量通过 props 暴露 `onBack`/`onSubmit`,避免直接耦合全局状态。
5.`RpgEntryHomeView.tsx` 增加入口 prop 与按钮。
6.`PlatformEntryFlowShellImpl.tsx` 串联导航、登录态、阶段渲染和返回逻辑。
7. 增加基础测试:路由解析、页面字段渲染、关键交互/校验、返回按钮。
8. 跑编码检查、类型检查和相关 vitest。
9. 分阶段 commit用户要求更新工作区时再 push。
## 测试与验证命令
常用命令:
```bash
npm run check:encoding
npm run typecheck
npx vitest run src/routing/appPageRoutes.test.ts src/components/platform-entry/<FeatureView>.test.tsx
# 或项目脚本:
npm run test -- --run src/routing/appPageRoutes.test.ts src/components/platform-entry/<FeatureView>.test.tsx
```
如果新增/修改中文文档或中文 UI`check:encoding` 必跑。
## 参考案例
- `references/profile-feedback-entry-2026-05-08.md`帮助与反馈入口案例覆盖文档、路由阶段、独立页面组件、“我的”页签按钮、shell 导航、测试和验证命令。
## 常见坑
1. 只在 `RpgEntryHomeView` 新增按钮但没有接 shell 导航,导致点击无效果。
2. 只新增 `SelectionStage` 但忘记 `appPageRoutes`,导致刷新/直达路由不能恢复页面。
3. 直达 `/profile/...` 时没有同步 `setPlatformTab('profile')`,底部 tab 状态与页面不一致。
4. 把反馈/设置表单插到“我的”面板下方,违背独立页面体验。
5. 没有测试 `resolveSelectionStageFromPath`/`resolvePathForSelectionStage`,后续路由改动容易回归。
6. 中文页面或文档改动后忘记编码检查。

View File

@@ -0,0 +1,80 @@
# 帮助与反馈入口案例2026-05-08
本案例来自 Genarrative “我的”页签新增反馈入口与独立反馈页实现。
## 目标
- 在“我的”页签新增“反馈”快捷入口。
- 点击后进入独立路由 `/profile/feedback`
- 页面按移动端参考图实现“帮助与反馈”表单:问题描述、上传凭证、联系电话、提交、查看反馈与投诉记录。
## 关键文件
- `docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`
- `src/components/platform-entry/platformEntryTypes.ts`
- `src/routing/appPageRoutes.ts`
- `src/components/platform-entry/PlatformFeedbackView.tsx`
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- `src/routing/appPageRoutes.test.ts`
- `src/components/platform-entry/PlatformFeedbackView.test.tsx`
## 落地顺序
1. 先补 PRD避免从参考图直接猜代码细节。
2.`SelectionStage` 中新增 `'profile-feedback'`
3.`STAGE_ROUTE_ENTRIES` 添加:
- `['profile-feedback', '/profile/feedback']`
4. 新建 `PlatformFeedbackView`
- `onBack: () => void`
- `onSubmit?: (payload) => void | Promise<void>`
- 内部维护描述、联系电话、上传图片预览、错误态、成功态。
- 首版没有后端接口时,`onSubmit` 为后续 API 接入点。
5.`RpgEntryHomeViewProps` 添加 `onOpenFeedback?: () => void`
6. 在“我的”页签快捷入口区新增 `ProfileShortcutButton`
- `label="反馈"`
- `subLabel="帮助与反馈"`
- `onClick={onOpenFeedback}`
7.`PlatformEntryFlowShellImpl`
- import `PlatformFeedbackView`
- 新增 `openProfileFeedback`
- 未登录则 `authUi?.openLoginModal()`
- 已登录则 `setPlatformTab('profile')` + `setSelectionStage('profile-feedback')`
- 直达阶段时 `useEffect` 同步 `profile` tab
- 渲染 `selectionStage === 'profile-feedback'` 的独立 `<motion.div>`
8. 增加测试:
- 路由双向解析
- 页面字段渲染
- 描述低于 10 字不提交
- 提交时 trim payload
- 顶部返回按钮调用 `onBack`
## UI 参考要点
- 移动端优先,页面最大宽度约 30rem。
- 浅灰背景,白色圆角卡片。
- 顶部标题:`帮助与反馈`
- 分区标题:`反馈问题`
- 问题描述 placeholder提示填写 10 字以上,勿填身份证号等隐私。
- 字数计数:`0/200`
- 上传凭证:最多四张,上传占位显示 `上传凭证``(最多四张)`
- 联系电话:选填。
- 主按钮:蓝色圆角 `提交`
- 底部链接:`查看反馈与投诉记录`
## 验证命令
```bash
npm run check:encoding
npm run typecheck
npx vitest run src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
# 或
npm run test -- --run src/routing/appPageRoutes.test.ts src/components/platform-entry/PlatformFeedbackView.test.tsx
```
## 已踩坑/经验
- `appPageRoutes.test.ts` 若已有历史内容,写入测试时注意不要误删旧用例;最终 commit 里出现 “insertions + deletions” 时要检查是否覆盖了既有测试。
- 页面组件里的图片预览需要在移除或卸载时 `URL.revokeObjectURL`,避免泄漏。
- 对隐藏 file input 的测试可以先不强行覆盖,首版覆盖表单字段、校验和 payload 更稳定。
- 如果用 `authUi` 对象作为 callback 依赖,需确认引用稳定性;现有 shell 中可接受,但复杂化时考虑拆出具体字段依赖。

View File

@@ -0,0 +1,149 @@
---
name: genarrative-profile-invite-flow
description: 在 Genarrative 中排查或修改邀请码、邀请好友、首次登录后填写邀请码、我的页签邀请码兑换链路时使用。
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [Genarrative, 邀请码, referral, auth, profile, query-params, 前端]
related_skills: []
---
# Genarrative 邀请码与邀请好友流程
用于排查或修改 Genarrative 的邀请码读取、填写、兑换、邀请中心与“我的”页签相关能力。
## 适用场景
- 判断 URL query 参数中的邀请码是否被读取。
- 修改邀请码填写入口、首次登录后引导或“我的”页签兑换入口。
- 排查邀请码预填、兑换、已填写状态、邀请好友复制链接。
- 修改邀请中心 API client 或前端 referral UI。
- 回答用户关于“邀请码在哪里填 / 从哪里配置 / query 是否支持”的问题。
## 先做代码核对,不要只凭旧记忆回答
邀请码流程近期发生过迁移:不要默认认为登录窗口可填写邀请码。回答前优先搜索并核对当前代码,尤其是:
```bash
cd <repo-root>
python3 - <<'PY'
from pathlib import Path
root=Path('src')
terms=['RegistrationInviteModal','readInviteCodeFromLocation','referralRedeemCode','redeemRpgProfileReferralInviteCode','邀请码','inviteCode']
for term in terms:
print('\n---', term)
for p in root.rglob('*'):
if p.is_file() and p.suffix in ['.ts', '.tsx']:
try:
txt=p.read_text('utf-8')
except Exception:
continue
if term in txt:
for i, line in enumerate(txt.splitlines(), 1):
if term in line:
print(f'{p}:{i}:{line.strip()[:180]}')
```
## 当前前端链路口径
### 1. AuthGate 中仍有旧 query 读取逻辑
文件:
- `src/components/auth/AuthGate.tsx`
重点函数 / 状态:
- `readInviteCodeFromLocation()`
- `pendingInviteCode`
- `showRegistrationInviteModal`
- `RegistrationInviteModal`
当前旧逻辑会读取:
- `?inviteCode=...`
- `?invite_code=...`
并把值清洗为大写字母数字形式。
### 2. 登录窗口本身不再填写邀请码
不要回答“登录窗口可填写邀请码”。当前登录弹窗 `LoginScreen` 只负责登录 / 注册账号;邀请码填写已迁移到登录后的流程。
### 3. 新版“我的”页签兑换入口在 RpgEntryHomeView
文件:
- `src/components/rpg-entry/RpgEntryHomeView.tsx`
重点常量 / 函数 / 状态:
- `PROFILE_INVITE_QUERY_KEYS`:新版 query 支持 `inviteCode` / `invite_code`
- `normalizeProfileInviteQueryCode()`:去掉非字母数字并转大写。
- `readProfileInviteCodeFromLocationSearch()`:从 `window.location.search` 读取并 normalize。
- `pendingProfileInviteCode`:组件初始化时读取 query 邀请码。
- `referralCenter`
- `referralRedeemCode`
- `setReferralRedeemCode`
- `openProfilePopupPanel('redeem')`
- `submitReferralRedeemCode()`
- `canShowReferralRedeemShortcut`
- `isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt)`
UI 中“填邀请码”面板会使用 `referralRedeemCode` 作为输入值,并通过 `submitReferralRedeemCode()` 提交。当前新版实现会在首次打开“填邀请码”面板时用 `pendingProfileInviteCode` 预填输入框;例如 `/?inviteCode=spring-2026` 会预填为 `SPRING2026`
### 4. 新版兑换 API client
文件:
- `src/services/rpg-entry/rpgProfileClient.ts`
函数:
- `getRpgProfileReferralInviteCenter()` -> `GET /profile/referrals/invite-center`
- `redeemRpgProfileReferralInviteCode(inviteCode)` -> `POST /profile/referrals/redeem-code`
## 判断 query 参数是否真正接入新版流程
回答这类问题时要区分两层:
1. “是否存在旧 query 读取代码”:看 `AuthGate.tsx``readInviteCodeFromLocation()`
2. “query 是否接到新版填写入口”:看 `RpgEntryHomeView.tsx` 是否存在 `pendingProfileInviteCode` / `readProfileInviteCodeFromLocationSearch()`,以及打开 `openProfilePopupPanel('redeem')` 时是否把该值写回 `referralRedeemCode`
当前新版流程已经支持 `inviteCode` / `invite_code` query 预填“我的”页签的“填邀请码”弹窗;登录窗口仍不填写邀请码。
如果未来代码只看到 AuthGate 读 query但没有看到 `RpgEntryHomeView``referralRedeemCode` 从 query 初始化,就应回答:
> 代码里仍支持读取 `inviteCode` / `invite_code`,但新版“第一次登录后 / 我的页签”的填写入口未必已经完整接入该 query 值;需要继续把 query 值传入新版 profile referral redeem 流程。
## 修改建议顺序
如果要把 query 邀请码完整接入新版流程,建议按这个顺序做:
1. 先确定 query 参数规范:继续支持 `inviteCode` / `invite_code`,并统一 normalize。
2.`RpgEntryHomeView.tsx` 内用 `readProfileInviteCodeFromLocationSearch(window.location.search)` 初始化 `pendingProfileInviteCode`
3.`pendingProfileInviteCode` 初始化 `referralRedeemCode`,并在 `openProfilePopupPanel('redeem')` 时重新写回,避免关闭后再次打开被清空。
4. 如产品要求自动弹出:
-`pendingProfileInviteCode` 且未登录时,自动调用 `authUi?.openLoginModal()` 打开登录窗口;登录窗口仍不承接邀请码输入。
-`pendingProfileInviteCode` 且已登录时,自动将 `referralRedeemCode` 设为该 query 邀请码,并 `setProfilePopupPanel('redeem')` 直接打开“填邀请码”面板。
-`useRef` 记录是否已处理过当前 query避免组件重渲染或 `authUi` 对象变化导致重复弹窗。
- 当前项目实现已从“只预填、不自动弹”调整为上述行为。
5. 兑换成功后清理输入态;是否清理 URL query 需由产品决定,避免破坏分享链接归因。
6. 补测试覆盖:未登录访问带 query、已登录访问带 query 自动打开填写面板、我的页签手动打开、已填写邀请码、过期窗口、空/非法 query。当前已有 `RpgEntryHomeView.recharge.test.tsx` 覆盖:
- `invite query opens login modal for logged out users`
- `invite query opens redeem modal directly for logged in users`
- `profile redeem invite modal reads query invite code after login`
## 常见坑
1. 不要把旧 `RegistrationInviteModal` 误认为当前唯一入口。
2. 不要说“登录窗口可以填写邀请码”,除非当前代码重新把邀请码输入放回 `LoginScreen`
3. `AuthGate` 读到 query 不等于新版 `RpgEntryHomeView` 已经预填。
4. “第一次登录后”与“我的页签”可能是两个入口;修改时要同时检查自动引导和手动入口。
5. `canShowReferralRedeemShortcut` 受登录态、创建时间窗口、邀请中心初始化、已兑换状态共同影响。
6. 邀请码 URL 通常由 `inviteLinkPath` 生成,复制逻辑在 `copyInviteInfo()`,不要只改兑换入口而忘记分享链接格式。
## 参考资料
- `references/query-invite-code-flow-2026-05-07.md`:本次会话确认的邀请码 query 与新版 profile referral 入口关系。
## 验证标准
- 能明确回答当前 query 参数读取位置与参数名。
- 能区分旧 AuthGate 邀请弹窗与新版“我的”页签 referral redeem。
- 若实现改动,测试覆盖带 query 的登录后预填/弹窗行为,以及已填写邀请码时不再提示。

View File

@@ -0,0 +1,101 @@
# Query 邀请码与新版 profile referral 入口关系2026-05-07
## 会话结论
用户指出:邀请码填写流程已经修改,登录窗口目前填写不了邀请码;邀请码填写被挪到了第一次登录后以及“我的”页签中。
因此后续回答或修改时不能只根据 `AuthGate` 里的旧逻辑判断“已支持”。
## 当前代码观察
### 旧 AuthGate 逻辑
文件:`src/components/auth/AuthGate.tsx`
- `readInviteCodeFromLocation()` 读取 `window.location.search`
- 支持 `inviteCode` / `invite_code`
- 会 normalize 为大写字母数字。
- 写入 `pendingInviteCode`,传给 `RegistrationInviteModal`
这只能说明“旧层仍有 query 读取”。
### 新版 profile referral 入口
文件:`src/components/rpg-entry/RpgEntryHomeView.tsx`
- “我的”页签标签为 `profile`
- 兑换输入状态:`referralRedeemCode`
- 打开填邀请码面板:`openProfilePopupPanel('redeem')`
- 提交兑换:`submitReferralRedeemCode()`
- 可显示快捷入口受 `canShowReferralRedeemShortcut` 控制。
文件:`src/services/rpg-entry/rpgProfileClient.ts`
- `getRpgProfileReferralInviteCenter()` -> `GET /profile/referrals/invite-center`
- `redeemRpgProfileReferralInviteCode(inviteCode)` -> `POST /profile/referrals/redeem-code`
## 回答口径
如果被问“是否支持 query 参数读取邀请码”,应回答:
- 代码里仍有 query 读取,支持 `inviteCode` / `invite_code`
- 但登录窗口不再填写邀请码。
- 新版入口在第一次登录后 / 我的页签;需要检查 `referralRedeemCode` 是否从 query 初始化。
- 若没有该连接,就不能说新版流程完整支持 query 预填。
## 本次实现后的状态
已将 query 邀请码读取接入新版 `RpgEntryHomeView`
- `PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code']`
- `normalizeProfileInviteQueryCode()`:去除非字母数字并转大写。
- `readProfileInviteCodeFromLocationSearch(window.location.search)`:读取 query 邀请码。
- `pendingProfileInviteCode`:组件初始化时读取 query。
- `referralRedeemCode`:用 `pendingProfileInviteCode` 初始化。
- `openProfilePopupPanel('redeem')`:打开“填邀请码”时重新写入 `pendingProfileInviteCode`,避免首次打开或重新打开时丢失 query 预填。
当前行为:只预填,不自动弹出“填邀请码”面板;用户仍需在“我的”页签点击“填邀请码”。
验证测试:
```bash
cd <repo-root>
npm test -- --run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx --max-warnings=0
node scripts/check-encoding.mjs
```
测试用例:`profile redeem invite modal reads query invite code after login` 覆盖 `/?inviteCode=spring-2026` 预填为 `SPRING2026`
## 后续调整:带邀请码链接自动开窗
用户进一步明确期望:
- 如果用户未登录,直接打开登录窗口。
- 如果用户已登录,直接打开邀请码填写窗口。
实现要点:
-`RpgEntryHomeView.tsx` 中保留 `pendingProfileInviteCode` 作为 query 邀请码来源。
- 新增 `autoOpenedInviteQueryRef = useRef(false)`,防止 effect 重复触发弹窗。
- 新增 `useEffect`
- 无 query 邀请码或已处理过则 return。
- 未登录:调用 `authUi?.openLoginModal()`
- 已登录:设置 `referralRedeemCode`、清空 referral 错误/成功提示、`setProfilePopupPanel('redeem')`
- 登录窗口仍不接收邀请码;邀请码只在登录后的 profile referral redeem 面板显示。
- 仍然只自动打开和预填,不自动提交兑换。
补充测试:
- `invite query opens login modal for logged out users`
- `invite query opens redeem modal directly for logged in users`
-`profile redeem invite modal reads query invite code after login` 同步调整为直接断言自动打开后的输入值。
验证命令仍为:
```bash
cd <repo-root>
npm test -- --run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx --max-warnings=0
node scripts/check-encoding.mjs
```

1
.worktreeinclude Normal file
View File

@@ -0,0 +1 @@
.env.secrets.local

View File

@@ -3,6 +3,9 @@ import type {
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest,
AdminDatabaseTableListResponse,
AdminDatabaseTableRowsQuery,
AdminDatabaseTableRowsResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -129,6 +132,23 @@ export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', {token});
}
export function getAdminDatabaseTables(token: string) {
return request<AdminDatabaseTableListResponse>('/admin/api/database/tables', {
token,
});
}
export function getAdminDatabaseTableRows(
token: string,
tableName: string,
query: AdminDatabaseTableRowsQuery = {},
) {
return request<AdminDatabaseTableRowsResponse>(
`/admin/api/database/tables/${encodeURIComponent(tableName)}/rows${buildDatabaseTableRowsQuery(query)}`,
{token},
);
}
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
method: 'POST',
@@ -257,6 +277,17 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
return queryString ? `?${queryString}` : '';
}
function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'search', query.search);
appendQueryParam(params, 'filters', query.filters);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function appendQueryParam(
params: URLSearchParams,
key: string,

View File

@@ -72,6 +72,30 @@ export interface AdminDatabaseOverviewPayload {
fetchErrors: string[];
}
export interface AdminDatabaseTableListResponse {
tables: string[];
fetchErrors: string[];
}
export interface AdminDatabaseTableRowsQuery {
limit?: number;
search?: string;
filters?: string;
}
export interface AdminDatabaseTableRowPayload {
cells: Record<string, unknown>;
raw: unknown;
}
export interface AdminDatabaseTableRowsResponse {
tableName: string;
columns: string[];
rows: AdminDatabaseTableRowPayload[];
totalReturned: number;
limit: number;
}
export interface AdminDatabaseTableStatPayload {
tableName: string;
rowCount: number | null;

View File

@@ -18,6 +18,7 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
@@ -160,6 +161,12 @@ export function AdminApp() {
{routeId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tables' ? (
<AdminDatabaseTablesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}

View File

@@ -4,6 +4,7 @@ import {
LogOut,
ShieldCheck,
ListChecks,
Database,
Table2,
TicketCheck,
TicketPercent,
@@ -24,6 +25,7 @@ interface AdminShellProps {
const routeIcons = {
overview: LayoutDashboard,
tables: Database,
debug: Bug,
tracking: Table2,
redeem: TicketPercent,

View File

@@ -1,4 +1,11 @@
export type AdminRouteId = 'overview' | 'debug' | 'tracking' | 'redeem' | 'invite' | 'tasks';
export type AdminRouteId =
| 'overview'
| 'tables'
| 'debug'
| 'tracking'
| 'redeem'
| 'invite'
| 'tasks';
export interface AdminRouteDefinition {
id: AdminRouteId;
@@ -8,6 +15,7 @@ export interface AdminRouteDefinition {
export const adminRoutes: AdminRouteDefinition[] = [
{id: 'overview', label: '总览', hash: '#overview'},
{id: 'tables', label: '表查询', hash: '#tables'},
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
@@ -16,7 +24,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
];
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase();
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
'overview'

View File

@@ -0,0 +1,371 @@
import {Eye, RefreshCcw, Search, X} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type {
AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminDatabaseTablesPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
export function AdminDatabaseTablesPage({
token,
onUnauthorized,
}: AdminDatabaseTablesPageProps) {
const [tables, setTables] = useState<string[]>([]);
const [tableName, setTableName] = useState(() => readHashTableName());
const [search, setSearch] = useState('');
const [filters, setFilters] = useState('');
const [limit, setLimit] = useState('100');
const [result, setResult] = useState<AdminDatabaseTableRowsResponse | null>(null);
const [detailRow, setDetailRow] = useState<AdminDatabaseTableRowPayload | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [copyMessage, setCopyMessage] = useState('');
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingRows, setIsLoadingRows] = useState(false);
useEffect(() => {
void loadTables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
useEffect(() => {
const nextTableName = readHashTableName();
if (nextTableName) {
setTableName(nextTableName);
}
const handleHashChange = () => {
const tableFromHash = readHashTableName();
if (tableFromHash) {
setTableName(tableFromHash);
void refreshRows(tableFromHash);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (tables.length && !tableName) {
setTableName(tables[0] ?? '');
}
}, [tableName, tables]);
useEffect(() => {
if (tableName) {
void refreshRows(tableName);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName]);
const visibleColumns = useMemo(() => {
const columns = result?.columns ?? [];
if (columns.length) {
return columns;
}
const firstRow = result?.rows[0];
return firstRow ? Object.keys(firstRow.cells) : [];
}, [result]);
async function loadTables() {
setIsLoadingTables(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTables(token);
setTables(response.tables);
if (response.fetchErrors.length) {
setErrorMessage(response.fetchErrors.join(''));
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingTables(false);
}
}
async function refreshRows(
nextTableName = tableName,
options: {
search?: string;
filters?: string;
limit?: string;
} = {},
) {
const normalizedTableName = nextTableName.trim();
if (!normalizedTableName) {
return;
}
const querySearch = options.search ?? search;
const queryFilters = options.filters ?? filters;
const queryLimit = options.limit ?? limit;
setIsLoadingRows(true);
setErrorMessage('');
try {
const response = await getAdminDatabaseTableRows(token, normalizedTableName, {
search: querySearch,
filters: queryFilters,
limit: parseLimit(queryLimit),
});
setResult(response);
setCopyMessage('');
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoadingRows(false);
}
}
function handleSearch(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void refreshRows();
}
function handleTableChange(nextTableName: string) {
setTableName(nextTableName);
const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`;
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
}
function handleResetQuery() {
setSearch('');
setFilters('');
setLimit('100');
void refreshRows(tableName, {search: '', filters: '', limit: '100'});
}
async function handleCopyDetailJson() {
if (!detailRow) {
return;
}
const copiedText = JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2);
try {
await navigator.clipboard.writeText(copiedText);
setCopyMessage('已复制 JSON');
} catch {
setCopyMessage('复制失败,请手动选中后复制');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p>SpacetimeDB </p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
disabled={isLoadingTables}
type="button"
onClick={() => void loadTables()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoadingTables ? '刷新中' : '刷新表'}</span>
</button>
<button
className="admin-primary-button"
disabled={!tableName || isLoadingRows}
type="button"
onClick={() => void refreshRows()}
>
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
</div>
</div>
<form className="admin-panel admin-form" onSubmit={handleSearch}>
<div className="admin-table-query-grid">
<label className="admin-field">
<span></span>
<select
value={tableName}
onChange={(event) => handleTableChange(event.target.value)}
>
{tableName && !tables.includes(tableName) ? (
<option value={tableName}>{tableName}</option>
) : null}
{tables.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<input
placeholder="全部"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
<label className="admin-field">
<span> JSON</span>
<input
placeholder='{"user_id":"u1"}'
value={filters}
onChange={(event) => setFilters(event.target.value)}
/>
</label>
<label className="admin-field admin-field-compact">
<span></span>
<input
inputMode="numeric"
value={limit}
onChange={(event) => setLimit(event.target.value)}
/>
</label>
<button className="admin-secondary-button" disabled={isLoadingRows} type="submit">
<Search size={17} aria-hidden="true" />
<span>{isLoadingRows ? '查询中' : '查询'}</span>
</button>
</div>
<div className="admin-action-row admin-query-action-row">
<button
className="admin-ghost-button admin-query-reset-button"
disabled={isLoadingRows}
type="button"
onClick={handleResetQuery}
>
</button>
<div className="admin-query-summary">
<span>{tableName || '-'}</span>
<span>{visibleColumns.length}</span>
</div>
</div>
</form>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>{result?.tableName || tableName || '数据行'}</h3>
<span>{result?.totalReturned ?? 0} </span>
</div>
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
{visibleColumns.map((column) => (
<th key={column}>{column}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{result?.rows.length ? (
result.rows.map((row, rowIndex) => (
<tr
key={buildRowKey(row, rowIndex)}
data-clickable="true"
onClick={() => setDetailRow(row)}
>
{visibleColumns.map((column) => (
<td key={column}>{formatCellValue(row.cells[column])}</td>
))}
<td>
<button
className="admin-secondary-button"
type="button"
onClick={(event) => {
event.stopPropagation();
setDetailRow(row);
}}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={Math.max(visibleColumns.length + 1, 1)}></td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{detailRow ? (
<div className="admin-confirm-backdrop" role="presentation">
<section className="admin-detail-panel" role="dialog" aria-modal="true">
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-detail-actions">
<button
className="admin-secondary-button"
type="button"
onClick={() => void handleCopyDetailJson()}
>
<span> JSON</span>
</button>
<button
className="admin-ghost-button"
title="关闭"
type="button"
onClick={() => setDetailRow(null)}
>
<X size={17} aria-hidden="true" />
</button>
</div>
</div>
{copyMessage ? <div className="admin-status admin-status-ok">{copyMessage}</div> : null}
<pre className="admin-code-block">
{JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2)}
</pre>
</section>
</div>
) : null}
</section>
);
}
function readHashTableName() {
const hash = window.location.hash;
const queryIndex = hash.indexOf('?');
if (queryIndex < 0) {
return '';
}
return new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? '';
}
function parseLimit(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) ? parsed : 100;
}
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
const firstValue = Object.values(row.cells)[0];
return `${rowIndex}-${String(firstValue ?? '')}`;
}
function formatCellValue(value: unknown) {
if (value === null || typeof value === 'undefined' || value === '') {
return '-';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return <pre className="admin-json-preview">{JSON.stringify(value, null, 2)}</pre>;
}

View File

@@ -155,7 +155,17 @@ function InfoPanel({
function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) {
return (
<tr>
<td>{stat.tableName}</td>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => {
window.location.hash = `#tables?table=${encodeURIComponent(stat.tableName)}`;
}}
>
{stat.tableName}
</button>
</td>
<td>{typeof stat.rowCount === 'number' ? stat.rowCount : '-'}</td>
<td>
{stat.errorMessage ? (

View File

@@ -302,13 +302,59 @@ button:disabled {
align-items: end;
}
.admin-table-query-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(160px, 1fr) minmax(220px, 1.2fr) minmax(96px, 0.45fr) auto;
gap: 12px;
align-items: end;
}
.admin-table tbody tr[data-clickable="true"] {
cursor: pointer;
}
.admin-table tbody tr[data-clickable="true"]:hover {
background: #f5fafb;
}
.admin-text-button:hover,
.admin-text-button:focus-visible {
color: #126e82;
text-decoration: underline;
outline: none;
}
.admin-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.admin-query-action-row {
justify-content: space-between;
}
.admin-query-summary,
.admin-detail-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-query-summary {
color: #667682;
font-size: 12px;
font-weight: 650;
}
.admin-query-reset-button {
width: auto;
padding: 0 12px;
}
.admin-field {
display: grid;
min-width: 0;
@@ -811,7 +857,8 @@ button:disabled {
.admin-two-column,
.admin-two-column-wide,
.admin-form-row,
.admin-filter-grid {
.admin-filter-grid,
.admin-table-query-grid {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,172 @@
# “我的”页签帮助与反馈入口 PRD
更新时间:`2026-05-08`
## 0. 目标
在平台“我的”页签新增“反馈”入口。用户点击后进入独立路由 `/profile/feedback`,看到移动端优先的“帮助与反馈”表单页面,用于提交问题描述、上传问题截图凭证并选填联系电话。
本次目标是补齐用户反馈入口和前端提交流程,不重新发明新的个人中心系统,不在“我的”页签当前面板下方展开表单。
## 1. 参考图
计划参考图保存在:
`../.hermes/plans/assets/profile-feedback-reference-2026-05-08.png`
页面结构以该参考图为准:
1. 顶部白色导航栏,标题为“帮助与反馈”。
2. 内容背景为浅灰色。
3. 分区标题为“反馈问题”。
4. 第一张白色圆角卡片为“问题描述”。
5. 第二张白色圆角卡片为“上传凭证(提供问题截图)”。
6. 第三张白色圆角卡片为“联系电话”。
7. 底部为蓝色主按钮“提交”。
8. 提交按钮下方为蓝色文本入口“查看反馈与投诉记录”。
## 2. 首版范围
### 2.1 包含
- “我的”页签常用功能区新增“反馈”入口。
- 点击入口进入 `/profile/feedback` 独立路由。
- 反馈页标题显示“帮助与反馈”。
- 问题描述输入:
- 最少 10 个字。
- 最多 200 个字。
- 实时显示 `当前字数/200`
- placeholder`请填写10个字以上的问题描述以便我们提供更好的帮助温馨提醒您请勿填写身份证号等个人隐私信息。`
- 上传凭证:
- 展示虚线上传方块。
- 支持选择图片时,最多 4 张。
- 前端可预览已选图片。
- 不接后端时,提交只进入前端成功态。
- 联系电话:
- 选填。
- placeholder`选填,如您填写则将会同步开发者与您联系`
- 提交后显示成功态。
- 返回后回到平台首页并定位“我的”页签。
### 2.2 不包含
- 不新增后端反馈存储接口。
- 不新增 SpacetimeDB 表结构和 migration。
- 不新增后台反馈记录管理页。
- 不实现真实“反馈与投诉记录”列表。
如果后续要求真实存储反馈,需要另起后端 PRD/技术方案,覆盖 `shared-contracts``api-server`、SpacetimeDB 表与后台管理入口。
## 3. 入口设计
### 3.1 入口位置
入口放在“我的”页签常用功能区,和“每日任务 / 邀请好友 / 填邀请码 / 玩家社区”同级。
入口展示:
- 主标题:`反馈`
- 副标题:`问题与建议`
- 图标:可复用 `MessageCircle` 或类似消息图标。
### 3.2 未登录状态
首版默认未登录用户点击入口时触发登录弹窗,不进入反馈页。
原因:当前“我的”页签的数据与账号绑定,反馈如果未来接入后端,也应能关联提交账号。
## 4. 反馈页 UI
### 4.1 页面整体
- 移动端优先。
- 背景为浅灰色或与现有平台浅色 surface 接近的背景。
- 表单容器使用白色圆角卡片。
- 桌面端居中展示,最大宽度不超过移动表单阅读范围,避免横向拉满。
### 4.2 顶部栏
- 标题:`帮助与反馈`
- 左侧返回/首页图标:点击返回平台首页“我的”页签。
- 右侧胶囊控制区可不完全复刻;项目内若没有同类控件,保持简洁,不强行新增无实际功能按钮。
### 4.3 问题描述卡片
- 标题:`问题描述`
- 输入框类型textarea。
- 最小高度接近参考图的大文本区域。
- 右下角字数:`0/200`
- 校验失败提示靠近卡片或提交按钮上方展示,不弹浏览器 alert。
### 4.4 上传凭证卡片
- 标题:`上传凭证(提供问题截图)`
- 上传入口为虚线边框方块。
- 文案:
- `上传凭证`
- `(最多四张)`
- 支持图片选择时,只允许 `image/*`
- 超过 4 张时提示:`最多上传四张凭证`
### 4.5 联系电话卡片
- 标题:`联系电话`
- 输入框类型text 或 tel。
- 联系电话选填,不阻塞提交。
- 最长 40 字符。
### 4.6 底部操作
- 主按钮:`提交`
- 按钮为蓝色圆角,宽度接近容器宽度。
- 二级链接:`查看反馈与投诉记录`
- 首版无记录页时,该链接可以:
- 隐藏;或
- 保留并点击后显示轻量提示“反馈记录暂未开放”。
## 5. 路由与状态
- 新增页面阶段:`profile-feedback`
- 新增路由:`/profile/feedback`
- 浏览器直接访问 `/profile/feedback` 时应显示反馈页。
- 点击页面返回时:
- 设置平台 tab 为 `profile`
- 回到 `platform` 阶段。
## 6. 表单校验
提交时按以下顺序校验:
1. 问题描述去除首尾空白后少于 10 个字:提示 `请填写10个字以上的问题描述`
2. 问题描述超过 200 个字:提示 `问题描述不能超过 200 字`
3. 联系电话超过 40 字符:提示 `联系电话不能超过 40 字`
4. 上传凭证超过 4 张:提示 `最多上传四张凭证`
校验通过后进入前端成功态。
## 7. 验收标准
- “我的”页签可看到“反馈”入口。
- 已登录用户点击后进入 `/profile/feedback`
- 未登录用户点击后弹登录。
- `/profile/feedback` 页面显示“帮助与反馈”。
- 问题描述字数统计实时变化。
- 空内容或少于 10 个字提交时显示校验错误。
- 有效内容提交后显示成功态。
- 上传凭证最多 4 张。
- 联系电话为空时可以提交。
- 返回后回到“我的”页签。
- 页面在 390×844 移动端视口不横向溢出。
- `npm run check:encoding``npm run typecheck`、定向测试通过。
## 8. 文件落点
- PRD`docs/prd/PROFILE_FEEDBACK_ENTRY_PRD_2026-05-08.md`
- 路由:`src/routing/appPageRoutes.ts`
- 阶段类型:`src/components/platform-entry/platformEntryTypes.ts`
- 反馈页:`src/components/platform-entry/PlatformFeedbackView.tsx`
- 我的页签入口:`src/components/rpg-entry/RpgEntryHomeView.tsx`
- 页面接入:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
- 测试:
- `src/routing/appPageRoutes.test.ts`
- `src/components/platform-entry/PlatformFeedbackView.test.tsx`

View File

@@ -0,0 +1,94 @@
# 后台数据库表查询技术方案2026-05-08
## 背景
后台“总览”页已经通过 `/admin/api/overview` 展示 SpacetimeDB 表统计,但只能看到表名、行数和统计状态。运营和排障时需要从统计行直接进入单表查询页,按基础条件快速查看真实行数据。
## 目标
- 在后台新增“表查询”页,支持所有 schema 表的只读查询。
- “总览 / 表统计”中的每一行可点击跳转到对应表的查询页。
- 提供基础查询能力表选择、关键词搜索、JSON 条件过滤、条数限制、刷新、查看行详情。
- 不修改 SpacetimeDB 表结构,不新增 reducer不引入写操作。
## 后续增强
- 查询页增加“重置条件”快捷操作,便于运营快速回到默认筛选状态。
- 行详情支持一键复制完整 JSON减少人工选中复制的操作成本。
- 查询页顶部增加轻量摘要,显示当前选表和可见列数,方便移动端快速确认上下文。
## 后端接口
### `GET /admin/api/database/tables`
鉴权:沿用 `require_admin_auth`
数据来源SpacetimeDB schema HTTP API。
响应:
```json
{
"tables": ["tracking_event", "user_account"],
"fetchErrors": []
}
```
### `GET /admin/api/database/tables/{tableName}/rows`
鉴权:沿用 `require_admin_auth`
Query
- `limit`:默认 100范围 1-500。
- `search`:可选,前端关键词;后端返回行后在 JSON 文本中大小写不敏感过滤。
- `filters`:可选 JSON object 字符串,例如 `{"user_id":"u1","enabled":true}`;后端返回行后按字段等值过滤。
响应:
```json
{
"tableName": "tracking_event",
"columns": ["event_id", "event_key"],
"rows": [
{
"cells": {
"event_id": "event-1",
"event_key": "daily_login"
},
"raw": ["event-1", "daily_login"]
}
],
"totalReturned": 1,
"limit": 100
}
```
实现约束:
- 表名必须来自 schema 且通过标识符安全校验,避免任意 SQL 注入。
- SQL 固定为 `SELECT * FROM {tableName} LIMIT {limit}`SpacetimeDB 2.2 HTTP SQL 不拼 `ORDER BY`
- 用户输入不直接拼入 SQL关键词和条件在 API Server 内存中过滤。
- private 表或 token 不可见时返回后台可读错误信息。
- SpacetimeDB SQL 行和 SATS 值统一转成人可读 JSONOption None 为 nullSome 展开为内部值Timestamp 单元素数组展开为内部值enum 可保留 tag/name 或原始数组文本。
## 前端页面
路由:`#tables`,导航名“表查询”。
页面能力:
- 表选择下拉,支持 URL hash `#tables?table=xxx` 直达指定表。
- 查询表单表名、关键词、JSON 条件、条数。
- 查询结果表格横向滚动,移动端不撑坏布局。
- 每行提供“详情”按钮,以独立弹层展示完整 JSON。
- 总览表统计行点击后跳转到 `#tables?table={tableName}`
## 验收
- `cd server-rs && cargo fmt -p api-server -p shared-contracts --check`
- `cd server-rs && cargo test -p api-server admin_database -- --nocapture`
- `npm run admin-web:typecheck`
- `npm run admin-web:build`
- `npm run check:encoding`
- `git diff --check`

View File

@@ -0,0 +1,73 @@
# 登录成功每日登录埋点闭环方案2026-05-08
## 背景
后台“埋点数据”需要能看到真实登录触发的 `daily_login` 埋点。此前方案 A 已把“读取任务中心时顺手写每日登录埋点”拆成独立 SpacetimeDB procedure
- `record_daily_login_tracking_event_and_return`
- `spacetime-client` 方法:`record_daily_login_tracking_event(user_id)`
但认证成功链路还没有调用该方法,因此当前只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。
## 现象
用户已经登录、cookie 未过期时,直接打开网页并不会触发每日登录埋点。原因是前端恢复登录态只读取 `/api/auth/me`,这条链路不会主动走 refresh cookie 续期,因此后端新的埋点写入点不会被触发。
## 修复思路
`AuthGate` 恢复已登录会话时,先主动调用一次 refresh 接口轮换 refresh cookie再调用 `/api/auth/me` 读取当前会话。这样无论本地 access token 是否仍然有效,打开页面都会进入 refresh 续期链路,从而触发后端的 `daily_login` 埋点写入。
## 目标
在用户认证成功并创建 refresh session / access token 后,异步尝试写入每日登录埋点。
覆盖入口:
- 手机验证码登录:`POST /api/auth/phone/login`
- 密码入口登录:`POST /api/auth/entry`
- 重置密码后自动登录:`POST /api/auth/password/reset`
- 微信 OAuth callback 登录:`GET /api/auth/wechat/callback`
- 微信绑定手机号后激活/登录态刷新:`POST /api/auth/wechat/bind-phone`
- refresh cookie 续期:`POST /api/auth/session/refresh`
## 设计约束
1. 埋点写入不能阻断登录成功响应。
2. 只有认证成功并已创建会话后,或 refresh session rotate 成功并签发新 access token 后才记录。
3. 失败只记 warning继续返回 token / cookie。
4. 写入统一收口,避免多个登录 handler 各自拼 procedure 调用。
5. 不修改 SpacetimeDB 表结构,不需要更新 `migration.rs`
## 实现方案
新增 `api-server` 内部 helper
```rust
record_daily_login_tracking_event_after_auth_success(
state: &AppState,
request_context: &RequestContext,
user_id: &str,
login_method: AuthLoginMethod,
).await;
```
该 helper
- 调用 `state.spacetime_client().record_daily_login_tracking_event(user_id.to_string()).await`
- 成功时记录 info
- 失败时记录 warn并明确“登录流程继续”
在各登录入口 `create_auth_session` 成功后调用该 helper。refresh cookie 续期在 `rotate_session``sign_access_token_for_user` 成功后调用同一个 helper`login_method` 使用 refresh session 上保存的 `issued_by_provider`,避免把续期统一误标成 password。
## 验收
- `cargo test -p api-server auth_session -- --nocapture`
- `cargo check -p api-server`
- `cargo check -p spacetime-client`
- `npm run check:encoding`
- `git diff --check`
- `npm run test -- AuthGate.test.tsx`
## 注意
`npm run dev` 是长期运行进程;如需本地 smoke应启动后用 `/healthz` 和后台页面验证,不要等待该命令退出。

View File

@@ -7,6 +7,7 @@ usage() {
用法:
npm run dev:rust
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
./scripts/dev-rust-stack.sh --spacetime-data-dir server-rs/.spacetimedb/local/data
./scripts/dev-rust-stack.sh --admin-web-port 3102
./scripts/dev-rust-stack.sh --api-timeout-seconds 600
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
@@ -18,7 +19,7 @@ usage() {
1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local/data 作为本地数据目录;端口被占用时自动接受 SpacetimeDB 建议的最近可用端口
5. 默认在发布模块前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。
EOF
}
@@ -126,18 +127,18 @@ cleanup() {
wait_for_spacetime() {
local server="$1"
local timeout_seconds="$2"
local root_dir="$3"
local data_dir="$3"
local process_pid="${4:-}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2
print_spacetime_start_failure_diagnostics "${root_dir}"
print_spacetime_start_failure_diagnostics "${data_dir}"
exit 1
fi
if is_spacetime_ready "${server}" "${root_dir}"; then
if is_spacetime_ready "${server}"; then
return
fi
@@ -145,16 +146,56 @@ wait_for_spacetime() {
done
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
print_spacetime_start_failure_diagnostics "${root_dir}"
print_spacetime_start_failure_diagnostics "${data_dir}"
exit 1
}
wait_for_spacetime_listen_addr() {
local log_file="$1"
local timeout_seconds="$2"
local process_pid="${3:-}"
local deadline=$((SECONDS + timeout_seconds))
local listen_addr=""
while ((SECONDS < deadline)); do
if [[ -f "${log_file}" ]]; then
listen_addr="$(sed -n 's/^.*Starting SpacetimeDB listening on \([^[:space:]]\+\).*$/\1/p' "${log_file}" | tail -n 1)"
if [[ -n "${listen_addr}" ]]; then
echo "${listen_addr}"
return
fi
fi
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] SpacetimeDB 进程在输出监听地址前退出。" >&2
if [[ -f "${log_file}" ]]; then
echo "[dev:rust] 最近 SpacetimeDB 启动日志: ${log_file}" >&2
tail -n 80 "${log_file}" >&2 || true
fi
exit 1
fi
sleep 0.2
done
echo "[dev:rust] 等待 SpacetimeDB 输出监听地址超时。" >&2
if [[ -f "${log_file}" ]]; then
echo "[dev:rust] 最近 SpacetimeDB 启动日志: ${log_file}" >&2
tail -n 80 "${log_file}" >&2 || true
fi
exit 1
}
port_from_listen_addr() {
local listen_addr="$1"
echo "${listen_addr##*:}"
}
is_spacetime_ready() {
local server="$1"
local root_dir="$2"
local output
if output="$(spacetime --root-dir="${root_dir}" server ping "${server}" 2>&1)" &&
if output="$(spacetime server ping "${server}" 2>&1)" &&
[[ "${output}" == *"Server is online:"* ]]; then
return 0
fi
@@ -174,10 +215,10 @@ request.on("error", () => process.exit(1));
}
print_spacetime_start_failure_diagnostics() {
local root_dir="$1"
local log_file="${root_dir}/data/logs/spacetime-standalone.log"
local data_dir="$1"
local log_file="${data_dir}/logs/spacetime-standalone.log"
echo "[dev:rust] SpacetimeDB root-dir: ${root_dir}" >&2
echo "[dev:rust] SpacetimeDB data-dir: ${data_dir}" >&2
if [[ ! -f "${log_file}" ]]; then
echo "[dev:rust] 未找到 SpacetimeDB standalone 日志: ${log_file}" >&2
@@ -189,26 +230,28 @@ print_spacetime_start_failure_diagnostics() {
if grep -q "mismatched database identity" "${log_file}" 2>/dev/null; then
echo "[dev:rust] 检测到本地 replica 与当前数据库 identity 不一致。" >&2
echo "[dev:rust] 常见原因是同一个 root-dir 保留了旧库 data/replicas/1但 control-db 已指向新库。" >&2
echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB再备份或移走 ${root_dir}/data 后重新启动。" >&2
echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/root-dir或先走迁移导出。" >&2
echo "[dev:rust] 常见原因是同一个 data-dir 保留了旧库 replicas/1但 control-db 已指向新库。" >&2
echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB再备份或移走 ${data_dir} 后重新启动。" >&2
echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/data-dir或先走迁移导出。" >&2
fi
}
describe_spacetime_root_owner() {
local root_dir="$1"
local windows_root_dir="${root_dir}"
local data_dir="$1"
local windows_data_dir="${data_dir}"
if [[ "${windows_root_dir}" =~ ^/([a-zA-Z])/(.*)$ ]]; then
windows_root_dir="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
if [[ "${windows_data_dir}" =~ ^/([a-zA-Z])/(.*)$ ]]; then
windows_data_dir="${BASH_REMATCH[1]}:/${BASH_REMATCH[2]}"
fi
# Windows 本地开发最常见的失败是同一个 root-dir 下已有 standalone 持有 spacetime.pid
# Windows 本地开发最常见的失败是同一个 data-dir 下已有 standalone 持有 spacetime.pid
# 启动前先打印占用进程,避免用户只看到底层 os error 33 而不知道该停哪个实例。
if command -v powershell.exe >/dev/null 2>&1; then
ROOT_DIR_FOR_POWERSHELL="${windows_root_dir}" powershell.exe -NoProfile -Command '
$rootDir = $env:ROOT_DIR_FOR_POWERSHELL
$normalized = $rootDir.Replace("/", "\")
# 只有 Windows/Git Bash 风格路径才交给 PowerShell 查 Windows 进程;
# WSL/Linux 的 /tmp、/home 路径不能直接拿去匹配 Windows CommandLine容易误命中无关 spacetime 进程。
if command -v powershell.exe >/dev/null 2>&1 && [[ "${data_dir}" =~ ^/([a-zA-Z])/ ]]; then
DATA_DIR_FOR_POWERSHELL="${windows_data_dir}" powershell.exe -NoProfile -Command '
$dataDir = $env:DATA_DIR_FOR_POWERSHELL
$normalized = $dataDir.Replace("/", "\")
Get-CimInstance Win32_Process |
Where-Object { $_.Name -match "spacetime" -and $_.CommandLine -and $_.CommandLine.Replace("/", "\") -like "*$normalized*" } |
ForEach-Object { "pid=$($_.ProcessId) name=$($_.Name) command=$($_.CommandLine)" }
@@ -217,7 +260,7 @@ Get-CimInstance Win32_Process |
fi
if command -v ps >/dev/null 2>&1; then
ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v root_dir="${root_dir}" '
ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v data_dir="${data_dir}" '
{
user = $1
pid = $2
@@ -230,7 +273,7 @@ Get-CimInstance Win32_Process |
sub(/^.*\//, "", name)
# 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。
if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, root_dir) > 0) {
if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, data_dir) > 0) {
print user " " pid " " ppid " " stat " " name " " args
}
}
@@ -271,50 +314,6 @@ request.on("error", () => process.exit(1));
exit 1
}
sync_local_spacetime_install() {
local root_dir="$1"
# SpacetimeDB standalone 会在 --root-dir 下回调 bin/current/spacetimedb-cli.exe
# Windows 本地开发使用工程内 root-dir 时,需要把用户级安装目录同步进来。
if [[ "${OSTYPE:-}" != msys* && "${OSTYPE:-}" != cygwin* ]]; then
return
fi
local target_cli="${root_dir}/bin/current/spacetimedb-cli.exe"
if [[ -f "${target_cli}" ]]; then
return
fi
local spacetime_command
spacetime_command="$(command -v spacetime || true)"
if [[ -z "${spacetime_command}" ]]; then
return
fi
local install_dir
install_dir="$(cd -- "$(dirname -- "${spacetime_command}")" && pwd)"
if [[ ! -d "${install_dir}/bin" ]]; then
return
fi
echo "[dev:rust] 同步本机 SpacetimeDB 安装到 ${root_dir}"
mkdir -p "${root_dir}"
cp -a "${install_dir}/bin" "${root_dir}/"
if [[ -f "${install_dir}/spacetime.exe" ]]; then
cp -f "${install_dir}/spacetime.exe" "${root_dir}/spacetime.exe"
fi
# Git Bash 复制 Windows junction 时可能不会生成可执行的 current 目录;
# 若 current 缺失,则用最新版本目录复制出一个真实目录,满足 standalone 回调路径。
if [[ ! -f "${target_cli}" ]]; then
local version_dir
version_dir="$(find "${root_dir}/bin" -mindepth 1 -maxdepth 1 -type d ! -name current | sort -V | tail -n 1)"
if [[ -n "${version_dir}" && -f "${version_dir}/spacetimedb-cli.exe" ]]; then
rm -rf "${root_dir}/bin/current"
cp -a "${version_dir}" "${root_dir}/bin/current"
fi
fi
}
generate_migration_bootstrap_secret() {
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
@@ -363,10 +362,11 @@ ADMIN_WEB_PORT="3102"
SPACETIME_HOST="127.0.0.1"
SPACETIME_PORT="3101"
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
SPACETIME_DATA_DIR="${SPACETIME_ROOT_DIR}/data"
DATABASE=""
API_LOG="info,tower_http=info"
SPACETIME_TIMEOUT_SECONDS="60"
API_SERVER_TIMEOUT_SECONDS="300"
API_SERVER_TIMEOUT_SECONDS="600"
SKIP_SPACETIME=0
SKIP_PUBLISH=0
PRESERVE_DATABASE=0
@@ -436,6 +436,11 @@ while [[ $# -gt 0 ]]; do
;;
--spacetime-root-dir)
SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}"
SPACETIME_DATA_DIR="${SPACETIME_ROOT_DIR}/data"
shift 2
;;
--spacetime-data-dir)
SPACETIME_DATA_DIR="${2:?缺少 --spacetime-data-dir 的值}"
shift 2
;;
--database)
@@ -537,39 +542,44 @@ echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}"
echo "[dev:rust] spacetime: ${SPACETIME_SERVER}"
echo "[dev:rust] database: ${DATABASE}"
echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}"
echo "[dev:rust] spacetime data: ${SPACETIME_DATA_DIR}"
echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s"
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
mkdir -p "${SPACETIME_ROOT_DIR}"
sync_local_spacetime_install "${SPACETIME_ROOT_DIR}"
if is_spacetime_ready "${SPACETIME_SERVER}" "${SPACETIME_ROOT_DIR}"; then
echo "[dev:rust] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER}"
else
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_ROOT_DIR}")"
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
echo "[dev:rust] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
echo "[dev:rust] 目标地址未就绪: ${SPACETIME_SERVER}" >&2
echo "[dev:rust] 如需复用,请传入占用实例实际端口,例如 --spacetime-port 3199如需重启请先停止下列进程。" >&2
echo "${SPACETIME_ROOT_OWNER}" >&2
exit 1
fi
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
exec spacetime \
--root-dir="${SPACETIME_ROOT_DIR}" \
start \
--edition standalone \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
) &
PIDS+=("$!")
NAMES+=("spacetimedb")
mkdir -p "${SPACETIME_ROOT_DIR}" "${SPACETIME_DATA_DIR}"
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")"
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
echo "[dev:rust] 当前 data-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
echo "[dev:rust] 如需复用,请传入占用实例实际端口并追加 --skip-spacetime如需重启请先停止下列进程。" >&2
echo "${SPACETIME_ROOT_OWNER}" >&2
exit 1
fi
SPACETIME_START_LOG="${SPACETIME_DATA_DIR}/logs/dev-rust-spacetime-start.log"
mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")"
: >"${SPACETIME_START_LOG}"
echo "[dev:rust] 启动 spacetimedb"
(
cd "${SERVER_RS_DIR}"
# 当目标端口被占用时SpacetimeDB 会询问是否使用最近的可用端口;
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
printf '\n' | spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
) 2>&1 | tee "${SPACETIME_START_LOG}" &
PIDS+=("$!")
NAMES+=("spacetimedb")
SPACETIME_LISTEN_ADDR="$(wait_for_spacetime_listen_addr "${SPACETIME_START_LOG}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}")"
SPACETIME_PORT="$(port_from_listen_addr "${SPACETIME_LISTEN_ADDR}")"
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
echo "[dev:rust] spacetime actual: ${SPACETIME_SERVER}"
fi
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
echo "[dev:rust] 等待 SpacetimeDB 就绪"
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_ROOT_DIR}" "${PIDS[0]:-}"
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${SPACETIME_DATA_DIR}" "${PIDS[0]:-}"
prepare_migration_bootstrap_secret
PUBLISH_ARGS=(
@@ -586,7 +596,12 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
PUBLISH_ARGS+=(--yes)
echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}"
spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"
(
cd "${SERVER_RS_DIR}"
# spacetime publish 会在内部调用 Cargo从 server-rs 目录执行,确保读取
# server-rs/.cargo/config.toml 中的 sccache/linker 配置,并复用同一套 target 缓存。
spacetime "${PUBLISH_ARGS[@]}"
)
fi
echo "[dev:rust] 启动 api-server"

View File

@@ -16,12 +16,14 @@ use axum::{
};
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use serde_json::{Map, Value};
use shared_contracts::admin::{
AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse,
AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
AdminTrackingEventEntryPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
AdminDatabaseOverviewPayload, AdminDatabaseTableListResponse, AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsQuery, AdminDatabaseTableRowsResponse, AdminDatabaseTableStatPayload,
AdminDebugHeaderInput, AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest,
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventListQuery,
AdminTrackingEventListResponse,
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
@@ -46,6 +48,8 @@ const BLOCKED_DEBUG_HEADERS: &[&str] = &[
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
const ADMIN_TRACKING_EVENT_DEFAULT_LIMIT: u32 = 200;
const ADMIN_TRACKING_EVENT_MAX_LIMIT: u32 = 1000;
const ADMIN_DATABASE_TABLE_DEFAULT_LIMIT: u32 = 100;
const ADMIN_DATABASE_TABLE_MAX_LIMIT: u32 = 500;
#[derive(Clone, Debug)]
pub struct AuthenticatedAdmin {
@@ -170,6 +174,26 @@ pub async fn admin_list_tracking_events(
))
}
pub async fn admin_list_database_tables(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let response = fetch_admin_database_table_list(&state).await?;
Ok(json_success_body(Some(&request_context), response))
}
pub async fn admin_list_database_table_rows(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
axum::extract::Path(table_name): axum::extract::Path<String>,
Query(query): Query<AdminDatabaseTableRowsQuery>,
) -> Result<Json<Value>, AppError> {
let response = fetch_admin_database_table_rows(&state, &table_name, query).await?;
Ok(json_success_body(Some(&request_context), response))
}
pub async fn require_admin_auth(
State(state): State<AppState>,
mut request: Request,
@@ -263,21 +287,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
.ok()
.flatten();
let schema_table_names = schema
.as_ref()
.and_then(|value| value.tables.as_ref())
.map(|tables| {
tables
.iter()
.filter_map(|table| table.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
})
.unwrap_or_default();
let schema_table_names = extract_schema_table_names(schema.as_ref());
let mut table_stats = Vec::new();
for table_name in &schema_table_names {
@@ -505,6 +515,275 @@ fn parse_count_value(value: &Value) -> Result<u64, String> {
}
}
async fn fetch_admin_database_table_list(
state: &AppState,
) -> Result<AdminDatabaseTableListResponse, AppError> {
let (_, tables, fetch_errors) = fetch_admin_database_schema_tables(state).await;
Ok(AdminDatabaseTableListResponse {
tables,
fetch_errors,
})
}
async fn fetch_admin_database_table_rows(
state: &AppState,
table_name: &str,
query: AdminDatabaseTableRowsQuery,
) -> Result<AdminDatabaseTableRowsResponse, AppError> {
let table_name = table_name.trim();
if !is_safe_spacetime_table_name(table_name) {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("表名不合法"));
}
let (_, tables, _) = fetch_admin_database_schema_tables(state).await;
if !tables.iter().any(|name| name == table_name) {
return Err(AppError::from_status(StatusCode::NOT_FOUND).with_message("表不存在"));
}
let client = Client::new();
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
let database = state.config.spacetime_database.trim();
let token = resolve_admin_spacetime_sql_token(state);
let limit = clamp_admin_database_table_limit(query.limit);
let sql = format!("SELECT * FROM {table_name} LIMIT {limit}");
let payload = fetch_spacetime_sql_json(&client, server_root, database, token.as_deref(), &sql)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!(
"表数据读取失败:{}",
normalize_table_count_error(&error)
))
})?;
let mut response = parse_admin_database_table_rows_sql_response(table_name, limit, payload)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message(format!("表数据解析失败:{error}"))
})?;
apply_admin_database_table_filters(&mut response.rows, &query)?;
response.total_returned = response.rows.len();
Ok(response)
}
async fn fetch_admin_database_schema_tables(
state: &AppState,
) -> (Option<SpacetimeSchemaResponse>, Vec<String>, Vec<String>) {
let client = Client::new();
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
let database = state.config.spacetime_database.trim();
let token = resolve_admin_spacetime_sql_token(state);
let mut fetch_errors = Vec::new();
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
&client,
&build_spacetime_schema_url(server_root, database),
token.as_deref(),
)
.await
.map_err(|error| fetch_errors.push(format!("数据库 schema 读取失败:{error}")))
.ok()
.flatten();
let tables = extract_schema_table_names(schema.as_ref());
(schema, tables, fetch_errors)
}
fn extract_schema_table_names(schema: Option<&SpacetimeSchemaResponse>) -> Vec<String> {
schema
.and_then(|value| value.tables.as_ref())
.map(|tables| {
tables
.iter()
.filter_map(|table| table.name.as_deref())
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn resolve_admin_spacetime_sql_token(state: &AppState) -> Option<String> {
state
.config
.spacetime_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(load_local_spacetime_cli_token)
}
fn clamp_admin_database_table_limit(limit: Option<u32>) -> u32 {
limit
.unwrap_or(ADMIN_DATABASE_TABLE_DEFAULT_LIMIT)
.clamp(1, ADMIN_DATABASE_TABLE_MAX_LIMIT)
}
fn parse_admin_database_table_rows_sql_response(
table_name: &str,
limit: u32,
payload: Value,
) -> Result<AdminDatabaseTableRowsResponse, String> {
let statement = extract_first_sql_statement(payload)?;
let columns = extract_sql_statement_columns(&statement);
let rows_value = statement
.get("rows")
.ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?;
let row_values = rows_value
.as_array()
.ok_or_else(|| "SQL rows 字段格式非法".to_string())?;
let rows = row_values
.iter()
.map(|row| build_admin_database_table_row(row, &columns))
.collect::<Vec<_>>();
Ok(AdminDatabaseTableRowsResponse {
table_name: table_name.to_string(),
columns,
total_returned: rows.len(),
rows,
limit,
})
}
fn extract_first_sql_statement(payload: Value) -> Result<Value, String> {
match payload {
Value::Array(statements) => statements
.into_iter()
.next()
.ok_or_else(|| "SQL 结果为空".to_string()),
Value::Object(statement) => Ok(Value::Object(statement)),
_ => Err("SQL 响应格式非法".to_string()),
}
}
fn extract_sql_statement_columns(statement: &Value) -> Vec<String> {
statement
.get("schema")
.and_then(|schema| schema.get("elements"))
.and_then(Value::as_array)
.map(|elements| {
elements
.iter()
.enumerate()
.map(|(index, element)| {
element
.get("name")
.and_then(extract_sql_schema_name)
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("col_{}", index + 1))
})
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload {
let raw = normalize_admin_database_value(row);
let mut cells = Map::new();
if let Some(values) = row.as_array() {
for (index, value) in values.iter().enumerate() {
let key = columns
.get(index)
.cloned()
.unwrap_or_else(|| format!("col_{}", index + 1));
cells.insert(key, normalize_admin_database_value(value));
}
} else if let Some(object) = row.as_object() {
for (key, value) in object {
cells.insert(key.clone(), normalize_admin_database_value(value));
}
}
AdminDatabaseTableRowPayload {
cells: Value::Object(cells),
raw,
}
}
fn normalize_admin_database_value(value: &Value) -> Value {
match value {
Value::Array(items) if items.len() == 1 => normalize_admin_database_value(&items[0]),
Value::Array(items) if items.len() == 2 => {
if let Some(index) = items.first().and_then(Value::as_u64) {
if index == 0 {
return items
.get(1)
.map(normalize_admin_database_value)
.unwrap_or(Value::Null);
}
if index == 1 && items.get(1).and_then(Value::as_array).is_some() {
return Value::Null;
}
}
Value::Array(items.iter().map(normalize_admin_database_value).collect())
}
Value::Array(items) => {
Value::Array(items.iter().map(normalize_admin_database_value).collect())
}
Value::Object(object) => {
if let Some(value) = object.get("some") {
return normalize_admin_database_value(value);
}
Value::Object(
object
.iter()
.map(|(key, value)| (key.clone(), normalize_admin_database_value(value)))
.collect(),
)
}
_ => value.clone(),
}
}
fn apply_admin_database_table_filters(
rows: &mut Vec<AdminDatabaseTableRowPayload>,
query: &AdminDatabaseTableRowsQuery,
) -> Result<(), AppError> {
if let Some(search) = normalized_non_empty(query.search.as_deref()) {
let needle = search.to_ascii_lowercase();
rows.retain(|row| row.cells.to_string().to_ascii_lowercase().contains(&needle));
}
if let Some(filters) = normalized_non_empty(query.filters.as_deref()) {
let parsed = serde_json::from_str::<Value>(filters).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("筛选 JSON 解析失败:{error}"))
})?;
let object = parsed.as_object().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("筛选条件必须是 JSON object")
})?;
rows.retain(|row| row_matches_admin_database_filters(row, object));
}
Ok(())
}
fn row_matches_admin_database_filters(
row: &AdminDatabaseTableRowPayload,
filters: &Map<String, Value>,
) -> bool {
let Some(cells) = row.cells.as_object() else {
return filters.is_empty();
};
filters.iter().all(|(key, expected)| {
cells
.get(key)
.map(|actual| admin_database_filter_value_matches(actual, expected))
.unwrap_or(false)
})
}
fn admin_database_filter_value_matches(actual: &Value, expected: &Value) -> bool {
if actual == expected {
return true;
}
if let Some(expected_text) = expected.as_str() {
return value_to_string(actual)
.map(|actual_text| actual_text == expected_text)
.unwrap_or(false);
}
false
}
async fn fetch_admin_tracking_events(
state: &AppState,
query: AdminTrackingEventListQuery,
@@ -949,14 +1228,16 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
#[cfg(test)]
mod tests {
use super::{
apply_admin_database_table_filters, build_admin_database_table_row,
build_admin_tracking_events_sql, build_body_preview, build_debug_base_url,
build_spacetime_schema_url, clamp_admin_tracking_event_limit, is_safe_spacetime_table_name,
normalize_debug_path, normalize_table_count_error,
build_spacetime_schema_url, clamp_admin_database_table_limit,
clamp_admin_tracking_event_limit, is_safe_spacetime_table_name, normalize_debug_path,
normalize_table_count_error, parse_admin_database_table_rows_sql_response,
parse_admin_tracking_events_sql_response, parse_spacetime_sql_count_response, trim_preview,
};
use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
use shared_contracts::admin::AdminTrackingEventListQuery;
use shared_contracts::admin::{AdminDatabaseTableRowsQuery, AdminTrackingEventListQuery};
#[test]
fn normalize_debug_path_rejects_absolute_url() {
@@ -1119,6 +1400,103 @@ mod tests {
assert_eq!(count, 3);
}
#[test]
fn clamp_admin_database_table_limit_uses_default_and_bounds() {
assert_eq!(clamp_admin_database_table_limit(None), 100);
assert_eq!(clamp_admin_database_table_limit(Some(0)), 1);
assert_eq!(clamp_admin_database_table_limit(Some(800)), 500);
}
#[test]
fn parse_admin_database_table_rows_sql_response_maps_schema_columns() {
let payload = json!([
{
"schema": {
"elements": [
{"name": {"some": "user_id"}},
{"name": {"some": "points"}}
]
},
"rows": [["u1", 12]]
}
]);
let response = parse_admin_database_table_rows_sql_response("profile_wallet", 100, payload)
.expect("table rows should parse");
assert_eq!(response.table_name, "profile_wallet");
assert_eq!(response.columns, vec!["user_id", "points"]);
assert_eq!(response.total_returned, 1);
assert_eq!(response.rows[0].cells["user_id"], json!("u1"));
assert_eq!(response.rows[0].cells["points"], json!(12));
}
#[test]
fn build_admin_database_table_row_normalizes_optional_sats_values() {
let row = build_admin_database_table_row(
&json!([[0, "u1"], [1, []]]),
&["user_id".to_string(), "deleted_at".to_string()],
);
assert_eq!(row.cells["user_id"], json!("u1"));
assert_eq!(row.cells["deleted_at"], json!(null));
}
#[test]
fn apply_admin_database_table_filters_supports_search_and_json_filters() {
let mut rows = vec![
build_admin_database_table_row(
&json!(["u1", "alice", 12]),
&[
"user_id".to_string(),
"name".to_string(),
"points".to_string(),
],
),
build_admin_database_table_row(
&json!(["u2", "bob", 8]),
&[
"user_id".to_string(),
"name".to_string(),
"points".to_string(),
],
),
];
apply_admin_database_table_filters(
&mut rows,
&AdminDatabaseTableRowsQuery {
search: Some("ali".to_string()),
filters: Some(r#"{"points":12}"#.to_string()),
limit: None,
},
)
.expect("filters should apply");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].cells["user_id"], json!("u1"));
}
#[test]
fn apply_admin_database_table_filters_rejects_non_object_filter() {
let mut rows = vec![build_admin_database_table_row(
&json!(["u1"]),
&["user_id".to_string()],
)];
let error = apply_admin_database_table_filters(
&mut rows,
&AdminDatabaseTableRowsQuery {
search: None,
filters: Some("[]".to_string()),
limit: None,
},
)
.expect_err("non object filter should fail");
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
}
#[test]
fn build_admin_tracking_events_sql_quotes_filters_and_clamps_limit() {
let sql = build_admin_tracking_events_sql(&AdminTrackingEventListQuery {

View File

@@ -14,8 +14,8 @@ use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_debug_http, admin_list_tracking_events, admin_login, admin_me, admin_overview,
require_admin_auth,
admin_debug_http, admin_list_database_table_rows, admin_list_database_tables,
admin_list_tracking_events, admin_login, admin_me, admin_overview, require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
@@ -200,6 +200,20 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth,
)),
)
.route(
"/admin/api/database/tables",
get(admin_list_database_tables).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/database/tables/{table_name}/rows",
get(admin_list_database_table_rows).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes)

View File

@@ -26,6 +26,47 @@ pub fn create_password_auth_session(
create_auth_session(state, user, session_client, AuthLoginMethod::Password)
}
#[cfg(not(test))]
pub async fn record_daily_login_tracking_event_after_auth_success(
state: &AppState,
request_context: &crate::request_context::RequestContext,
user_id: &str,
login_method: AuthLoginMethod,
) {
// 登录埋点是运营数据,不应反向阻断已经成功的认证会话签发。
match state
.spacetime_client()
.record_daily_login_tracking_event(user_id.to_string())
.await
{
Ok(()) => tracing::info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
user_id = %user_id,
login_method = %login_method.as_str(),
"登录成功每日登录埋点已记录"
),
Err(error) => tracing::warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
user_id = %user_id,
login_method = %login_method.as_str(),
error = %error,
"登录成功每日登录埋点记录失败,登录流程继续"
),
}
}
#[cfg(test)]
pub async fn record_daily_login_tracking_event_after_auth_success(
_state: &AppState,
_request_context: &crate::request_context::RequestContext,
_user_id: &str,
_login_method: AuthLoginMethod,
) {
// 单元测试默认不启动 SpacetimeDB这里仅验证登录链路调用点能通过编译并保持非阻断语义。
}
pub fn create_auth_session(
state: &AppState,
user: &AuthUser,

View File

@@ -4,7 +4,7 @@ use axum::{
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use module_auth::{AuthLoginMethod, PasswordEntryError, PasswordEntryInput};
use serde_json::json;
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
@@ -12,7 +12,8 @@ use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
attach_set_cookie_header, build_refresh_session_cookie_header,
create_password_auth_session, record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
request_context::RequestContext,
@@ -49,6 +50,13 @@ pub async fn password_entry(
}
let session_client = resolve_session_client_context(&headers);
let signed_session = create_password_auth_session(&state, &result.user, &session_client)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Password,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await

View File

@@ -16,6 +16,7 @@ use crate::{
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
phone_auth::map_phone_auth_error,
@@ -79,6 +80,13 @@ pub async fn reset_password(
&session_client,
module_auth::AuthLoginMethod::Password,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
module_auth::AuthLoginMethod::Password,
)
.await;
let mut headers = HeaderMap::new();
attach_set_cookie_header(

View File

@@ -20,6 +20,7 @@ use crate::{
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_phone_auth_platform_store_error},
@@ -176,6 +177,13 @@ pub async fn phone_login(
&session_client,
AuthLoginMethod::Phone,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Phone,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await

View File

@@ -13,7 +13,8 @@ use crate::{
auth::RefreshSessionToken,
auth_session::{
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
build_refresh_session_cookie_header, map_refresh_session_error, sign_access_token_for_user,
build_refresh_session_cookie_header, map_refresh_session_error,
record_daily_login_tracking_event_after_auth_success, sign_access_token_for_user,
},
http_error::AppError,
request_context::RequestContext,
@@ -54,6 +55,13 @@ pub async fn refresh_session(
&rotated.session.session_id,
Some(&rotated.session.issued_by_provider),
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&rotated.user.id,
rotated.session.issued_by_provider.clone(),
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await

View File

@@ -21,6 +21,7 @@ use crate::{
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
record_daily_login_tracking_event_after_auth_success,
},
http_error::AppError,
platform_errors::{attach_retry_after, map_wechat_provider_error},
@@ -74,6 +75,7 @@ pub async fn start_wechat_login(
pub async fn handle_wechat_callback(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
headers: HeaderMap,
Query(query): Query<WechatCallbackQuery>,
) -> Result<impl IntoResponse, AppError> {
@@ -141,6 +143,13 @@ pub async fn handle_wechat_callback(
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
@@ -208,6 +217,13 @@ pub async fn bind_wechat_phone(
&session_client,
AuthLoginMethod::Wechat,
)?;
record_daily_login_tracking_event_after_auth_success(
&state,
&request_context,
&result.user.id,
AuthLoginMethod::Wechat,
)
.await;
state
.sync_auth_store_snapshot_to_spacetime()
.await

View File

@@ -544,6 +544,13 @@ pub struct RuntimeTrackingEventInput {
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeTrackingEventProcedureResult {
pub ok: bool,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigSnapshot {

View File

@@ -34,6 +34,8 @@
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract避免浏览器拿到 OSS 写权限。
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
## 3. 边界约束

View File

@@ -1084,6 +1084,7 @@ fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result
}
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
// 中文注释time::Time 的 Display 在小时小于 10 时不会稳定补零OSS V4 必须使用固定宽度 UTC 时间。
Ok(format!(
"{}T{:02}{:02}{:02}Z",
format_v4_signature_scope_date(signed_at),
@@ -1486,6 +1487,39 @@ mod tests {
assert!(response.signed_url.contains("&x-oss-signature="));
}
#[test]
fn sign_get_object_url_uses_square_hole_object_key_without_bucket_prefix() {
let client = OssClient::new(
OssConfig::new(
"xushi-dev".to_string(),
"oss-cn-shanghai.aliyuncs.com".to_string(),
"test-access-key-id".to_string(),
"test-access-key-secret".to_string(),
DEFAULT_READ_EXPIRE_SECONDS,
DEFAULT_POST_EXPIRE_SECONDS,
DEFAULT_POST_MAX_SIZE_BYTES,
DEFAULT_SUCCESS_ACTION_STATUS,
)
.expect("OSS config should be valid"),
);
let response = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string(),
expire_seconds: Some(300),
})
.expect("square hole object key should build signed url");
assert_eq!(response.bucket, "xushi-dev".to_string());
assert_eq!(
response.object_key,
"generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string()
);
assert!(response
.signed_url
.starts_with("https://xushi-dev.oss-cn-shanghai.aliyuncs.com/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png?"));
}
#[test]
fn sign_get_object_url_rejects_unsupported_prefix() {
let client = build_client();

View File

@@ -77,6 +77,42 @@ pub struct AdminDatabaseTableStatPayload {
pub error_message: Option<String>,
}
// 后台表清单独立用于“表查询”页,避免页面必须先拉完整总览。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableListResponse {
pub tables: Vec<String>,
pub fetch_errors: Vec<String>,
}
// 后台通用表查询参数,用户输入不进入 SQL只在 API Server 内存中过滤。
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableRowsQuery {
pub limit: Option<u32>,
pub search: Option<String>,
pub filters: Option<String>,
}
// 后台通用表查询响应cells 使用列名映射raw 保留原始行便于详情排障。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableRowsResponse {
pub table_name: String,
pub columns: Vec<String>,
pub rows: Vec<AdminDatabaseTableRowPayload>,
pub total_returned: usize,
pub limit: u32,
}
// 单行查询结果,值统一用 JSON 承载以兼容不同表字段类型。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableRowPayload {
pub cells: Value,
pub raw: Value,
}
// 调试请求只允许同源路径、受控请求头和有限请求体。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]

View File

@@ -894,6 +894,16 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
))
}
pub(crate) fn map_runtime_tracking_event_procedure_result(
result: RuntimeTrackingEventProcedureResult,
) -> Result<(), SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(())
}
pub(crate) fn map_runtime_profile_task_center_procedure_result(
result: RuntimeProfileTaskCenterProcedureResult,
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {

View File

@@ -525,6 +525,7 @@ pub mod record_big_fish_like_procedure;
pub mod record_big_fish_play_procedure;
pub mod record_custom_world_profile_like_procedure;
pub mod record_custom_world_profile_play_procedure;
pub mod record_daily_login_tracking_event_and_return_procedure;
pub mod record_puzzle_work_like_procedure;
pub mod record_visual_novel_runtime_event_procedure;
pub mod redeem_profile_referral_invite_code_procedure;
@@ -655,6 +656,7 @@ pub mod runtime_snapshot_table;
pub mod runtime_snapshot_type;
pub mod runtime_snapshot_upsert_input_type;
pub mod runtime_tracking_scope_kind_type;
pub mod runtime_tracking_event_procedure_result_type;
pub mod save_puzzle_form_draft_procedure;
pub mod save_puzzle_generated_images_procedure;
pub mod seed_analytics_date_dimensions_reducer;
@@ -1309,6 +1311,7 @@ pub use record_big_fish_like_procedure::record_big_fish_like;
pub use record_big_fish_play_procedure::record_big_fish_play;
pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like;
pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play;
pub use record_daily_login_tracking_event_and_return_procedure::record_daily_login_tracking_event_and_return;
pub use record_puzzle_work_like_procedure::record_puzzle_work_like;
pub use record_visual_novel_runtime_event_procedure::record_visual_novel_runtime_event;
pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code;
@@ -1439,6 +1442,7 @@ pub use runtime_snapshot_table::*;
pub use runtime_snapshot_type::RuntimeSnapshot;
pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput;
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult;
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions;

View File

@@ -0,0 +1,62 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_get_input_type::RuntimeProfileTaskCenterGetInput;
use super::runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct RecordDailyLoginTrackingEventAndReturnArgs {
pub input: RuntimeProfileTaskCenterGetInput,
}
impl __sdk::InModule for RecordDailyLoginTrackingEventAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `record_daily_login_tracking_event_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait record_daily_login_tracking_event_and_return {
fn record_daily_login_tracking_event_and_return(
&self,
input: RuntimeProfileTaskCenterGetInput,
) {
self.record_daily_login_tracking_event_and_return_then(input, |_, _| {});
}
fn record_daily_login_tracking_event_and_return_then(
&self,
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl record_daily_login_tracking_event_and_return for super::RemoteProcedures {
fn record_daily_login_tracking_event_and_return_then(
&self,
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeTrackingEventProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>(
"record_daily_login_tracking_event_and_return",
RecordDailyLoginTrackingEventAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeTrackingEventProcedureResult {
pub ok: bool,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeTrackingEventProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -304,6 +304,30 @@ impl SpacetimeClient {
.await
}
pub async fn record_daily_login_tracking_event(
&self,
user_id: String,
) -> Result<(), SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_center_get_input(user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.record_daily_login_tracking_event_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_tracking_event_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_profile_task_center(
&self,
user_id: String,

View File

@@ -491,13 +491,36 @@ pub fn query_analytics_metric(
}
}
// 任务中心读取会顺手记录当日登录埋点,确保“每日登录”只依赖后端事实
// 登录成功埋点由认证链路主动调用;任务中心只负责读取和刷新任务进度
#[spacetimedb::procedure]
pub fn record_daily_login_tracking_event_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeTrackingEventProcedureResult {
match ctx.try_with_tx(|tx| {
let validated_input = build_runtime_profile_task_center_get_input(input.user_id.clone())
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(tx);
record_daily_login_tracking_event(tx, &validated_input.user_id)
}) {
Ok(()) => RuntimeTrackingEventProcedureResult {
ok: true,
error_message: None,
},
Err(message) => RuntimeTrackingEventProcedureResult {
ok: false,
error_message: Some(message),
},
}
}
// 任务中心读取会刷新进度;每日登录埋点应由登录成功链路提前记录。
#[spacetimedb::procedure]
pub fn get_profile_task_center(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeProfileTaskCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), true)) {
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), false)) {
Ok(record) => RuntimeProfileTaskCenterProcedureResult {
ok: true,
record: Some(record),

View File

@@ -13,6 +13,7 @@ const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
refreshStoredAccessToken: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
@@ -28,6 +29,7 @@ const authMocks = vi.hoisted(() => ({
vi.mock('../../services/apiClient', () => ({
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
ensureStoredAccessToken: authMocks.ensureStoredAccessToken,
refreshStoredAccessToken: authMocks.refreshStoredAccessToken,
}));
vi.mock('../../services/authService', () => ({
@@ -94,6 +96,7 @@ beforeEach(() => {
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.refreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
authMocks.getCurrentAuthUser.mockResolvedValue({
user: null,
availableLoginMethods: ['phone'],
@@ -204,12 +207,12 @@ test('auth gate keeps platform content visible when phone login is available', a
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
});
test('auth gate waits for access token refresh before exposing restored user content', async () => {
test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => {
let resolveToken!: (token: string) => void;
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.ensureStoredAccessToken.mockReturnValue(tokenPromise);
authMocks.refreshStoredAccessToken.mockReturnValue(tokenPromise);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -224,10 +227,11 @@ test('auth gate waits for access token refresh before exposing restored user con
expect(screen.getByText('正在校验登录状态...')).toBeTruthy();
expect(authMocks.getCurrentAuthUser).not.toHaveBeenCalled();
resolveToken('jwt-restored-token');
resolveToken('jwt-refreshed-token');
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(authMocks.ensureStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.refreshStoredAccessToken).toHaveBeenCalledTimes(1);
expect(authMocks.ensureStoredAccessToken).not.toHaveBeenCalled();
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
});
@@ -440,7 +444,7 @@ test('auth state refresh keeps mounted platform content and local tab state', as
const tokenPromise = new Promise<string>((resolve) => {
resolveToken = resolve;
});
authMocks.ensureStoredAccessToken.mockReturnValueOnce(tokenPromise);
authMocks.refreshStoredAccessToken.mockReturnValueOnce(tokenPromise);
act(() => {
window.dispatchEvent(new Event('genarrative-auth-state-changed'));

View File

@@ -10,7 +10,7 @@ import {
import { useGameSettings } from '../../hooks/useGameSettings';
import {
AUTH_STATE_EVENT,
ensureStoredAccessToken,
refreshStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
@@ -311,7 +311,10 @@ export function AuthGate({ children }: AuthGateProps) {
}
try {
await ensureStoredAccessToken();
// 中文注释:打开已登录页面也要主动轮换 refresh cookie。
// 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期,
// 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。
await refreshStoredAccessToken();
const nextSession = await getCurrentAuthUser();
if (!isActive) {
return;

View File

@@ -280,6 +280,7 @@ import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultA
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
import { createMockVisualNovelRunFromDraft } from '../visual-novel-runtime/visualNovelMockData';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
getVisiblePlatformCreationTypes,
@@ -1675,6 +1676,22 @@ export function PlatformEntryFlowShellImpl({
});
const { setPlatformTab } = platformBootstrap;
useEffect(() => {
if (selectionStage === 'profile-feedback') {
setPlatformTab('profile');
}
}, [selectionStage, setPlatformTab]);
const openProfileFeedback = useCallback(() => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setPlatformTab('profile');
setSelectionStage('profile-feedback');
}, [authUi, setPlatformTab, setSelectionStage]);
const enterCreateTab = useCallback(() => {
// 只依赖稳定的 setter避免把 bootstrap 对象的 render 级引用变化
// 传导成 Agent session 恢复 effect 的重复触发。
@@ -7449,6 +7466,7 @@ export function PlatformEntryFlowShellImpl({
setIsProfilePlayStatsOpen(false);
}}
onOpenPlayedWork={openPlayedWork}
onOpenFeedback={openProfileFeedback}
onOpenProfileDashboardCard={(cardKey) => {
if (cardKey === 'playedWorks') {
openProfilePlayedWorks();
@@ -7463,6 +7481,23 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'profile-feedback' && (
<motion.div
key="platform-profile-feedback"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<PlatformFeedbackView
onBack={() => {
setPlatformTab('profile');
setSelectionStage('platform');
}}
/>
</motion.div>
)}
{selectionStage === 'work-detail' && selectedPublicWorkDetail && (
<motion.div
key="platform-work-detail"

View File

@@ -0,0 +1,63 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { PlatformFeedbackView } from './PlatformFeedbackView';
test('PlatformFeedbackView renders reference feedback fields', () => {
render(<PlatformFeedbackView onBack={vi.fn()} />);
expect(screen.getByText('帮助与反馈')).toBeTruthy();
expect(screen.getByText('反馈问题')).toBeTruthy();
expect(screen.getByLabelText('问题描述')).toBeTruthy();
expect(screen.getByText('0/200')).toBeTruthy();
expect(screen.getByText('上传凭证(提供问题截图)')).toBeTruthy();
expect(screen.getByText('上传凭证')).toBeTruthy();
expect(screen.getByLabelText('联系电话')).toBeTruthy();
expect(screen.getByRole('button', { name: '提交' })).toBeTruthy();
expect(screen.getByRole('button', { name: '查看反馈与投诉记录' })).toBeTruthy();
});
test('PlatformFeedbackView validates minimum description length before submit', () => {
const onSubmit = vi.fn();
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('问题描述'), {
target: { value: '太短' },
});
fireEvent.click(screen.getByRole('button', { name: '提交' }));
expect(screen.getByText('请填写10个字以上的问题描述')).toBeTruthy();
expect(onSubmit).not.toHaveBeenCalled();
});
test('PlatformFeedbackView submits trimmed payload', async () => {
const onSubmit = vi.fn();
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText('问题描述'), {
target: { value: ' 这个反馈页面无法正常上传图片 ' },
});
fireEvent.change(screen.getByLabelText('联系电话'), {
target: { value: ' 13800000000 ' },
});
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith({
description: '这个反馈页面无法正常上传图片',
contactPhone: '13800000000',
evidenceFiles: [],
});
await waitFor(() => expect(screen.getByText('反馈已提交')).toBeTruthy());
});
test('PlatformFeedbackView calls back from header home button', () => {
const onBack = vi.fn();
render(<PlatformFeedbackView onBack={onBack} />);
fireEvent.click(screen.getByRole('button', { name: '返回我的页签' }));
expect(onBack).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,322 @@
import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10;
const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200;
const MAX_FEEDBACK_EVIDENCE_COUNT = 4;
const MAX_CONTACT_PHONE_LENGTH = 40;
export type PlatformFeedbackPayload = {
description: string;
contactPhone: string;
evidenceFiles: File[];
};
export type PlatformFeedbackViewProps = {
onBack: () => void;
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<void>;
};
type EvidencePreview = {
id: string;
file: File;
url: string;
};
function buildEvidencePreviewId(file: File, index: number) {
return `${file.name}:${file.size}:${file.lastModified}:${index}`;
}
export function PlatformFeedbackView({
onBack,
onSubmit,
}: PlatformFeedbackViewProps) {
const evidenceInputRef = useRef<HTMLInputElement | null>(null);
const [description, setDescription] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const descriptionLength = description.length;
const evidenceFiles = useMemo(
() => evidencePreviews.map((preview) => preview.file),
[evidencePreviews],
);
useEffect(
() => () => {
evidencePreviews.forEach((preview) => URL.revokeObjectURL(preview.url));
},
[evidencePreviews],
);
const showTemporaryNotice = (message: string) => {
setNotice(message);
window.setTimeout(() => setNotice(null), 1600);
};
const updateDescription = (value: string) => {
setDescription(value.slice(0, MAX_FEEDBACK_DESCRIPTION_LENGTH));
setError(null);
setSubmitted(false);
};
const updateContactPhone = (value: string) => {
setContactPhone(value.slice(0, MAX_CONTACT_PHONE_LENGTH));
setError(null);
setSubmitted(false);
};
const openEvidencePicker = () => {
evidenceInputRef.current?.click();
};
const addEvidenceFiles = (files: FileList | null) => {
if (evidenceInputRef.current) {
evidenceInputRef.current.value = '';
}
if (!files || files.length === 0) {
return;
}
const selectedFiles = Array.from(files).filter((file) =>
file.type.startsWith('image/'),
);
const remainingCount = MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length;
if (remainingCount <= 0 || selectedFiles.length > remainingCount) {
setError('最多上传四张凭证');
}
const nextFiles = selectedFiles.slice(0, Math.max(remainingCount, 0));
if (nextFiles.length === 0) {
return;
}
const nextPreviews = nextFiles.map((file, index) => ({
id: buildEvidencePreviewId(file, evidencePreviews.length + index),
file,
url: URL.createObjectURL(file),
}));
setEvidencePreviews((currentPreviews) => [...currentPreviews, ...nextPreviews]);
setSubmitted(false);
};
const removeEvidencePreview = (id: string) => {
setEvidencePreviews((currentPreviews) => {
const previewToRemove = currentPreviews.find((preview) => preview.id === id);
if (previewToRemove) {
URL.revokeObjectURL(previewToRemove.url);
}
return currentPreviews.filter((preview) => preview.id !== id);
});
setError(null);
setSubmitted(false);
};
const submitFeedback = () => {
if (isSubmitting) {
return;
}
const trimmedDescription = description.trim();
const trimmedContactPhone = contactPhone.trim();
if (trimmedDescription.length < MIN_FEEDBACK_DESCRIPTION_LENGTH) {
setError('请填写10个字以上的问题描述');
setSubmitted(false);
return;
}
if (trimmedDescription.length > MAX_FEEDBACK_DESCRIPTION_LENGTH) {
setError('问题描述不能超过 200 字');
setSubmitted(false);
return;
}
if (trimmedContactPhone.length > MAX_CONTACT_PHONE_LENGTH) {
setError('联系电话不能超过 40 字');
setSubmitted(false);
return;
}
if (evidenceFiles.length > MAX_FEEDBACK_EVIDENCE_COUNT) {
setError('最多上传四张凭证');
setSubmitted(false);
return;
}
setIsSubmitting(true);
setError(null);
// 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。
void Promise.resolve(
onSubmit?.({
description: trimmedDescription,
contactPhone: trimmedContactPhone,
evidenceFiles,
}),
)
.then(() => setSubmitted(true))
.catch((submitError: unknown) => {
setSubmitted(false);
setError(submitError instanceof Error ? submitError.message : '提交失败');
})
.finally(() => setIsSubmitting(false));
};
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-[#f5f6f8] text-[#202124]">
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col pb-8">
<header className="sticky top-0 z-10 rounded-b-[1.35rem] bg-white px-4 pb-3 pt-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<div className="grid grid-cols-[2.5rem_1fr_5.75rem] items-center gap-2">
<button
type="button"
onClick={onBack}
aria-label="返回我的页签"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#24262b] transition hover:bg-slate-100"
>
<Home className="h-5 w-5" />
</button>
<div className="text-center text-base font-semibold text-[#1f2329]">
</div>
<div
className="flex h-8 items-center justify-center gap-2 rounded-full border border-[#d8dce3] px-2 text-[#1f2329]"
aria-hidden="true"
>
<span className="text-lg leading-none">···</span>
<span className="h-4 w-px bg-[#d8dce3]" />
<span className="h-0.5 w-3 rounded-full bg-[#1f2329]" />
<span className="h-3.5 w-3.5 rounded-full border-2 border-[#1f2329]" />
</div>
</div>
</header>
<main className="flex flex-1 flex-col gap-3 px-4 pt-5">
<div className="text-sm font-medium text-[#8b93a1]"></div>
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
<label htmlFor="profile-feedback-description" className="block text-base font-semibold text-[#1f2329]">
</label>
<textarea
id="profile-feedback-description"
value={description}
maxLength={MAX_FEEDBACK_DESCRIPTION_LENGTH}
onChange={(event) => updateDescription(event.target.value)}
placeholder="请填写10个字以上的问题描述以便我们提供更好的帮助温馨提醒您请勿填写身份证号等个人隐私信息。"
className="mt-3 min-h-[10.5rem] w-full resize-none border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
/>
<div className="text-right text-xs text-[#a5adba]">
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
</div>
</section>
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
<div className="text-base font-semibold text-[#1f2329]">
()
</div>
<div className="mt-4 flex flex-wrap gap-3">
{evidencePreviews.map((preview) => (
<div
key={preview.id}
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[#e3e6eb] bg-[#f7f8fa]"
>
<img
src={preview.url}
alt="反馈凭证预览"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => removeEvidencePreview(preview.id)}
aria-label="移除上传凭证"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/55 text-white"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
<button
type="button"
onClick={openEvidencePicker}
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[#cdd3dc] bg-[#fbfcfd] text-[#9aa3af] transition hover:border-[#2f7cf6] hover:text-[#2f7cf6]"
>
<ImagePlus className="h-6 w-6" />
<span className="mt-2 text-xs font-medium"></span>
<span className="mt-0.5 text-[11px]">()</span>
</button>
) : null}
</div>
<input
ref={evidenceInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => addEvidenceFiles(event.target.files)}
/>
</section>
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
<label htmlFor="profile-feedback-phone" className="block text-base font-semibold text-[#1f2329]">
</label>
<input
id="profile-feedback-phone"
type="tel"
value={contactPhone}
maxLength={MAX_CONTACT_PHONE_LENGTH}
onChange={(event) => updateContactPhone(event.target.value)}
placeholder="选填,如您填写则将会同步开发者与您联系"
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
/>
</section>
{error ? (
<div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-600">
{error}
</div>
) : null}
{submitted ? (
<div className="flex items-center gap-2 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
<CheckCircle2 className="h-4 w-4" />
</div>
) : null}
{notice ? (
<div className="rounded-2xl bg-blue-50 px-4 py-3 text-sm font-medium text-[#2f7cf6]">
{notice}
</div>
) : null}
<button
type="button"
onClick={submitFeedback}
disabled={isSubmitting}
className="mt-2 flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-[#2f7cf6] text-base font-semibold text-white shadow-[0_10px_22px_rgba(47,124,246,0.26)] transition hover:bg-[#1f6bea] disabled:cursor-not-allowed disabled:opacity-60"
>
<Send className="h-4 w-4" />
{isSubmitting ? '提交中' : '提交'}
</button>
<button
type="button"
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
className="self-center px-3 py-2 text-sm font-medium text-[#2f7cf6]"
>
</button>
<button
type="button"
onClick={onBack}
className="mt-auto flex items-center justify-center gap-2 self-center px-3 py-2 text-xs font-medium text-[#8b93a1]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</main>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'profile-feedback'
| 'work-detail'
| 'detail'
| 'agent-workspace'

View File

@@ -140,6 +140,7 @@ export interface RpgEntryHomeViewProps {
profilePlayStatsError?: string | null;
onCloseProfilePlayStats?: () => void;
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onOpenFeedback?: () => void;
onRechargeSuccess?: () => void | Promise<void>;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
@@ -2855,6 +2856,7 @@ export function RpgEntryHomeView({
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onOpenFeedback,
onRechargeSuccess,
createTabContent,
draftTabContent,
@@ -4222,6 +4224,12 @@ export function RpgEntryHomeView({
icon={MessageCircle}
onClick={() => openProfilePopupPanel('community')}
/>
<ProfileShortcutButton
label="反馈"
subLabel="问题与建议"
icon={MessageCircle}
onClick={onOpenFeedback}
/>
</div>
</section>

View File

@@ -1,78 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
APP_RUNTIME_ROUTES,
buildPublicWorkDetailPath,
buildPublicWorkDetailUrl,
buildPublicWorkStagePath,
isKnownMainAppPagePath,
normalizeAppPath,
readPublicWorkCodeFromLocationSearch,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './appPageRoutes';
describe('appPageRoutes', () => {
it('normalizes page paths for stable matching', () => {
expect(normalizeAppPath('')).toBe('/');
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe(
'/creation/rpg/agent',
it('resolves profile feedback route', () => {
expect(resolveSelectionStageFromPath('/profile/feedback')).toBe(
'profile-feedback',
);
});
it('resolves platform entry stages from independent paths', () => {
expect(resolveSelectionStageFromPath('/creation/rpg/agent')).toBe(
'agent-workspace',
expect(resolveSelectionStageFromPath('/profile/feedback/')).toBe(
'profile-feedback',
);
expect(resolveSelectionStageFromPath('/creation/big-fish/result/')).toBe(
'big-fish-result',
);
expect(resolveSelectionStageFromPath('/creation/match3d/result')).toBe(
'match3d-result',
);
expect(resolveSelectionStageFromPath('/gallery/puzzle/detail')).toBe(
'puzzle-gallery-detail',
);
});
it('falls back to platform for unknown paths inside the main app', () => {
expect(resolveSelectionStageFromPath('/missing')).toBe('platform');
});
it('resolves paths from selection stages', () => {
expect(resolvePathForSelectionStage('custom-world-generating')).toBe(
'/creation/rpg/generating',
);
expect(resolvePathForSelectionStage('puzzle-runtime')).toBe(
'/runtime/puzzle',
);
});
it('recognizes runtime pages as main app pages', () => {
expect(
isKnownMainAppPagePath(APP_RUNTIME_ROUTES['rpg-character-select']),
).toBe(true);
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
});
it('builds and reads public work detail query routes', () => {
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
'/works/detail?work=CW-00000001',
);
expect(
buildPublicWorkDetailUrl('CW-00000001', 'https://example.test'),
).toBe('https://example.test/works/detail?work=CW-00000001');
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
'CW-00000001',
);
expect(
buildPublicWorkStagePath('puzzle-gallery-detail', 'PZ-00000002'),
).toBe('/gallery/puzzle/detail?work=PZ-00000002');
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
'/runtime/big-fish?work=BF-00000003',
);
expect(buildPublicWorkStagePath('match3d-runtime', 'M3-00000004')).toBe(
'/runtime/match3d?work=M3-00000004',
expect(resolvePathForSelectionStage('profile-feedback')).toBe(
'/profile/feedback',
);
});
});

View File

@@ -6,6 +6,7 @@ export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['profile-feedback', '/profile/feedback'],
['work-detail', '/works/detail'],
['detail', '/worlds/detail'],
['agent-workspace', '/creation/rpg/agent'],

View File

@@ -513,6 +513,10 @@ export async function ensureStoredAccessToken() {
return refreshAccessToken();
}
export async function refreshStoredAccessToken() {
return refreshAccessToken();
}
export async function fetchWithApiAuth(
input: string,
init: RequestInit = {},