Merge origin/master into codex/wechat

This commit is contained in:
2026-05-12 16:20:45 +08:00
993 changed files with 154111 additions and 6329 deletions
@@ -0,0 +1,74 @@
# 后台创作入口开关操作入口
日期:2026-05-11
## 背景
创作中心入口配置已经迁移到 SpacetimeDB,前端创作中心与 api-server 路由熔断共用同一份入口配置。为了让运营/管理员可以调整这张开关表,需要在后台提供显式操作入口,而不是只通过数据库表查询或手工 reducer 操作。
## 后台入口
后台新增导航:
- 名称:入口开关
- hash:`#creation-entry`
- 页面:`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`
页面能力:
- 读取当前入口开关表。
- 编辑单个入口:
- `id`
- `title`
- `subtitle`
- `badge`
- `imageSrc`
- `visible`
- `open`
- `sortOrder`
- 保存前复用后台写操作确认弹窗。
- 保存后重新用后端返回的配置刷新列表。
## API
后台新增受管理员鉴权保护的接口:
```text
GET /admin/api/creation-entry/config
POST /admin/api/creation-entry/config
```
POST body:
```json
{
"id": "puzzle",
"title": "拼图",
"subtitle": "拼图关卡创作",
"badge": "可创建",
"imageSrc": "/creation-type-references/puzzle.webp",
"visible": true,
"open": true,
"sortOrder": 30
}
```
响应统一返回当前全量入口列表,方便后台页面直接刷新本地状态。
## 入库链路
```text
Admin Web
-> api-server /admin/api/creation-entry/config
-> AppState::upsert_creation_entry_type_config
-> spacetime-client procedure upsert_creation_entry_type_config
-> spacetime-module creation_entry_type_config 表
```
`visible=false` 会让创作中心不展示对应入口;`open=false` 会让前端展示锁定态,并让 api-server 熔断对应玩法创作 / 运行态 API。隐藏入口但仍保留既有作品号、广场详情或试玩链路时,应只关闭 `visible`,不要关闭 `open`。
## 注意
- 前端后台页面只做管理表单,不成为配置事实源。
- `src/config/newWorkEntryConfig.ts` 不应恢复。
- SpacetimeDB client bindings 当前新增了对应临时 binding 文件;后续执行标准 bindings regenerate 时应覆盖并保持同名 procedure/type。
@@ -0,0 +1,98 @@
# 后台数据库表查询技术方案(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 值统一转成人可读 JSON:Option None 为 null,Some 展开为内部值,Timestamp 单元素数组展开为内部值,enum 可保留 tag/name 或原始数组文本。
## 前端页面
路由:`#tables`,导航名“表查询”。
页面能力:
- 表选择下拉展示中文表名并保留原始表名,支持 URL hash `#tables?table=xxx` 直达指定表。
- 查询表单:表名、关键词、JSON 条件、条数。
- 查询结果表格横向滚动,移动端不撑坏布局。
- 查询结果标题和已选表摘要展示中文表名,鼠标悬浮显示原始表名和表说明,方便运营识别真实数据域。
- 表头支持点击排序,排序只作用于当前已拉取的行数据,不改变后端 SQL。
- 表头展示中文字段名,鼠标悬浮显示原始字段名、字段说明和排序提示,方便运营阅读且保留排障所需的真实列名。
- 单元格内容过长时在表格内单行省略,完整内容可通过悬浮标题或行详情弹层查看。
- 每行提供“详情”按钮,以独立弹层展示完整 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`
@@ -0,0 +1,170 @@
# 后台埋点数据明细与 Excel 导出方案
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** 在百梦后台新增“埋点数据”页,展示每条埋点原始事件的详细字段,并支持导出为 Excel 可直接打开的表格文件。
**Architecture:** 后端继续由 `api-server` 作为后台 BFF,经 SpacetimeDB HTTP SQL 只读查询 `tracking_event`,不改变表结构和 reducer。前端在 `apps/admin-web` 中新增独立路由与页面,页面渲染后端返回的原始明细,并在浏览器侧导出 Excel 兼容的 `.xls` HTML 表格,避免新增依赖。
**Tech Stack:** Rust Axum、SpacetimeDB HTTP SQL、shared-contracts、React 19、TypeScript、Vite。
---
## 范围
本次只做后台只读能力:
- 展示 `tracking_event` 原始事件明细。
- 每条埋点展示:事件 ID、Event Key、事件名称、Scope、Scope ID、Day Key、用户 ID、作品拥有者、Profile ID、模块、metadata、发生时间。
- 支持按 Event Key、用户 ID、Scope Kind、Scope ID 筛选。
- 支持导出当前筛选结果为 Excel 可打开文件。
不做:
- 不新增或修改 SpacetimeDB 表结构。
- 不在后台写入或删除埋点。
- 不把埋点聚合口径下沉到前端计算。
## 后端契约
新增接口:
```text
GET /admin/api/tracking/events?eventKey=&userId=&scopeKind=&scopeId=&limit=
```
鉴权:复用后台 `require_admin_auth`。
返回:
```json
{
"entries": [
{
"eventId": "daily-login:user:xxx:123",
"eventKey": "daily_login",
"eventTitle": "每日登录",
"scopeKind": "user",
"scopeId": "xxx",
"dayKey": 20580,
"userId": "xxx",
"ownerUserId": null,
"profileId": null,
"moduleKey": "profile",
"metadataJson": "{}",
"occurredAt": "2026-05-07T00:00:00Z"
}
]
}
```
后端实现要点:
1. DTO 放在 `shared-contracts/src/admin.rs`,避免 Rust 与前端口径分叉。
2. Handler 放在 `api-server/src/admin.rs`,使用当前已有 SpacetimeDB HTTP SQL helper 思路。
3. SQL 只读 `tracking_event`,固定白名单列;由于 SpacetimeDB 2.2 HTTP SQL 不支持 `ORDER BY`,后端取回默认 200 / 最大 1000 条后在 API 层按 `occurred_at` 倒序排序。
4. 查询条件只通过字符串转义函数拼接,禁止直接拼接未转义用户输入。
5. `eventTitle` 由后端根据已知事件 key 映射,未知事件返回 `eventKey`。
## 前端页面
新增路由:`#tracking`,导航标题为“埋点数据”。
页面能力:
1. 顶部筛选区:Event Key、用户 ID、Scope Kind、Scope ID、刷新、导出 Excel。Event Key 候选来自后台前端的埋点定义注册表,需覆盖 `BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md` 中已接入的通用埋点事件,不能只保留 `daily_login`。
2. 列表区:移动端可横向滚动,桌面端表格展示。
3. 详情区:每行有“详情”按钮,弹出独立面板展示完整字段与格式化后的 metadata JSON。
4. 导出:导出当前页面已加载结果,文件名形如 `tracking-events-2026-05-07.xls`。
导出实现:
- 使用 HTML table + Excel MIME:`application/vnd.ms-excel;charset=utf-8`。
- 文件扩展名使用 `.xls`,Excel/WPS 可直接打开。
- 所有单元格做 HTML 转义。
- metadata 保留原始 JSON 文本,便于运营继续筛选。
## 验收命令
```bash
npm run check:encoding
npm run admin-web:typecheck
cargo test -p shared-contracts -p api-server admin_tracking -- --nocapture
```
如后端接口改动较大,再补充:
```bash
npm run api-server
curl http://127.0.0.1:<port>/healthz
```
## 实施任务
### Task 1: 补充 shared-contracts 后台埋点 DTO
**Files:**
- Modify: `server-rs/crates/shared-contracts/src/admin.rs`
**Steps:**
1. 新增 `AdminTrackingEventListQuery`。
2. 新增 `AdminTrackingEventEntryPayload`。
3. 新增 `AdminTrackingEventListResponse`。
4. 为 DTO 添加中文注释。
### Task 2: 增加后端后台埋点查询接口
**Files:**
- Modify: `server-rs/crates/api-server/src/admin.rs`
- Modify: `server-rs/crates/api-server/src/app.rs`
**Steps:**
1. 在 `admin.rs` 新增 query 解析与 SQL 构造。
2. 复用 SpacetimeDB HTTP SQL 调用风格读取 rows。
3. 新增 `admin_list_tracking_events` handler。
4. 在 `app.rs` 挂载 `/admin/api/tracking/events`。
5. 添加单元测试覆盖 SQL 字符串转义、limit clamp、SQL 响应解析。
### Task 3: 增加前端 API 类型与客户端方法
**Files:**
- Modify: `apps/admin-web/src/api/adminApiTypes.ts`
- Modify: `apps/admin-web/src/api/adminApiClient.ts`
**Steps:**
1. 新增埋点 entry/list/query 类型。
2. 新增 `listAdminTrackingEvents(token, query)`。
3. 使用 `URLSearchParams` 拼接非空查询字段。
### Task 4: 新增后台埋点数据页面
**Files:**
- Create: `apps/admin-web/src/pages/AdminTrackingEventsPage.tsx`
- Modify: `apps/admin-web/src/styles/admin.css`
**Steps:**
1. 实现筛选、刷新、错误状态。
2. 实现明细表格。
3. 实现独立详情面板。
4. 实现 Excel `.xls` 导出。
5. 保持 UI 简洁,不添加说明类大段文案。
### Task 5: 接入后台路由与导航
**Files:**
- Modify: `apps/admin-web/src/app/adminRoutes.ts`
- Modify: `apps/admin-web/src/app/AdminShell.tsx`
- Modify: `apps/admin-web/src/app/AdminApp.tsx`
**Steps:**
1. 增加 `tracking` 路由。
2. 导航增加图标。
3. `AdminApp` 渲染新页面。
### Task 6: 验证并提交
**Steps:**
1. 运行 `npm run check:encoding`。
2. 运行 `npm run admin-web:typecheck`。
3. 运行后端相关 cargo test。
4. 修复问题后提交并推送当前分支。
@@ -96,8 +96,10 @@ export interface ApiErrorEnvelope {
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
| 读取兑换码列表 | `GET /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
| 读取后台邀请码列表 | `GET /admin/api/profile/invite-codes` | 管理员 Bearer |
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
### 4.3 前端类型命名
@@ -190,6 +192,8 @@ export interface AdminDisableProfileRedeemCodeRequest {
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
startsAt?: string | null;
expiresAt?: string | null;
metadata?: Record<string, unknown>;
}
@@ -206,13 +210,24 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string;
}
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
startsAt: string | null;
expiresAt: string | null;
status: 'pending' | 'active' | 'expired';
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
}
```
### 4.4 登录 contract
@@ -284,6 +299,31 @@ export interface ProfileInviteCodeAdminResponse {
### 4.7 兑换码管理 contract
列表请求:
`GET /admin/api/profile/redeem-codes`
成功返回:
```json
{
"entries": [
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"globalUsedCount": 0,
"enabled": true,
"allowedUserIds": [],
"createdBy": "admin:root",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
创建/更新请求:
```json
@@ -300,8 +340,6 @@ export interface ProfileInviteCodeAdminResponse {
停用请求:
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
```json
{
"code": "WELCOME2026"
@@ -325,15 +363,46 @@ export interface ProfileInviteCodeAdminResponse {
}
```
兑换码管理页进入时必须通过 `GET /admin/api/profile/redeem-codes` 加载数据库已有记录。最近一次接口返回记录仍由 `AdminApp` 维护为管理端会话态,用于展示当前操作结果;历史列表不得依赖该会话态,刷新页面后必须从后端列表接口恢复。列表项点击后回填表单,继续通过同一个 `POST /admin/api/profile/redeem-codes` 修改原记录。
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
### 4.8 邀请码管理 contract
列表请求:
`GET /admin/api/profile/invite-codes`
成功返回:
```json
{
"entries": [
{
"userId": "admin:root:SPRING2026",
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"status": "active",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
后台邀请码列表只返回后台运营预置码。后端按 `profile_invite_code.user_id` 的 `admin:` 前缀过滤,普通用户在邀请中心生成的个人邀请码不得展示在后台列表中。
创建/更新请求:
```json
{
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"metadata": {
"batch": "spring"
}
@@ -346,6 +415,9 @@ export interface ProfileInviteCodeAdminResponse {
{
"userId": "admin",
"inviteCode": "SPRING2026",
"startsAt": "2026-05-01T00:00:00Z",
"expiresAt": "2026-06-01T00:00:00Z",
"status": "active",
"metadata": {
"batch": "spring"
},
@@ -356,6 +428,118 @@ export interface ProfileInviteCodeAdminResponse {
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。
#### 4.8.1 邀请码有效期语义
邀请码仍然是“用户稳定邀请身份码”,不做删除或软删除。本轮只增加时间窗字段,用于控制**新填写邀请码**是否允许绑定:
1. `startsAt` / 后端 `starts_at`:邀请码开始生效时间;为空表示立即生效。
2. `expiresAt` / 后端 `expires_at`:邀请码截止时间;为空表示长期有效。
3. 两个字段都为空时,邀请码视为长期有效。
4. `expiresAt` 采用左闭右开语义:当前时间 `>= expiresAt` 时视为已过期。
5. 时间字段在管理 API JSON 中统一使用 ISO 8601 UTC 字符串或 `null`;SpacetimeDB 内部仍按 `Timestamp` 存储,契约层负责转换,前端不得自行假设微秒/毫秒整数。
6. 有效期只影响用户之后调用填写邀请码接口建立新邀请关系;已绑定的邀请关系、历史奖励、统计和审计记录不回溯修改。
字段合法性要求:
1. `startsAt` 和 `expiresAt` 均允许为空。
2. 若两者都存在,必须满足 `startsAt < expiresAt`;相等或开始晚于截止应由后端拒绝,前端可提前提示但不能替代后端校验。
3. 后台编辑已有邀请码时,空值代表清空该边界;不要用空字符串写入契约。
#### 4.8.2 用户填写邀请码的错误优先级与校验逻辑
填写邀请码时,后端是唯一业务真相。前端只展示后端错误,不复制完整业务规则。推荐校验优先级如下:
1. **请求身份与输入基础校验**:未登录、空邀请码、格式不合法等请求级错误优先返回。
2. **用户自身状态校验**:用户不存在、用户资料不可用、已绑定过邀请关系等与当前用户直接相关的错误优先于邀请码时间窗。
3. **邀请码查找**:按标准化后的邀请码查找记录;不存在时返回“邀请码不存在或不可用”。
4. **自邀请校验**:邀请码归属用户等于当前用户时,返回“不能填写自己的邀请码”。
5. **时间窗校验**:
- `starts_at` 存在且当前时间 `< starts_at`,返回“邀请码未生效”。
- `expires_at` 存在且当前时间 `>= expires_at`,返回“邀请码已过期”。
6. **绑定写入与奖励发放**:只有以上校验全部通过,才写入邀请绑定、奖励或相关流水。
该顺序的目标是避免用“未生效/已过期”泄露不该暴露的用户状态,同时保证用户看到的错误与实际阻断原因一致。若后续新增风控、封禁、黑名单等规则,应在写入前补入,并在本节同步明确优先级。
#### 4.8.3 后台邀请码列表状态展示规则
后台列表状态可由后端返回 `status`,也可在前端用同一规则从 `startsAt` / `expiresAt` 派生;如果两者同时存在,列表展示以后端 `status` 为准,并仅把前端派生结果用于兜底。
| 条件 | 状态值 | 中文标签 | 展示建议 |
| --- | --- | --- | --- |
| `startsAt` 存在且当前时间 `< startsAt` | `pending` | 未生效 | 展示开始时间,提示尚不能被新用户填写 |
| `expiresAt` 存在且当前时间 `>= expiresAt` | `expired` | 已过期 | 展示截止时间,提示不再允许新绑定 |
| 其他情况 | `active` | 有效 | 正常高亮展示 |
补充展示规则:
1. 两个字段都为空时状态为 `active`,中文可展示为“长期有效”。
2. `startsAt` 为空、`expiresAt` 未来存在时状态为 `active`,中文可展示为“有效至 YYYY-MM-DD HH:mm”。
3. `startsAt` 未来、`expiresAt` 为空时状态为 `pending`,中文可展示为“YYYY-MM-DD HH:mm 生效”。
4. 列表至少展示邀请码、状态、开始时间、截止时间、更新时间;metadata 可保留折叠/摘要展示,避免挤占移动端宽度。
5. 列表状态只用于运营理解,不作为安全边界;真正是否可填写仍以后端 redeem 校验为准。
### 4.9 后台写操作二次确认规范
后台所有会修改线上数据的操作,在真正调用 API 前必须二次确认;取消确认时不得发送任何请求。该规范覆盖当前和未来新增的管理写入口,不限于 profile 模块。
必须二次确认的操作包括但不限于:
1. 创建/更新兑换码:`POST /admin/api/profile/redeem-codes`。
2. 停用兑换码:`POST /admin/api/profile/redeem-codes/disable`。
3. 创建/更新邀请码:`POST /admin/api/profile/invite-codes`。
4. 创建/更新个人任务配置:`POST /admin/api/profile/tasks`。
5. 停用个人任务配置:`POST /admin/api/profile/tasks/disable`。
6. 后续任何 `POST` / `PATCH` / `PUT` / `DELETE` 管理接口,只要会修改数据、触发任务、写审计或影响线上配置,均默认纳入确认。
交互要求:
1. 确认弹窗必须在 API 调用前出现,确认后才进入 loading 和提交状态。
2. 弹窗必须展示操作类型(新增、更新、停用、删除、发布等)、对象标识(如 `code`、`inviteCode`、`taskId`)和影响说明。
3. 默认按钮顺序为“取消 / 确认”,取消不应有危险色;危险操作(停用、删除、覆盖线上配置)确认按钮使用警示样式。
4. 弹窗文案统一提示“该操作会立即影响线上数据”,但不要在页面常驻展示大段规则说明。
5. 支持键盘和移动端:Esc 或取消按钮关闭;移动端弹窗宽度自适应,不遮挡关键对象信息。
6. loading 期间锁定确认按钮和原页面提交按钮,避免重复写入。
7. 成功后按现有页面规则刷新列表或合并返回记录;失败时展示后端错误,不能静默关闭为成功。
建议抽象通用确认能力,例如 `confirmAdminWriteAction({ actionLabel, targetLabel, riskLevel, onConfirm })` 或通用 `AdminConfirmDialog`,页面只传入对象与回调,避免每个页面重复实现不同交互。
#### 4.9.1 二次确认文案模板
```text
标题:确认{操作类型}{对象类型}
正文:即将{操作类型}「{对象标识}」。该操作会立即影响线上数据。
取消按钮:取消
确认按钮:确认{操作类型}
```
示例:
1. `确认更新邀请码`:即将更新「SPRING2026」的有效期与 metadata。该操作会立即影响线上数据。
2. `确认停用兑换码`:即将停用「WELCOME2026」。该操作会立即影响线上数据。
3. `确认更新任务配置`:即将更新「daily_login」。该操作会立即影响线上数据。
### 4.10 邀请码有效期与二次确认改动范围
实现本设计时预期改动范围如下,未列出的层级不要擅自承接业务规则:
1. `server-rs/crates/spacetime-module/src/runtime/profile.rs`:邀请码表结构、upsert、redeem 时间窗校验与后台列表投影。
2. `server-rs/crates/spacetime-module/src/migration.rs`:旧邀请码记录迁移,默认 `starts_at = None`、`expires_at = None`。
3. `server-rs/crates/shared-contracts/src/**`:管理请求/响应 DTO 增加 `startsAt`、`expiresAt`、`status` 等字段。
4. `server-rs/crates/spacetime-client/src/module_bindings/**` 与 mapper:按表结构变更重新生成/补齐绑定字段。
5. `server-rs/crates/api-server/src/runtime_profile.rs`:接收、校验、转发并返回邀请码时间窗字段;保持错误 envelope 兼容后台读取逻辑。
6. `apps/admin-web/src/api/adminApiTypes.ts` 与 `adminApiClient.ts`:同步契约字段,不在 client 层写业务判断。
7. `apps/admin-web/src/pages/AdminInviteCodePage.tsx`:有效期表单、列表状态展示、保存前确认。
8. `apps/admin-web/src/pages/AdminRedeemCodePage.tsx`、`AdminTaskConfigPage.tsx` 及后续写页面:统一接入写操作二次确认。
9. `apps/admin-web/src/styles/admin.css`:状态标签、确认弹窗与移动端样式。
验证建议:
1. 服务端单测覆盖:未生效邀请码拒绝、已过期邀请码拒绝、有效时间窗可绑定、空时间窗长期有效、已绑定关系不受后续过期影响。
2. 管理 API 覆盖:upsert 能写入/清空 `startsAt`、`expiresAt`;列表返回状态正确;`startsAt >= expiresAt` 被拒绝。
3. 前端交互覆盖:点击保存/停用不会直接请求,取消确认不请求,确认后只请求一次,失败展示后端错误。
4. 回归兑换码与任务配置页面,确认所有写操作均有统一二次确认。
5. 修改后端时按项目约束运行对应 Rust 测试、`npm run api-server` 联调和 `/healthz`;修改前端时运行 `npm run admin-web:typecheck`、`npm run admin-web:build`;文档或中文改动后运行 `npm run check:encoding`。
## 5. 鉴权与会话
1. token key 固定为 `genarrative_admin_token`。
@@ -372,19 +556,22 @@ export interface ProfileInviteCodeAdminResponse {
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`。
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`。
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata,不在前端复制后端邀请码规则。
7. 所有按钮的 loading 状态必须锁定重复提交。
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
6. 兑换码页和邀请码页进入时加载数据库列表,保存后合并返回记录,点击列表项回填表单进入编辑态。
7. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata,不在前端复制后端邀请码规则。
8. 所有按钮的 loading 状态必须锁定重复提交。
9. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
## 7. 部署与联调
### 7.1 本地联调
1. 启动后端:`npm run api-server`。
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`。
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到 `ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`。
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server。
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口。
1. 完整本地栈直接在仓库根目录执行 `npm run dev`。
2. `npm run dev` 默认启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 和后台 Vite。
3. 主站默认地址为 `http://127.0.0.1:3000`,后台可从主站 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`。
4. 主站 Vite 会把 `/admin/` 转发到后台 dev server,贴近生产同域 `/admin/` 入口。
5. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到当前 Rust API 地址;`--api-port` 改动时脚本会同步注入 `ADMIN_API_TARGET`。
6. 如需单独启动后台前端,可继续执行根脚本 `npm run admin-web:dev`,或在 `apps/admin-web` 执行 `npm run dev`;单独启动时未配置 `ADMIN_API_TARGET` 会默认代理到 `http://127.0.0.1:3100`。
7. 后台 dev 端口可用 `npm run dev -- --admin-web-port <port>` 覆盖。
### 7.2 构建部署
@@ -430,8 +617,8 @@ export interface ProfileInviteCodeAdminResponse {
- token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
- 兑换码 public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
- 兑换码数据库列表加载、列表点击回填、public/unique/private 表单提交和停用。
- 邀请码数据库列表加载、普通用户邀请码不展示、列表点击回填、metadata JSON 对象校验和结果展示。
2. 根工程:
- `npm run check:encoding`。
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
@@ -0,0 +1,69 @@
# 短信验证码阿里云时间戳格式修复(2026-05-07)
## 背景
使用阿里云短信验证码真实 provider 发送验证码时,接口返回:
```text
短信验证码发送失败:Specified time stamp or date value is not well formatted.
```
该错误来自阿里云 OpenAPI 网关对签名请求头 `x-acs-date` 的格式校验。
## 根因
`server-rs/crates/platform-auth/src/lib.rs` 中阿里云 ACS3 签名逻辑会构造 `x-acs-date` 请求头。
原实现使用 `time::format_description::well_known::Rfc3339`,当 `OffsetDateTime::now_utc()` 带纳秒时会生成形如:
```text
2026-05-07T14:23:59.364767Z
```
阿里云 ACS3 签名要求 `x-acs-date` 使用不带小数秒的 UTC ISO 8601 格式:
```text
yyyy-MM-dd'T'HH:mm:ss'Z'
```
即:
```text
2026-05-07T14:23:59Z
```
带小数秒的时间戳会被阿里云网关判定为格式非法,从而返回 `Specified time stamp or date value is not well formatted.`。
## 修复方案
将 `current_aliyun_timestamp()` 改为手动输出不带小数秒的 UTC ISO 8601 格式:
```text
yyyy-MM-dd'T'HH:mm:ss'Z'
```
并新增单元测试,确保:
- 长度等于 `2026-05-07T12:34:56Z`;
- 固定位置包含 `-`、`T`、`:`、`Z`;
- 不包含小数点;
- 除固定分隔符外均为数字。
## 影响范围
- 仅影响阿里云短信验证码 provider 的请求签名头 `x-acs-date`。
- 不改动短信模板、签名、验证码业务参数。
- 不改动 mock 短信 provider。
- 不涉及前端接口契约变化。
## 验收
执行:
```bash
cd server-rs
cargo test -p platform-auth aliyun -- --nocapture
cargo fmt -p platform-auth --check
```
预期:相关测试通过,格式检查通过。
@@ -0,0 +1,367 @@
# Analytics Date Dimension 与个人任务埋点范围收口记录(2026-05-04)
## 背景
本记录用于收口 `.hermes/plans/2026-05-04_022223-analytics-time-dimension-mapping.md` 中当前阶段已经落地的内容,并明确尚未执行的后续范围。
本阶段目标不是完整上线运营统计查询,而是先完成两件基础工作:
1. 收紧个人任务系统的埋点范围,避免运营或接口把个人任务错误配置为 `site`、`module`、`work` 等非用户维度。
2. 新增统一日期维表 `analytics_date_dimension`,为后续按周、月、季、年聚合埋点数据提供稳定的日期 bucket 映射。
## 当前已完成范围
### 1. 个人任务埋点范围锁定为 User
当前个人任务系统首版只支持用户维度埋点。
已完成:
- Admin 任务配置页不再展示“埋点范围”选择。
- Admin 保存任务配置时固定传 `scopeKind: 'user'`。
- API 层拒绝非 `user` 的个人任务配置。
- 领域输入构造层拒绝非 `User` 的个人任务配置。
- `Work => user_id` 的错误映射已移除。
- 任务进度刷新、任务中心快照、领奖链路遇到非 `User` 的异常个人任务配置时显式报错,不再静默按 0 进度处理。
相关文件:
```text
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
apps/admin-web/src/api/adminApiTypes.ts
server-rs/crates/api-server/src/runtime_profile.rs
server-rs/crates/module-runtime/src/commands.rs
server-rs/crates/module-runtime/src/errors.rs
server-rs/crates/spacetime-module/src/runtime/profile.rs
```
### 2. 日期维表领域模型与纯函数
已在 `module-runtime` 中补充日期维表快照和纯函数。
日期维表使用现有北京时间业务日 `day_key` 语义:
```text
date_key = floor((occurred_at_micros + 8h) / 1d)
```
已完成能力:
- 从 `YYYY-MM-DD` 解析业务日 `date_key`。
- 从 `date_key` 构造日期维表快照。
- 生成 ISO weekday:周一=1,周日=7。
- 生成 ISO week key:`YYYYWW`,跨年周按 ISO week-year。
- 生成 week/month/quarter/year 的 key 和起止 `date_key`。
- 限制日期维表支持范围为:
- `2000-01-01`
- 到 `2100-12-31`
相关文件:
```text
server-rs/crates/module-runtime/src/domain.rs
server-rs/crates/module-runtime/src/application.rs
server-rs/crates/module-runtime/src/lib.rs
```
### 3. SpacetimeDB 日期维表与 reducer
已新增 SpacetimeDB 表:
```text
analytics_date_dimension
```
表字段包括:
```text
date_key
calendar_date
weekday
iso_week_key
week_start_date_key
week_end_date_key
month_key
month_start_date_key
month_end_date_key
quarter_key
quarter_start_date_key
quarter_end_date_key
year_key
year_start_date_key
year_end_date_key
created_at
updated_at
```
已新增索引:
```text
iso_week_key
month_key
quarter_key
year_key
```
已新增 reducer:
```text
ensure_analytics_date_dimension_for_date
seed_analytics_date_dimensions
```
当前 reducer 行为:
- `ensure` 单日幂等补齐。
- `seed` 按日期范围幂等补齐。
- `seed` 拒绝 `start_date > end_date`。
- `seed` 单次最多允许 `ANALYTICS_DATE_DIMENSION_MAX_SEED_DAYS = 3660` 天。
- 裸 `date_key` 进入 ensure 前先做支持范围校验,避免极端整数进入日历算法。
相关文件:
```text
server-rs/crates/spacetime-module/src/runtime/analytics_date_dimension.rs
server-rs/crates/spacetime-module/src/runtime/mod.rs
server-rs/crates/spacetime-module/src/migration.rs
docs/technical/SPACETIMEDB_TABLE_CATALOG.md
```
### 4. SpacetimeDB Rust client bindings
已按项目脚本生成 Rust bindings,并在生成参数中显式包含 private tables/functions:
```bash
PATH="/tmp/spacetime-bin:$PATH" npm run spacetime:generate -- --rust-only
```
本次已修改生成脚本:
```text
scripts/generate-spacetime-bindings.mjs
```
在 `spacetime generate` 参数中加入:
```text
--include-private
```
说明:SpacetimeDB CLI 2.1.0 的参数名是 `--include-private`,不是 `--non-private`。该参数含义是将 private tables/functions 也包含进生成代码,满足 api-server 通过 Rust bindings 访问 module private table/reducer 的需求。
```text
spacetimedb tool version 2.1.0; spacetimedb-lib version 2.1.0
```
生成脚本:
```text
scripts/generate-spacetime-bindings.mjs
```
已新增 analytics date dimension 相关 bindings:
```text
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_ensure_input_type.rs
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_seed_input_type.rs
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_type.rs
server-rs/crates/spacetime-client/src/module_bindings/analytics_date_dimension_table.rs
server-rs/crates/spacetime-client/src/module_bindings/ensure_analytics_date_dimension_for_date_reducer.rs
server-rs/crates/spacetime-client/src/module_bindings/seed_analytics_date_dimensions_reducer.rs
```
并更新了:
```text
server-rs/crates/spacetime-client/src/module_bindings/mod.rs
```
注意:
- `analytics_date_dimension` 表当前是 private table;由于生成脚本已加 `--include-private`,本次 codegen 已生成 `analytics_date_dimension_table.rs`,可通过 `ctx.db.analytics_date_dimension()` 访问 client cache / query builder。
- bindings 目录是自动生成产物,本次以项目脚本整体刷新,除新增 analytics 文件外,也带来了大量已存在 table/reducer/procedure 文件的格式化/生成器输出差异。
### 5. 测试覆盖
已新增测试:
```text
server-rs/crates/module-runtime/tests/analytics_date_dimension.rs
server-rs/crates/module-runtime/tests/profile_task_scope.rs
server-rs/crates/shared-contracts/tests/profile_task_contract.rs
```
覆盖重点:
- `2024-02-29` 闰年。
- `2025-12-29` ISO week 跨年。
- `2026-01-01` 跨年周。
- `2026-03-31` Q1 结束。
- `2026-04-01` Q2 开始。
- `2026-12-31` 年末。
- 非法日期解析失败。
- 超出日期维表支持范围失败。
- 个人任务 `scopeKind=user` 成功。
- 个人任务 `scopeKind=site/module/work` 失败。
- `work` scope 不会静默映射到 `user_id`。
- Admin 个人任务配置 contract 保持 `scopeKind: user`。
## 已验证命令
从 `server-rs/` 执行:
```bash
cargo fmt -p module-runtime -p spacetime-module -p spacetime-client
cargo test -p spacetime-client --no-run
cargo test -p spacetime-module --no-run
cargo test -p module-runtime --test analytics_date_dimension
cargo test -p module-runtime --test profile_task_scope
cargo test -p shared-contracts --test profile_task_contract
```
从项目根目录执行:
```bash
npm run admin-web:typecheck
```
当前结果:
- `spacetime-client --no-run` 编译通过。
- `spacetime-module --no-run` 编译通过。
- `analytics_date_dimension` 测试通过:8 passed。
- `profile_task_scope` 测试通过:3 passed。
- `profile_task_contract` 测试通过:2 passed。
- `admin-web:typecheck` 通过。
已知非本阶段阻塞:
- 完整运行 `cargo test -p spacetime-module` 时,曾出现既有 puzzle 测试失败:
```text
puzzle::tests::puzzle_preview_is_publishable_with_complete_draft FAILED
assertion failed: preview.publish_ready
```
该失败与当前埋点范围和日期维表改动无直接关系,本阶段以 `cargo test -p spacetime-module --no-run` 作为编译门禁。
## 当前未完成 / 暂缓项
### 1. 暂未新增 spacetime-client facade
当前没有新增:
```text
SpacetimeClient::ensure_analytics_date_dimension_for_date
SpacetimeClient::seed_analytics_date_dimensions
```
原因:
- 生成脚本已加入 `--include-private`,private reducer/type/table bindings 已可用于后续 facade 实现。
- 但 Step 7/8/9 暂缓,尚未由 `api-server` 或统计查询链路调用该能力。
- 如后续只是 SpacetimeDB module 内部写入统计时 ensure,可以直接复用 module 内部 helper,不一定需要远程 client facade。
- 若后续需要由 API 或运维接口触发 seed/ensure,可基于本次已生成的 reducer bindings 再补 facade。
### 2. Step 7/8/9 暂缓
本阶段未接入:
- 事件写入链路自动 ensure 日期维表。
- 聚合查询 API 的 `granularity = day | week | month | quarter | year`。
- shared contracts / 前端 analytics contracts。
- 历史事件回填。
这些应作为后续阶段单独设计和落地。
## 后续建议顺序
1. 如需提交本阶段改动,确认是否接受 `module_bindings` 整体刷新带来的大量生成文件 diff。
2. 如希望 diff 更小,可评估仅提交 analytics date dimension 相关生成文件与 `mod.rs`;但需要非常谨慎,因为 `module_bindings` 是自动生成产物。
3. 如需要由 `api-server` 触发 seed/ensure,再补 `spacetime-client` facade。
4. 进入 Step 7/8/9:事件写入链路、聚合查询 API、前端 contracts。
## Step 7/8/9 后续接入记录(2026-05-04)
本次继续推进此前暂缓的 Step 7/8/9 中“按日期维度聚合查询 API / contracts / client facade”部分。
### 已新增能力
1. `module-runtime` 新增 analytics metric 聚合领域类型与纯函数:
- `AnalyticsGranularity = day | week | month | quarter | year`
- `AnalyticsMetricQueryInput`
- `AnalyticsBucketMetric`
- `AnalyticsMetricQueryResponse`
- `aggregate_runtime_tracking_daily_stats(...)`
2. `spacetime-module` 新增 `query_analytics_metric` procedure,直接聚合 tracking daily stat,输出按 bucket 排序的统计结果。
3. `spacetime-client` 新增 facade:
```rust
SpacetimeClient::query_analytics_metric(event_key, scope_kind, scope_id, granularity)
```
4. `api-server` 新增登录态接口:
```http
GET /api/profile/analytics/metric?eventKey=...&scopeKind=user&scopeId=...&granularity=day
```
请求参数:
| 参数 | 说明 |
| --- | --- |
| `eventKey` | 埋点事件 key,必填 |
| `scopeKind` | `site | work | module | user` |
| `scopeId` | 对应范围 ID,必填 |
| `granularity` | `day | week | month | quarter | year` |
响应 data:
```ts
type AnalyticsMetricQueryResponse = {
buckets: Array<{
bucketKey: string;
bucketStartDateKey: number;
bucketEndDateKey: number;
value: number;
}>;
};
```
5. shared contracts / 前端 shared contracts 已新增 analytics query 类型:
- `AnalyticsMetricQueryRequest`
- `AnalyticsMetricQueryResponse`
- `AnalyticsBucketMetricResponse` / `AnalyticsBucketMetric`
- `AnalyticsGranularity`
### 本次验证
从 `server-rs/` 执行通过:
```bash
cargo test -p module-runtime --test analytics_granularity
cargo check -p spacetime-module
cargo check -p spacetime-client
cargo check -p api-server
```
验证结果:
- `analytics_granularity` 测试通过:3 passed。
- `spacetime-module` 编译通过,仅存在既有 dead_code warnings。
- `spacetime-client` 编译通过。
- `api-server` 编译通过,仅存在既有 prompt dead_code warnings。
### 注意事项
当前环境未检测到 `spacetime` / `spacetimedb` CLI,因此 analytics metric 相关 `module_bindings` 是按现有生成物结构手动补齐的临时生成物。后续有 CLI 的开发机应优先通过项目脚本重新生成 bindings,并复核手写生成物是否可被正式生成输出覆盖。
---
## 阶段结论
当前阶段已经完成“个人任务埋点范围收紧”和“日期维表 module 侧能力”的核心落地,并已生成 SpacetimeDB Rust client bindings。
剩余工作不再是 bindings 环境阻塞,而是后续业务接入范围:是否增加 `spacetime-client` facade,以及是否继续推进事件写入链路、聚合查询 API 和前端 analytics contracts。
@@ -0,0 +1,118 @@
# api-server 外部服务环境变量配置 2026-05-07
## 背景
`server-rs/crates/api-server/src/config.rs` 统一收口 api-server 启动配置。外部服务分为两类:
1. 公共服务:阿里云、腾讯云、微信等对外公开且接口域名稳定的服务。
2. 非公共服务:团队自选模型网关、图片网关、视频模型、内部兼容服务等,URL 与模型名可能随部署、供应商或账号策略变化。
本次约定:公共服务 URL 可以保留代码默认值;非公共服务的 URL 与模型名必须通过环境变量提供,不再在 `config.rs` 写死具体模型名称或私有网关地址。
## 公共服务默认值
以下默认值属于公共服务稳定接口,可继续保留在代码或示例环境中:
```text
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
WECHAT_AUTHORIZE_ENDPOINT=https://open.weixin.qq.com/connect/qrconnect
WECHAT_ACCESS_TOKEN_ENDPOINT=https://api.weixin.qq.com/sns/oauth2/access_token
WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo
```
说明:DashScope 属于阿里云公开服务,基础 URL 可保留;具体图片模型名不属于稳定公共接口,必须由环境变量配置。
## 非公共服务必配项
生产环境或真实联调使用到对应能力时,应显式配置以下变量:
```text
# 文本 LLM 网关
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
# APIMart / OpenAI 兼容 Responses 文本网关
APIMART_BASE_URL=
APIMART_API_KEY=
# VectorEngine / GPT-image-2 / Suno / Vidu 生成网关
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai
VECTOR_ENGINE_API_KEY=
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000
# Hyper3D Rodin Gen-2 3D 模型生成
HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2
HYPER3D_API_KEY=
HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000
# 火山引擎豆包语音 ASR / TTS
VOLCENGINE_SPEECH_API_KEY=
VOLCENGINE_SPEECH_APP_ID=
VOLCENGINE_SPEECH_ACCESS_KEY=
VOLCENGINE_SPEECH_ASR_RESOURCE_ID=volc.seedasr.sauc.concurrent
VOLCENGINE_SPEECH_TTS_RESOURCE_ID=seed-tts-2.0
VOLCENGINE_SPEECH_REQUEST_TIMEOUT_MS=180000
VOLCENGINE_SPEECH_ASR_WS_URL=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async
VOLCENGINE_SPEECH_TTS_BIDIRECTION_WS_URL=wss://openspeech.bytedance.com/api/v3/tts/bidirection
VOLCENGINE_SPEECH_TTS_SSE_URL=https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse
# DashScope 图片模型名
DASHSCOPE_SCENE_IMAGE_MODEL=
DASHSCOPE_REFERENCE_IMAGE_MODEL=
DASHSCOPE_COVER_IMAGE_MODEL=
# Ark / 角色视频模型网关
ARK_CHARACTER_VIDEO_BASE_URL=
ARK_CHARACTER_VIDEO_API_KEY=
ARK_CHARACTER_VIDEO_MODEL=
ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS=420000
```
## 兼容变量
为降低部署切换成本,当前代码仍兼容部分历史变量:
```text
GENARRATIVE_LLM_BASE_URL / LLM_BASE_URL
GENARRATIVE_LLM_MODEL / LLM_MODEL / VITE_LLM_MODEL
GENARRATIVE_LLM_API_KEY / LLM_API_KEY / ARK_API_KEY
DASHSCOPE_SCENE_IMAGE_MODEL / DASHSCOPE_IMAGE_MODEL
DASHSCOPE_REFERENCE_IMAGE_MODEL / DASHSCOPE_IMAGE_EDIT_MODEL
DASHSCOPE_COVER_IMAGE_MODEL / DASHSCOPE_IMAGE_MODEL
ARK_CHARACTER_VIDEO_BASE_URL / ARK_BASE_URL / GENARRATIVE_LLM_BASE_URL / LLM_BASE_URL
ARK_CHARACTER_VIDEO_API_KEY / ARK_API_KEY / GENARRATIVE_LLM_API_KEY / LLM_API_KEY
ARK_CHARACTER_VIDEO_MODEL / DASHSCOPE_CHARACTER_VIDEO_MODEL
VOLCENGINE_SPEECH_API_KEY / VOLCENGINE_API_KEY
VOLCENGINE_SPEECH_APP_ID / VOLCENGINE_ACCESS_KEY_ID
VOLCENGINE_SPEECH_ACCESS_KEY / VOLCENGINE_SECRET_ACCESS_KEY
HYPER3D_BASE_URL / RODIN_BASE_URL
HYPER3D_API_KEY / RODIN_API_KEY
HYPER3D_MODEL_REQUEST_TIMEOUT_MS / RODIN_MODEL_REQUEST_TIMEOUT_MS
```
## 运行时行为
1. `AppConfig::default()` 不再包含具体非公共模型名或私有网关 URL。
2. `AppConfig::from_env()` 会从环境变量读取非公共模型名和 URL。
3. 文本 LLM provider 为 `ark` 且未配置 `GENARRATIVE_LLM_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
4. 角色视频 provider 复用 Ark 且未配置 `ARK_CHARACTER_VIDEO_BASE_URL` 时,仍回退到 Ark 公开基础 URL。
5. 具体模型名缺失时不在配置层伪造默认模型,调用到对应能力时由下游配置校验返回缺配置错误。
6. VectorEngine 图片与音频生成只读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY`,其中 GPT-image-2 图片生成额外读取 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;不复用 `APIMART_*`、`GENARRATIVE_LLM_*` 或前端变量。
7. 火山引擎语音能力由 `platform-speech` 收口协议帧与上游鉴权,`api-server` 只暴露平台鉴权后的代理路由,不向前端返回任何密钥字段。
8. Hyper3D Rodin Gen-2 使用公开默认 `https://api.hyper3d.com/api/v2`,API Key 只读取 `HYPER3D_API_KEY` / `RODIN_API_KEY`,不复用文本 LLM、图片或音频网关密钥。
9. APIMart 当前只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态理解链路;GPT-image-2 图片生成不得再读取 APIMart 配置。
10. 本地 `npm run api-server`、`npm run dev:rust` 与 `npm run dev` 的环境文件优先级固定为外层 shell 变量最高,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖;真实密钥建议放在 `.env.secrets.local`,防止 `.env` 中的空示例值覆盖私密配置。
## 示例文件
生产示例环境变量维护在:
```text
deploy/env/api-server.env.example
```
真实密钥、内部网关 URL 和具体模型名只应写入服务器 `/etc/genarrative/api-server.env` 或本地未提交的 `.env.local` / `.env.secrets.local`,不得提交到仓库。
@@ -49,7 +49,7 @@ npm run build
npm run check:content
```
后端代码变更后,按项目约束还需要用 `npm run api-server:maincloud` 做一次启动验证。
后端代码变更后,按项目约束还需要用 `npm run api-server` 做一次启动验证。
本轮最终结果:
@@ -58,7 +58,7 @@ npm run check:content
- `cargo test --manifest-path server-rs\Cargo.toml` 已通过,结果同 `api-server` 默认测试。
- `npm test` 已通过,结果为 `160 passed` 个测试文件、`704 passed` 个用例。
- `npm run typecheck`、`npm run build`、`npm run check:content`、`npm run check:encoding`、`git diff --check` 已通过。
- `npm run api-server:maincloud` 已完成启动烟测,`/healthz` 返回 `200`;期间 Maincloud 订阅恢复出现 `503` warning,但未阻止服务启动。
- `npm run api-server` 已完成启动烟测,`/healthz` 返回 `200`;期间 Maincloud 订阅恢复出现 `503` warning,但未阻止服务启动。
仍需单独处理的非本轮阻塞:
@@ -0,0 +1,37 @@
# `AuthGate` 登录后又回到未登录状态修复
日期:`2026-05-09`
## 背景
本地联调中,手机号验证码登录有时会先显示登录成功,随后又瞬间回到未登录态。
## 根因
`AuthGate` 首次挂载时会异步 hydrate:
1. 先轮换 refresh cookie
2. 再请求 `/api/auth/me`
3. 再根据结果写入 `user` 和 `status`
如果用户在这轮 hydrate 尚未完成时已经完成了登录,后到达的旧 hydrate 结果仍可能把刚写入的 `user` 覆盖回 `null`,导致登录态闪回未登录。
## 修复
`AuthGate` 增加 hydrate 版本号保护:
1. 每次启动 hydrate 都分配独立版本号。
2. 登录成功、退出登录、收到全局 auth state 事件时递增版本号。
3. 旧版本 hydrate 的结果到达后直接丢弃,不再覆盖当前 `user` / `status`。
## 验证
1. `npm run test -- src/components/auth/AuthGate.test.tsx`
2. `npm run test -- src/services/apiClient.test.ts src/services/authService.test.ts`
3. `npm run check:encoding`
## 关联
- `src/components/auth/AuthGate.tsx`
- `src/components/auth/AuthGate.test.tsx`
- `.hermes/shared-memory/pitfalls.md`
@@ -115,3 +115,41 @@
2. 该失败只代表登录方式配置探测失败,不代表登录功能不可用,因此不把 `读取登录方式失败` 写入登录弹窗错误条。
3. 登录弹窗仍展示密码登录表单,玩家可继续登录后进入创作链路。
4. 本地仍需要启动 `api-server`,否则后续 `POST /api/auth/entry` 等真实登录请求无法完成。
## 9. 2026-05-07 本地短信入口恢复记录
如果登录弹窗里短信登录页签“像是被删了”,先不要改前端表单,优先检查本地登录方式探测结果:
1. 仓库根目录 `.env.local` 里必须显式保留 `SMS_AUTH_ENABLED=true`。
2. 本地启动请优先使用 `npm run api-server`、`npm run dev:rust` 或 `npm run dev`,这些脚本会按“shell 环境优先、`.env.local` 覆盖 `.env`”合并配置。
3. 若 `GET /api/auth/login-options` 只返回 `["password"]`,说明短信入口没有被服务端配置打开,前端只是按 contract 正常降级。
4. 当 `SMS_AUTH_ENABLED=true` 生效时,`GET /api/auth/login-options` 至少应返回 `["phone", "password"]`,短信登录页签才会重新出现。
## 10. 2026-05-07 前端代理端口错配修复记录
如果 Rust API 直连返回 `["phone", "password"]`,但从前端域名请求 `GET /api/auth/login-options` 返回 `500`,短信页签同样会消失。此时不是登录 UI 被删除,而是 `AuthGate` 按第 5.3 节降级成 `["password"]`。
本地排查顺序固定为:
1. 先请求 `http://127.0.0.1:3000/api/auth/login-options`,确认前端代理是否成功返回 JSON。
2. 再请求当前 Rust API 目标,例如 `http://127.0.0.1:3100/api/auth/login-options` 或 `http://127.0.0.1:8082/api/auth/login-options`。
3. 若直连 API 成功而 3000 返回 `500`,检查 `RUST_SERVER_TARGET`、`GENARRATIVE_API_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 是否指向仍在监听的 API 端口。
4. `npm run dev` / `npm run dev:rust` 完整栈默认由脚本计算 API 端口;加载 `.env.local` 给后端使用后,脚本必须重新固定 `RUST_SERVER_TARGET`,避免 `.env.local` 中的旧代理目标覆盖本次启动的实际 API 端口。
5. `npm run dev:web` 只启动前端,不会自动拉起 Rust API;如果 `.env.local` / 当前环境已经显式声明 `GENARRATIVE_RUNTIME_SERVER_TARGET`、`RUST_SERVER_TARGET`、`GENARRATIVE_API_TARGET` 或 `GENARRATIVE_API_PORT`,脚本必须固定使用该目标。目标当下不可用时只打印警告,不自动切到另一个端口,避免前端进程长时间绑定到随后会停掉的临时 API。
6. 如果 `3000` 仍然返回 `500`,先确认浏览器是不是还开着旧的前端进程。当前脚本如果因为端口占用漂移到 `3001` / `3002`,应直接关掉旧进程后重启,而不是继续用旧的 3000 页面判断登录入口状态。
## 11. 2026-05-10 `npm run api-server` 环境加载与短信 provider 排查记录
本地单独启动 `api-server` 时,环境变量合并顺序固定为:
```text
外层 shell > .env > .env.local > .env.secrets.local
```
这保证 `.env.local` 能覆盖 `.env.example` 派生出的默认值,`.env.secrets.local` 能继续覆盖本地私密密钥配置。`scripts/api-server-dev.mjs` 不得让 `.env` 后加载并覆盖 `.env.local`,否则 `SMS_AUTH_ENABLED` 或 `SMS_AUTH_PROVIDER` 可能被压回错误值。
排查“点击获取验证码但手机收不到短信”时,除了确认 `availableLoginMethods` 包含 `phone`,还必须确认当前进程实际使用的 provider:
1. `SMS_AUTH_PROVIDER="mock"` 只用于本地 UI / 账号链路联调,不会向手机发送真实短信;此时应使用 `SMS_AUTH_MOCK_VERIFY_CODE`,默认 `123456`。
2. 真实短信链路必须使用 `SMS_AUTH_PROVIDER="aliyun"`,并在修改 `.env.local` 后重启 `api-server`,运行中的进程不会自动切换 provider。
3. 真实 provider 是否被使用,以 `api-server` 日志中的 `provider=aliyun`、`provider_request_id` 和 `provider_out_id` 为准。
@@ -0,0 +1,42 @@
# 登录恢复与推荐页加载态收口修复
日期:`2026-05-09`
## 背景
推荐页作品卡偶发一直停留在“加载中...”,同时刷新网页后可能从已登录态回到未登录态。两类问题都发生在平台入口首屏和登录态恢复链路中,表现上像推荐页问题,实际涉及 `AuthGate`、请求层 token 处理和推荐页嵌入运行态的启动状态机。
## 根因
刷新网页掉线的关键根因是 `AuthGate` hydrate 先强制调用 `refreshStoredAccessToken()`。如果 refresh cookie 临时不可用、代理错配或后端短暂返回 `401`,该方法会清掉本地 access token,随后 `/api/auth/me` 只能恢复成未登录。
推荐页作品卡卡住加载中的根因是推荐页自动启动运行态时,`activeRecommendEntryKey` 和 `activeRecommendRuntimeKind` 先被设置,失败时只把 kind 置空或由玩法内部写错误;外层没有稳定的 `activeRecommendRuntimeError` 收口。并发切换作品时,旧启动请求也可能晚到覆盖新启动状态。
## 修复
1. `refreshStoredAccessToken()` 增加 `clearOnFailure` 选项,默认保持原全局恢复语义;显式传 `false` 时 refresh 失败不会清空现有 access token。
2. `AuthGate` 已有本地 access token 时,先用 `/api/auth/me` 确认当前用户;确认成功后再后台调用 refresh 续期与写每日登录埋点。后台 refresh 失败只静默忽略,不再把已确认账号改成未登录。
3. 本地没有 access token 时,`AuthGate` 仍通过 refresh cookie 补票;该路径失败会清 token 并落到未登录,保持原有安全语义。
4. 推荐页 `selectRecommendRuntimeEntry` 增加启动请求版本号。旧请求晚到后直接丢弃,不能覆盖当前作品。
5. 推荐页运行态启动失败时统一写入 `activeRecommendRuntimeError = "作品暂时无法进入,请稍后再试。"`,并关闭 `isStartingRecommendEntry`,避免作品卡永久显示加载态。
6. 推荐页入口继续保持登录门禁。未登录用户点击推荐 Tab 只切到推荐封面并弹出登录弹窗;未登录状态下点击推荐封面再次弹出登录弹窗,不打开详情、不启动运行态。
7. `RpgEntryHomeView` 将公开作品详情入口与推荐页入口拆成 `onOpenGalleryDetail` 和 `onOpenRecommendGalleryDetail`:发现页、搜索结果和排行榜仍可按公开浏览能力打开详情;推荐页封面、推荐运行态错误重试、桌面推荐模块统一走推荐门禁入口。
## 验证
1. `npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "explicit refresh opts out|auth gate keeps a valid local token login|home recommendation"`
2. 后续完整收口仍建议执行:
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/components/auth/AuthGate.test.tsx src/services/apiClient.test.ts`
- `npm run test -- src/services/apiClient.test.ts src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
## 关联文件
1. `src/services/apiClient.ts`
2. `src/components/auth/AuthGate.tsx`
3. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
4. `src/components/rpg-entry/RpgEntryHomeView.tsx`
5. `src/components/auth/AuthGate.test.tsx`
6. `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`
7. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
@@ -67,9 +67,11 @@ HTTP status server error (503 Service Unavailable)
远端 `xushi-p4wfr` 挂起期间,抓大鹅本地体验应使用本地 SpacetimeDB:
```powershell
spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101
npm run dev:rust
$env:GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="codex-local-bootstrap-secret-20260501"
spacetime --root-dir=server-rs/.spacetimedb/local publish xushi-p4wfr --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes
Push-Location server-rs
spacetime publish xushi-p4wfr --server http://127.0.0.1:3101 --module-path crates/spacetime-module -c=on-conflict --yes
Pop-Location
```
再让 Rust API 指向本地库:
@@ -0,0 +1,164 @@
# 宝贝识物创作发布实现方案 2026-05-11
## 1. 范围
本方案对应第 2 线程:创作发布线程。
本线程落地:
1. 创作入口配置;
2. 模板表单;
3. 本地草稿生成 service;
4. 结果页;
5. 发布 payload 约束;
6. 本地 Demo 运行态;
7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。
本阶段运行态先做浏览器本地 Demo,并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。
## 2. 前端接入点
新增玩法 ID:
```text
baby-object-match
```
用户展示名:
```text
宝贝识物
```
入口文件:
1. `src/config/newWorkEntryConfig.ts`
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
新增阶段:
```text
baby-object-match-workspace
baby-object-match-generating
baby-object-match-result
baby-object-match-runtime
```
## 3. 契约
前端共享契约放在:
```text
packages/shared/src/contracts/edutainmentBabyObject.ts
```
核心字段:
1. `BabyObjectMatchDraft.templateId = "baby-object-match"`;
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`;
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`;
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2` 或 `placeholder`;
5. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`。
## 4. Service 边界
前端 service 放在:
```text
src/services/edutainment-baby-object/babyObjectMatchClient.ts
```
首版提供:
1. `createBabyObjectMatchDraft(payload)`;
2. `saveBabyObjectMatchDraft(draft)`;
3. `publishBabyObjectMatchWork(payload)`。
当前后端正式接口未在本线程扩表落地,因此 service 先走本地 Demo 存储,并把 asset 结果标记为 `placeholder`。后续后端接入时,应替换为:
```text
POST /api/creation/edutainment/baby-object-match/drafts
PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
```
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
## 5. UI 边界
工作台只展示两个必填输入和生成按钮。
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
## 6. 运行态边界
前端运行态放在:
```text
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
```
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`。
首关状态机:
1. `waiting`:礼物盒关闭,等待任意手抬起;
2. `active`:当前物品停留在屏幕中央;
3. `correct`:展示“真棒”反馈,成功次数加 1;
4. `wrong`:展示“再想一想吧”反馈,当前物品回到中央;
5. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。
动作输入:
1. 任意手完成一次 `open_palm -> grab` 抓握序列:打开礼物盒并生成当前物品;
2. 左手连续横向移动达到阈值:将当前物品送入左侧篮子;
3. 右手连续横向移动达到阈值:将当前物品送入右侧篮子。
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。选篮只使用明确 `leftHand` 或 `rightHand` 的连续横向轨迹阈值,不再通过 `wave_left_hand`、`wave_right_hand`、`wave` 等动作名触发;侧别为 `unknown` 的手部轨迹也不参与选篮,以避免多套判定误命中和连续误触发。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:`rightHand` 轨迹映射玩家左手并进入左侧篮子,`leftHand` 轨迹映射玩家右手并进入右侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
开发者调试输入:
1. `F`:映射任意手抬起,打开礼物盒并生成当前物品;
2. 鼠标左键按下并拖动:映射左手轨迹,抬起后将当前物品送入左侧篮子;
3. 鼠标右键按下并拖动:映射右手轨迹,抬起后将当前物品送入右侧篮子。
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
## 7. 发布约束
发布前必须执行:
1. 两个物品名非空;
2. 两个物品名对应的 asset 存在;
3. 标签补齐精确 `寓教于乐`;
4. `publicationStatus` 从 `draft` 变为 `published`。
发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。
## 8. 热身关衔接
`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`。
热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。
## 9. 验收命令
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
npm run check:encoding
npm run typecheck
npm run build:raw
```
若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server`、`/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。
@@ -0,0 +1,245 @@
# 后端用户行为埋点覆盖方案
更新时间:`2026-05-09`
## 1. 范围
本方案用于补齐后端可直接观测的用户行为埋点入口,统一写入 SpacetimeDB 的 `tracking_event` 与 `tracking_daily_stat`,为任务系统、运营看板与后续漏斗分析提供事实数据。
本轮明确不纳入以下范围:
- 后台管理入口:`/admin/...`
- RPG 相关入口
- 大鱼吃小鱼相关入口
- Visual Novel 相关入口
- Story 相关入口
- Combat 相关入口
上述范围后续若需要埋点,应单独定义事件口径,避免把后台运营审计或特定玩法内行为混入本轮通用用户行为埋点。
## 2. 写入链路
### 2.1 SpacetimeDB 通用 procedure
新增通用 procedure:
- `record_tracking_event_and_return(input: RuntimeTrackingEventInput)`
该入口复用既有运行态埋点写入能力:
1. 按 `occurred_at_micros` 计算北京时间业务日 `day_key`。
2. 按同一 `day_key` 幂等补齐 `analytics_date_dimension`,保证周/月/季/年聚合查询有日期 bucket 映射。
3. 写入原始事实 `tracking_event`。
4. 更新聚合投影 `tracking_daily_stat`。
5. 触发依赖事件进度的个人任务刷新。
每日登录 `daily_login` 也必须走该通用 procedure:认证链路仍保留 `record_daily_login_tracking_event_after_auth_success(...)` 作为业务语义 helper,但 helper 内部构造 `TrackingEventDraft` 后调用 `record_tracking_event_after_success(...)`,不再绕到每日登录专用 SpacetimeDB procedure。
### 2.2 spacetime-client 封装
`spacetime-client` 提供薄封装:
- `SpacetimeRuntimeClient::record_tracking_event(...)`
API Server 只依赖该 facade,不在 handler 中直接拼接 SpacetimeDB procedure 调用。
### 2.3 api-server helper 与中间件
API Server 新增统一 helper:
- `tracking::TrackingEventDraft`
- `tracking::record_tracking_event_after_success(...)`
- `tracking::record_route_tracking_event_after_success(...)`
路由级中间件 `record_api_tracking_after_success` 挂在最终响应链路上,只在最终 HTTP status 为 2xx 时写入埋点。埋点失败只写 `warn` 日志,不阻断认证、充值、发布、任务领取等主业务流程。
## 3. metadata 口径
当前通用路由埋点仅记录低敏字段:
| 字段 | 含义 |
| --- | --- |
| `route` | 请求路径,不包含 query string |
| `method` | HTTP Method |
| `status` | 最终成功响应状态码 |
| `operation` | `RequestContext` 中的操作名 |
| `asset` | 仅资产类事件写入的低敏资产/操作信息,包含 `operation`、`operationFamily`、`assetObjectId`、`assetKind`、`objectKey`、`bucket`、`contentType`、`contentLength`、`version`、`bindingId`、`entityKind`、`entityId`、`slot`、`ownerUserId`、`profileId` 等可用于定位资产事实的字段;不写签名 URL、表单签名、OSS policy、token 或完整请求体。 |
| `assetOperation` | 资产类路由兜底事件的操作 key,用于不读取请求体时仍能按操作族聚合。 |
禁止在通用埋点 metadata 中写入手机号、token、cookie、邀请码、请求体、密钥、连接串、外部凭证、OSS 签名 URL、PostObject policy 或签名表单字段。
### 3.1 作品级游玩埋点
所有已接入后端正式试玩/播放入口的作品类型统一写 `work_play_start`:
- `scope_kind = work`。
- `scope_id = 稳定作品 ID`,优先使用 `profile_id`;大鱼吃小鱼沿用 `session_id` 作为作品 ID。
- `user_id = 当前认证用户`。
- `owner_user_id = 作品作者/拥有者`,无法从入口直接确认作者时可为空,但 `metadata.userId` 仍保留当前玩家。
- `profile_id = 作品 profile_id`,大鱼吃小鱼这类 session 型作品可为空。
- `module_key = play_type`,例如 `puzzle`、`match3d`、`square-hole`、`custom-world`、`big-fish`、`visual-novel`。
- `metadata` 固定包含 `operation = work_play_start`、`playType`、`workId`、`sourceRoute`,并按入口补充 `runId`、`ownerUserId`、`profileId`、`levelId`、`mode` 等低敏字段。
该事件用于“某个作品被多少不同用户玩过”等作品级分析;权威去重统计仍建议优先使用业务投影(如 `profile_played_world`),埋点侧用于分析与漏斗联动。
## 4. 事件清单
### 4.1 认证与会话
| 事件 | 入口 |
| --- | --- |
| `auth_login_options_view` | `GET /api/auth/login-options` |
| `auth_phone_code_send` | `POST /api/auth/phone/send-code` |
| `daily_login` | 认证成功与 refresh 续期后由 `record_daily_login_tracking_event_after_auth_success(...)` 主动写入,事件 ID 按 `daily-login:{user_id}:{day_key}` 幂等 |
| `auth_phone_login_success` | `POST /api/auth/phone/login` |
| `auth_me_view` | `GET /api/auth/me` |
| `auth_sessions_view` | `GET /api/auth/sessions` |
| `auth_refresh_success` | `POST /api/auth/refresh` |
| `auth_logout` | `POST /api/auth/logout` |
| `auth_logout_all` | `POST /api/auth/logout-all` |
| `auth_wechat_bind_phone_success` | `POST /api/auth/wechat/bind-phone` |
### 4.2 个人中心、账户运营与任务
| 事件 | 入口 |
| --- | --- |
| `profile_identity_update` | `PATCH /api/profile/me` |
| `profile_dashboard_view` | `GET /api/profile/dashboard` |
| `wallet_ledger_view` | `GET /api/profile/wallet-ledger` |
| `recharge_center_view` | `GET /api/profile/recharge-center` |
| `recharge_order_create` | `POST /api/profile/recharge/orders` |
| `feedback_submit` | `POST /api/profile/feedback` |
| `invite_center_view` | `GET /api/profile/referrals/invite-center` |
| `referral_invite_code_redeem` | `POST /api/profile/referrals/redeem-code` |
| `redeem_code_submit` | `POST /api/profile/redeem-codes/redeem` |
| `task_center_view` | `GET /api/profile/tasks` |
| `task_reward_claim` | `POST /api/profile/tasks/{task_id}/claim` |
| `save_archive_list_view` | `GET /api/profile/save-archives` |
| `save_archive_detail_view` | `GET /api/profile/save-archives/{archive_id}` |
| `browse_history_view` | `GET /api/profile/browse-history` |
| `browse_history_record` | `POST /api/profile/browse-history` |
| `browse_history_clear` | `DELETE /api/profile/browse-history` |
| `play_stats_view` | `GET /api/profile/play-stats` |
| `profile_analytics_metric_view` | `GET /api/profile/analytics/metric` |
### 4.3 AI、资产、LLM 与语音
资产操作统一按用户级事件写入:`scope_kind = user`、`scope_id = 当前认证 user_id`、`user_id/owner_user_id = 当前认证 user_id`。其中 `asset_upload_ticket_create`、`asset_upload_confirm`、`asset_bind` 在 handler 成功后主动记录资产 metadata,避免只依赖路由兜底;其余资产工坊入口通过路由级兜底保留用户级操作事实。
| 事件 | 入口 |
| --- | --- |
| `ai_task_create` | `POST /api/ai/tasks` |
| `ai_task_start` | `POST /api/ai/tasks/{task_id}/start` |
| `ai_task_stage_start` | `POST /api/ai/tasks/{task_id}/stages/{stage_id}/start` |
| `ai_task_chunk_append` | `POST /api/ai/tasks/{task_id}/chunks` |
| `ai_task_stage_complete` | `POST /api/ai/tasks/{task_id}/stages/{stage_id}/complete` |
| `ai_task_reference_attach` | `POST /api/ai/tasks/{task_id}/references` |
| `ai_task_complete` | `POST /api/ai/tasks/{task_id}/complete` |
| `ai_task_fail` | `POST /api/ai/tasks/{task_id}/fail` |
| `ai_task_cancel` | `POST /api/ai/tasks/{task_id}/cancel` |
| `asset_upload_ticket_create` | `POST /api/assets/direct-upload-tickets` |
| `asset_sts_credentials_create` | `POST /api/assets/sts-upload-credentials` |
| `asset_upload_confirm` | `POST /api/assets/objects/confirm` |
| `asset_bind` | `POST /api/assets/objects/bind` |
| `asset_character_visual_generate` | `POST /api/assets/character-visual/generate` |
| `asset_character_visual_publish` | `POST /api/assets/character-visual/publish` |
| `asset_character_animation_generate` | `POST /api/assets/character-animation/generate` |
| `asset_character_animation_publish` | `POST /api/assets/character-animation/publish` |
| `asset_character_animation_import` | `POST /api/assets/character-animation/import-video` |
| `asset_character_workflow_cache_save` | `POST /api/assets/character-workflow-cache` |
| `asset_history_view` | `GET /api/assets/history` |
| `llm_request` | `POST /api/llm/chat/completions` |
| `speech_config_view` | `GET /api/speech/volcengine/config` |
| `asr_stream_start` | `GET /api/speech/volcengine/asr/stream` |
| `tts_bidirection_start` | `GET /api/speech/volcengine/tts/bidirection` |
| `tts_sse_start` | `POST /api/speech/volcengine/tts/sse` |
### 4.4 运行态与创作入口
| 事件 | 入口 |
| --- | --- |
| `runtime_settings_view` | `GET /api/runtime/settings` |
| `runtime_settings_update` | `PUT /api/runtime/settings` |
| `runtime_snapshot_view` | `GET /api/runtime/save/snapshot` |
| `runtime_snapshot_save` | `PUT /api/runtime/save/snapshot` |
| `runtime_snapshot_delete` | `DELETE /api/runtime/save/snapshot` |
| `puzzle_route_success` | `/api/runtime/puzzle/...` 成功响应兜底 |
| `match3d_route_success` | `/api/creation/match3d/...` 与 `/api/runtime/match3d/...` 成功响应兜底 |
| `square_hole_route_success` | `/api/creation/square-hole/...` 与 `/api/runtime/square-hole/...` 成功响应兜底 |
| `custom_world_route_success` | `/api/runtime/custom-world...` 成功响应兜底 |
| `creative_agent_route_success` | `/api/runtime/creative-agent...` 成功响应兜底 |
| `work_play_start` | 拼图、抓大鹅、方洞挑战、自定义世界、大鱼吃小鱼、Visual Novel 的正式开始游玩/播放入口;写 `scope_kind = work`、`scope_id = 作品 ID` |
2048、Survivor、Moku 等未被排除的模板/玩法,如果经由上述 runtime、creative、custom-world、puzzle、match3d 或 square-hole 后端入口,会被路由级兜底事件覆盖。
## 5. 查询与验收建议
按每日登录核查原始事实:
```sql
SELECT event_id, event_key, scope_kind, scope_id, user_id, module_key, metadata_json, occurred_at
FROM tracking_event
WHERE event_key = 'daily_login'
ORDER BY occurred_at DESC
LIMIT 20;
```
按作品级游玩核查原始事实:
```sql
SELECT event_key, scope_kind, scope_id, user_id, owner_user_id, profile_id, module_key, metadata_json, occurred_at
FROM tracking_event
WHERE event_key = 'work_play_start'
ORDER BY occurred_at DESC
LIMIT 20;
```
按某个作品统计不同游玩用户:
```sql
SELECT scope_id, COUNT(DISTINCT user_id) AS player_count
FROM tracking_event
WHERE event_key = 'work_play_start'
AND scope_kind = 'work'
AND scope_id = '<profile_id_or_work_id>'
GROUP BY scope_id;
```
按资产操作核查原始事实:
```sql
SELECT event_key, scope_kind, scope_id, user_id, owner_user_id, module_key, metadata_json, occurred_at
FROM tracking_event
WHERE module_key = 'asset'
ORDER BY occurred_at DESC
LIMIT 20;
```
按事件核查原始事实:
```sql
SELECT event_key, scope_kind, scope_id, user_id, module_key, metadata_json, occurred_at
FROM tracking_event
WHERE event_key = 'task_center_view'
ORDER BY occurred_at DESC
LIMIT 20;
```
按日聚合核查:
```sql
SELECT day_key, event_key, scope_kind, scope_id, count
FROM tracking_daily_stat
WHERE event_key = 'task_center_view'
ORDER BY day_key DESC
LIMIT 20;
```
验收重点:
1. 成功请求写入 `tracking_event` 并刷新 `tracking_daily_stat`。
2. `daily_login` 由认证成功/refresh 续期链路主动写入,且走 `record_tracking_event_and_return` 通用 procedure。
3. 非 2xx 响应不记录通用成功事件。
4. 后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 路由不写入本轮通用埋点。
5. 埋点写入失败时主接口仍返回原业务结果,只记录后端 warning。
6. metadata 不包含凭证、请求体或敏感业务字段。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
# 创作 Agent 流式失败保留可见回复修复 2026-05-05
## 1. 问题
方洞挑战等轻量玩法复用 `usePlatformCreationAgentFlowController` 与 `creationAgentSse.ts` 消费 `reply_delta / session / error`。当上游 LLM 已经返回部分 `replyText`,但后续因为超时、上游断流、SSE 解析或最终 JSON 解析失败而发送 `error` 事件时,前端会在 `finally` 里退出流式态。
旧 UI 只在 `isStreamingReply=true` 时展示临时 assistant 气泡,因此用户会先看到一段回答,然后回答突然消失,只剩错误提示。
## 2. 目标
1. 已经展示给用户的流式回复不能因为最终失败从聊天区消失。
2. SSE `error` 仍然终止本轮提交,并保留错误提示。
3. 后端错误不能只压成 `上游服务请求失败`,应优先把 LLM 流错误原因放到业务 `message`。
4. 不修改 SpacetimeDB schema、消息表结构或玩法运行规则。
## 3. 前端契约
`readCreationAgentSessionFromSse()` 在收到 `reply_delta` 后再收到 `error` 时,必须先触发 `onUpdate(text)`,再抛出错误。调用方可以从最近一次可见文本中恢复 UI。
`usePlatformCreationAgentFlowController.submitMessage()` 的失败收尾规则:
1. 提交时仍先追加 optimistic user message。
2. 每次 `onUpdate` 同步更新 `streamingReplyText` 与最近可见回复引用。
3. 如果 `streamMessage()` 抛错且最近可见回复非空,把该文本追加为本地 assistant `warning` 消息。
4. 再设置 `error`,最后关闭 `isStreamingReply`。
5. 成功拿到最终 session 时,以后端 session snapshot 为准,并清空最近可见回复。
这条本地 `warning` 消息只用于失败态 UI 保留,不代表该 assistant 消息已经写入 SpacetimeDB。
## 4. 后端契约
`creation_agent_llm_turn` 在 `LlmClient::stream_text()` 失败时,返回:
```text
<玩法 generation_failed 文案>:<LlmError Display>
```
同时写 `warn` 日志,便于结合 `logs/llm-raw` 定位上游原始输入输出。
方洞挑战 SSE 错误提取优先级:
1. `error.details.message`
2. `error.message`
3. 其它嵌套 JSON message
4. 原始 body 文本
5. 状态码兜底
## 5. 验收
1. `reply_delta` 后收到 `error` 时,测试应断言 `onUpdate` 已经收到可见文本。
2. 控制器测试应断言失败后本地消息列表包含 user 消息和 assistant warning 消息。
3. `cargo check -p api-server` 通过。
4. `npm run typecheck` 与编码检查通过。
@@ -0,0 +1,113 @@
# 创作入口配置数据库化与 Runtime 缺失作品返回首页
日期:2026-05-10
## 背景
前端创作中心原本把新建作品入口配置保存在前端代码中,导致入口是否展示、是否开放、卡片文案和 api-server 路由可用性无法使用同一份事实源控制。
同时,用户直接访问 `/runtime/<玩法>?work=<作品号>` 时,如果作品不存在,运行态会先弹出错误提示;但弹窗关闭后仍停留在无运行数据的页面,容易出现空白页。
## 设计结论
1. 创作入口配置事实源迁入 SpacetimeDB。
2. 前端只通过 `GET /api/creation-entry/config` 读取配置并派生展示卡片。
3. api-server 使用同一份配置对相关运行时路由做熔断。
4. 直接 runtime 深链找不到作品时,弹窗确认后回到首页 `/`,避免停留在空白运行态。
## 数据模型
SpacetimeDB 新增两张表:
- `creation_entry_config`
- 全局配置头,保存创作入口主卡片和类型选择弹窗文案。
- `creation_entry_type_config`
- 每个玩法入口的展示与开关配置。
- 关键字段:
- `id`
- `title`
- `subtitle`
- `badge`
- `image_src`
- `visible`
- `open`
- `sort_order`
其中:
- `visible=false`:前端隐藏入口。
- `open=false`:前端展示为锁定/暂不可创建,api-server 据此熔断对应玩法 API;只隐藏创作页入口但保留既有作品链路时不要关闭 `open`。
- `sort_order`:数据库排序字段,前端只做可见/锁定分组派生。
## API
新增:
```text
GET /api/creation-entry/config
```
返回 shared-contracts 中的 `CreationEntryConfigResponse`:
```json
{
"startCard": {
"title": "...",
"description": "...",
"idleBadge": "...",
"busyBadge": "..."
},
"typeModal": {
"title": "...",
"description": "..."
},
"creationTypes": [
{
"id": "puzzle",
"title": "拼图",
"subtitle": "拼图关卡创作",
"badge": "可创建",
"imageSrc": "/creation-type-references/puzzle.webp",
"visible": true,
"open": true,
"sortOrder": 30,
"updatedAtMicros": 0
}
],
"updatedAtMicros": 0
}
```
## 前端约束
- 禁止再新增 `src/config/newWorkEntryConfig.ts` 这类入口事实源。
- 创作入口 UI 使用 `src/services/creationEntryConfigService.ts` 拉取后端配置。
- `src/components/platform-entry/platformEntryCreationTypes.ts` 只保留展示派生:
- `visible -> hidden`
- `open -> locked`
- `sortOrder -> 初始顺序`
- 缺失作品的 runtime 深链恢复策略放在 `src/routing/runtimeNotFoundRecovery.ts`。
## Runtime 缺失作品恢复
当路径是以下任意 runtime 深链,并且作品读取/启动返回 404 或 NOT_FOUND:
- `/runtime/puzzle`
- `/runtime/match3d`
- `/runtime/big-fish`
- `/runtime/square-hole`
- `/runtime/visual-novel`
前端执行:
1. 清理当前运行态选择和错误状态。
2. 弹出“作品不存在或已下架,将返回首页。”。
3. 跳转首页 `/`。
## 验证命令
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/routing/runtimeNotFoundRecovery.test.ts
npm run typecheck
cd server-rs && cargo check -p api-server -p spacetime-module --no-default-features
```
@@ -0,0 +1,75 @@
# 登录成功每日登录埋点闭环方案(2026-05-08)
## 背景
后台“埋点数据”需要能看到真实登录触发的 `daily_login` 埋点。此前方案 A 已把“读取任务中心时顺手写每日登录埋点”拆成独立 SpacetimeDB procedure:
- `record_daily_login_tracking_event_and_return`
- `spacetime-client` 方法:`record_daily_login_tracking_event(user_id)`
但认证成功链路当时还没有调用该方法,因此当时只完成了“任务中心读取不污染登录埋点”,没有完成“用户真实登录写入每日登录埋点”。后续后端通用埋点能力落地后,`daily_login` 已进一步改为通过统一 `record_tracking_event_and_return(RuntimeTrackingEventInput)` procedure 写入,旧 `record_daily_login_tracking_event_and_return` 不再作为认证链路的目标入口。
## 现象
用户已经登录、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:
- 构造 `TrackingEventDraft::user("daily_login", "profile", user_id)`
- 使用 `daily-login:{user_id}:{day_key}` 作为事件 ID,保持北京时间自然日幂等
- 调用统一 `record_tracking_event_after_success(...)`,最终进入 `record_tracking_event_and_return(RuntimeTrackingEventInput)`
- 成功时记录 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` 和后台页面验证,不要等待该命令退出。
@@ -0,0 +1,74 @@
# 本地 Rust 栈端口冲突预检
日期:`2026-05-09`
## 问题
执行完整本地栈启动命令时:
```bash
node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh --spacetime-port 3101 --skip-spacetime
```
如果本机已有旧的 `api-server`、主站 Vite 或后台 Vite 进程仍在监听默认端口,脚本可能出现混合日志:
```text
Port 3000 is in use, trying another one...
Error: Os { code: 10048, kind: AddrInUse }
process didn't exit successfully: `server-rs\target\debug\api-server.exe`
```
其中 `wait_for_api_server` 只探测 `http://127.0.0.1:<api-port>/healthz`。当旧 `api-server` 仍监听 `8082` 时,健康检查会命中旧进程并误判新服务已就绪;随后新 `cargo run` 真正绑定 `8082` 时失败。与此同时,Vite 默认会在 `3000` 被占用时漂移到下一个端口,导致浏览器仍可能打开旧前端。
## 处理
`scripts/dev-rust-stack.sh` 在进入 SpacetimeDB publish 和 Rust 编译前,先检查三类端口是否可绑定:
1. Rust `api-server`:默认 `127.0.0.1:8082`。
2. 主站 Vite:默认 `0.0.0.0:3000`。
3. 后台 Vite:默认 `127.0.0.1:3102`。
端口被占用时,脚本会直接失败并打印监听进程。Windows 本地会通过 `Get-NetTCPConnection` 与 `Win32_Process` 输出 `pid / name / address / command`,方便精确停止旧进程。
主站和后台 Vite 也追加 `--strictPort`,避免默认漂移到 `3001`、`3103` 等端口后让浏览器继续访问旧页面。
## 排障步骤
PowerShell 查看默认端口占用:
```powershell
Get-NetTCPConnection -State Listen -LocalPort 3000,3102,8082,3101 -ErrorAction SilentlyContinue |
Select-Object LocalAddress,LocalPort,OwningProcess |
Sort-Object LocalPort
```
查看进程命令行:
```powershell
Get-CimInstance Win32_Process |
Where-Object { $_.ProcessId -in @(3000端口PID, 8082端口PID) } |
Select-Object ProcessId,Name,CommandLine
```
停止确认可丢弃的旧本地开发进程:
```powershell
Stop-Process -Id <pid> -Force
```
如果确实需要保留旧栈,可显式换端口启动新栈:
```bash
node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh \
--skip-spacetime \
--spacetime-port 3101 \
--api-port 8090 \
--web-port 3001 \
--admin-web-port 3103
```
## 验证
1. `bash -n scripts/dev-rust-stack.sh` 通过。
2. 默认端口被占用时重新运行完整栈,脚本应在 publish 前失败并打印占用进程。
3. 清理占用进程或换端口后,重新启动时不再出现 Vite 端口漂移或 `api-server` `AddrInUse`。
@@ -0,0 +1,137 @@
# Hyper3D Rodin Gen-2 3D 模型生成接入方案 2026-05-08
## 1. 范围
本方案用于接入 Hyper3D Rodin Gen-2 的文生 3D 模型与图生 3D 模型能力。
本次只做后端安全代理与前端可复用 client,不新增 SpacetimeDB 表,不落正式资产对象,不把 Hyper3D API Key 下发到前端。生成结果仍由调用方在拿到下载链接后决定是否进入 OSS / `asset_object` 的正式资产链。
## 2. 参考接口
参考文档:
- `https://developer.hyper3d.ai/api-specification/rodin-generation-gen2`
- `https://developer.hyper3d.ai/api-specification/check-status`
- `https://developer.hyper3d.ai/api-specification/download-results`
- `https://developer.hyper3d.ai/api-specification/check-status_reset_v`
- `https://developer.hyper3d.ai/api-specification/download-results_reset_v`
上游接口:
```text
POST https://api.hyper3d.com/api/v2/rodin
POST https://api.hyper3d.com/api/v2/status
POST https://api.hyper3d.com/api/v2/download
```
Rodin Gen-2 提交接口必须使用 `multipart/form-data`。文本生成时提交 `prompt`;图片生成时提交一个或多个 `images` 文件,可选 `prompt` 作为辅助描述。两种模式均固定提交 `tier=Gen-2`。
官方 `*_reset_v` 文档对状态和下载有两个关键约束:
1. 生成接口返回的顶层 `uuid` 是后续下载接口的 `task_uuid`,不要使用 `jobs.uuids` 中的子任务 uuid 作为下载参数。
2. 状态接口使用 `subscription_key` 查询,并返回 `jobs[]`;只有所有 job 的 `status` 都为 `Done` 才能进入下载,任一 job `Failed` 都应视为任务失败。
## 3. 环境变量
```text
HYPER3D_BASE_URL=https://api.hyper3d.com/api/v2
HYPER3D_API_KEY=
HYPER3D_MODEL_REQUEST_TIMEOUT_MS=180000
```
兼容变量:
```text
RODIN_BASE_URL
RODIN_API_KEY
RODIN_MODEL_REQUEST_TIMEOUT_MS
```
说明:
1. `HYPER3D_API_KEY` / `RODIN_API_KEY` 只允许写入本地或生产私密环境,不提交到 Git。
2. 缺少 API Key 时,后端返回 `503 SERVICE_UNAVAILABLE`。
3. 本地真实联调推荐把 key 放在 `.env.secrets.local`;`npm run api-server`、`npm run dev:rust` 与 `npm run dev` 均应保持“外层 shell 变量优先,随后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的口径,避免 `.env` 里的空示例值压掉私密 key。
4. `HYPER3D_BASE_URL` 默认使用公开 API 基础地址;如果团队后续改用代理网关,可通过环境变量覆盖。
## 4. 后端路由
新增 4 个鉴权路由:
| 方法 | 路由 | 用途 |
| --- | --- | --- |
| `POST` | `/api/assets/hyper3d/text-to-model` | 提交 Rodin Gen-2 文生模型任务 |
| `POST` | `/api/assets/hyper3d/image-to-model` | 提交 Rodin Gen-2 图生模型任务 |
| `POST` | `/api/assets/hyper3d/status` | 使用 `subscriptionKey` 查询任务状态 |
| `POST` | `/api/assets/hyper3d/download` | 使用 `taskUuid` 获取模型下载列表 |
文生模型请求最小体:
```json
{
"prompt": "一只低多边形宝箱,适合 RPG 游戏资产",
"geometryFileFormat": "glb",
"material": "PBR",
"quality": "medium",
"meshMode": "Quad",
"previewRender": true
}
```
图生模型请求最小体:
```json
{
"imageDataUrls": ["data:image/png;base64,..."],
"prompt": "保留主体轮廓,生成游戏可用 3D 模型",
"conditionMode": "concat",
"geometryFileFormat": "glb"
}
```
## 5. 约束
1. 图片只接受 `data:image/png|jpeg|webp;base64,...`,最多 5 张。
2. 单张图片解码后不超过 10MB。
3. `geometryFileFormat` 限定为 `glb/usdz/fbx/obj/stl`,默认 `glb`。
4. `material` 限定为 `PBR/Shaded/All`,默认 `PBR`。
5. `quality` 限定为 `high/medium/low/extra-low`,默认 `medium`。
6. `meshMode` 限定为 `Quad/Raw`,默认 `Quad`。
7. `addons` 首版只允许 `HighPack`。
8. `bboxCondition` 必须为 3 个正数,按上游要求序列化为 JSON 字符串。
9. `subscriptionKey` 是 Hyper3D 返回的 opaque token,状态查询只校验非空,不在本地做 256 字符等固定长度限制,避免长 token 阻断抓大鹅草稿内联模型生成。
## 6. 返回语义
提交任务成功后返回:
```json
{
"ok": true,
"provider": "hyper3d-rodin",
"mode": "text-to-model",
"taskUuid": "task-uuid",
"subscriptionKey": "subscription-key",
"jobUuids": ["job-uuid"],
"message": "Submitted.",
"tier": "Gen-2"
}
```
状态查询会把上游 `Waiting / Generating / Done / Failed` 归一化为 `waiting / generating / done / failed / unknown`,整体状态必须以 `jobs[]` 聚合结果为准。下载接口只返回上游 `list.name` 与 `list.url`,不在 Hyper3D 代理路由中转存文件;具体玩法若需要持久化模型,应在玩法编排层等待 `Done` 后再下载并转存。
## 7. 验收
建议执行:
```bash
npm run check:encoding
npm run typecheck
cd server-rs
cargo test -p shared-contracts hyper3d
cargo test -p api-server hyper3d
cargo check -p api-server
```
真实 API smoke 只在本地私密环境设置 `HYPER3D_API_KEY` 后执行。提交生成任务会消耗 Hyper3D Credit,默认验证不自动调用真实生成接口。
@@ -108,6 +108,12 @@ Node 旧链路对上传封面有明确处理:
Rust 本批必须保持这组兼容约束。
2026-05-08 交互补充:
1. 前端裁剪面板不再展示 `缩放 / 左右位置 / 上下位置` 参数滑杆。
2. 作者直接在图片上拖拽裁剪框内部移动区域,或拖拽四边、四角调整裁剪范围。
3. 调整过程中前端持续锁定 `16:9`,确认时仍只提交后端兼容的 `cropRect`。
## 4. 请求与响应 contract
### 4.1 `POST /api/custom-world/scene-image`
@@ -122,6 +122,12 @@
2. AI worker 绕过确认链路写出不完整记录
3. 把 OSS 响应中的派生 URL 当成对象真相
### 5.1 OSS V4 签名时间格式
`platform-oss` 的 OSS V4 签名时间必须显式格式化为 `YYYYMMDDTHHMMSSZ`,签名 scope 日期必须显式格式化为 `YYYYMMDD`。实现中不要依赖 `OffsetDateTime::time().to_string()` 或 `date().to_string()` 再替换字符,因为 UTC 小时、分钟、秒为个位数时可能不会保留前导零,导致 AI 生图已完成但上传 OSS 阶段报 `OSS V4 签名时间格式化失败`。
拼图、视觉小说、自定义世界等所有服务端生成图片上传链路都复用同一套 `platform-oss` 签名 helper;新增签名逻辑时必须覆盖个位时间分量的测试样例,例如 `05:03:09` 应输出 `T050309Z`。
## 6. 与 Web 端的边界
Web 端当前只允许:

Some files were not shown because too many files have changed in this diff Show More