diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
index 48a1278cc..301342ea5 100644
--- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
+++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
@@ -2081,7 +2081,7 @@ function pickDraftCompletionDialogSourceId(
function buildDraftCompletionDialogSource(
kind: CreationWorkShelfKind,
ids: Array
,
-) {
+): string {
const sourceId = pickDraftCompletionDialogSourceId(ids);
switch (kind) {
case 'rpg':
@@ -2103,6 +2103,7 @@ function buildDraftCompletionDialogSource(
case 'baby-object-match':
return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId);
}
+ return formatPlatformTaskCompletionSource('创作草稿', sourceId);
}
function createMiniGameDraftGenerationStateForRestoredDraft(
diff --git a/src/index.css b/src/index.css
index d18074754..5d82171fe 100644
--- a/src/index.css
+++ b/src/index.css
@@ -2044,7 +2044,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: normal;
}
-.creation-work-card__share-button {
+.creation-work-card__quick-actions {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 0.12rem;
+}
+
+.creation-work-card__quick-action-button {
display: inline-flex;
width: 2rem;
height: 2rem;
@@ -2061,17 +2068,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform 160ms ease;
}
-.creation-work-card__share-button:hover {
+.creation-work-card__quick-action-button:hover {
transform: translateY(-1px);
background: color-mix(in srgb, var(--platform-cool-bg) 24%, transparent);
color: var(--platform-cool-text);
}
-.creation-work-card__share-button:focus-visible {
+.creation-work-card__quick-action-button:focus-visible {
outline: 2px solid var(--platform-cool-border);
outline-offset: 2px;
}
+.creation-work-card__quick-action-button--danger {
+ color: color-mix(in srgb, #c7653d 78%, var(--platform-text-soft));
+}
+
+.creation-work-card__quick-action-button--danger:hover {
+ background: color-mix(in srgb, #c7653d 18%, transparent);
+ color: #a9472c;
+}
+
+.creation-work-card__quick-action-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+ transform: none;
+}
+
.creation-work-card__meta {
display: flex;
min-width: 0;
--
2.52.0
From 418fcb0548ade06888f3651dd6726f13a7006306 Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Wed, 27 May 2026 20:58:37 +0800
Subject: [PATCH 05/19] Fail closed when SpacetimeDB auth restore is
unavailable
---
.env.example | 2 -
.hermes/shared-memory/decision-log.md | 17 +-
.hermes/shared-memory/pitfalls.md | 9 +-
deploy/container/api-server.Dockerfile | 1 -
deploy/container/api-server.env.example | 1 -
deploy/container/docker-compose.loadtest.yml | 2 -
deploy/env/api-server.env.example | 1 -
...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +
...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +-
scripts/deploy/production-api-deploy.sh | 9 +-
scripts/jenkins-server-provision.sh | 1 -
server-rs/crates/api-server/src/app.rs | 128 ++++-
server-rs/crates/api-server/src/config.rs | 6 -
.../src/creation_agent_document_input.rs | 10 +-
server-rs/crates/api-server/src/main.rs | 40 +-
server-rs/crates/api-server/src/state.rs | 129 +----
server-rs/crates/module-auth/src/lib.rs | 78 +--
server-rs/crates/spacetime-client/src/auth.rs | 56 --
.../spacetime-client/src/module_bindings.rs | 6 -
.../get_auth_store_snapshot_procedure.rs | 54 --
.../import_auth_store_snapshot_procedure.rs | 54 --
.../upsert_auth_store_snapshot_procedure.rs | 59 --
.../spacetime-module/src/auth/procedures.rs | 521 ++++++++++++------
.../crates/spacetime-module/src/puzzle.rs | 6 +
24 files changed, 595 insertions(+), 601 deletions(-)
delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_auth_store_snapshot_procedure.rs
delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/import_auth_store_snapshot_procedure.rs
delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upsert_auth_store_snapshot_procedure.rs
diff --git a/.env.example b/.env.example
index 03d8c2c11..c74c3aaaf 100644
--- a/.env.example
+++ b/.env.example
@@ -46,8 +46,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS="30"
AUTH_REFRESH_COOKIE_PATH="/api/auth"
AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
AUTH_REFRESH_COOKIE_SECURE="false"
-# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。
-GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"
diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 8536eea5c..1495b21d3 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -507,6 +507,13 @@
- 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding`。
- 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
+## 2026-05-27 auth_store_snapshot 改为行级记录,不再保留 default 聚合单行
+
+- 背景:`auth_store_snapshot/default` 聚合 JSON 行会把整份认证快照收敛到单键,过期快照一旦被导入就可能覆盖 `user_account` / `auth_identity` / `refresh_session` 的整表状态。
+- 决策:`auth_store_snapshot` 只保留行级记录,按 `meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/` 拆分存储;`api-server` 启动恢复只认正式认证表,`auth_store_snapshot` 仅作为行级备查,不再作为文件快照替代源。
+- 影响范围:`spacetime-module` auth procedures、`spacetime-client` auth facade、`api-server` 启动恢复、后端架构文档、开发运维文档、认证排障记忆。
+- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
+
## 2026-05-13 微信小程序支付以后端通知为唯一入账事实
- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。
@@ -1070,4 +1077,12 @@
- 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。
- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/api-server/src/modules/wooden_fish.rs`、`src/services/wooden-fish/woodenFishClient.ts`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。
- 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。
-- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
+ - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
+
+## 2026-05-27 认证快照完全去文件化并仅保留行级备查
+
+- 背景:`api-server` 依赖本地 `auth-store.json` 或 `GENARRATIVE_AUTH_STORE_PATH` 恢复认证真相会在 SpacetimeDB 不可用时把旧快照回灌到 `auth_identity` / `user_account`,导致用户数据被清空或覆盖。
+- 决策:`api-server` 启动时只允许从 SpacetimeDB 正式认证表恢复;`module-auth` 不再维护本地持久化文件,只保留内存工作集和 JSON 导入 / 导出;`spacetime-module` 的认证快照只保留行级 `auth_store_snapshot` 备查,不再提供旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 兼容入口。
+- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
+- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。
+- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index acec81b35..d39b6b0c5 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -512,10 +512,17 @@
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `password_hash`、`password_login_enabled` 和 `token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()`,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
-- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照;启动恢复时从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB。
+- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。若启动时连不上 SpacetimeDB,`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
- 关联:`server-rs/crates/api-server/src/password_management.rs`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。
+## 认证本地文件快照已废弃,旧 procedure 也已删
+
+- 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot`,或者把 `auth-store.json` 误当成认证恢复源。
+- 原因:认证恢复已经彻底收口到 SpacetimeDB 正式表和 `module-auth` 的 JSON 导入 / 导出路径;本地文件持久化会和正式表投影打架,SpacetimeDB 不可用时还可能把旧快照回灌到用户表。
+- 处理:先用 `npm run spacetime:generate -- --rust-only` 刷新 bindings,确认 `server-rs/crates/spacetime-client/src/module_bindings.rs` 里已没有旧 procedure 导出;`module-auth` 只保留内存态,不再写本地快照文件。
+- 验证:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding`。
+
## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置
- 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。
diff --git a/deploy/container/api-server.Dockerfile b/deploy/container/api-server.Dockerfile
index 1a0c1eaa2..1f098dfdd 100644
--- a/deploy/container/api-server.Dockerfile
+++ b/deploy/container/api-server.Dockerfile
@@ -24,7 +24,6 @@ EXPOSE 8082
ENV GENARRATIVE_ENV=container \
GENARRATIVE_API_HOST=0.0.0.0 \
GENARRATIVE_API_PORT=8082 \
- GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json \
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
CMD ["api-server"]
diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example
index a3e0dd338..98a2c115e 100644
--- a/deploy/container/api-server.env.example
+++ b/deploy/container/api-server.env.example
@@ -27,7 +27,6 @@ GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_CONTAINER
GENARRATIVE_JWT_ISSUER=genarrative-container
GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER
AUTH_REFRESH_COOKIE_SECURE=false
-GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
# 默认连接 compose 内部 SpacetimeDB;宿主机发布模块使用 127.0.0.1:13101。
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml
index afac4962a..f21832db0 100644
--- a/deploy/container/docker-compose.loadtest.yml
+++ b/deploy/container/docker-compose.loadtest.yml
@@ -52,7 +52,6 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- - api-auth-store:/var/lib/genarrative/auth
- api-tracking-outbox:/var/lib/genarrative/tracking-outbox
ulimits:
nofile:
@@ -142,6 +141,5 @@ services:
volumes:
spacetime-data:
- api-auth-store:
api-tracking-outbox:
nginx-logs:
diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example
index c7a85beea..38d14caaf 100644
--- a/deploy/env/api-server.env.example
+++ b/deploy/env/api-server.env.example
@@ -34,7 +34,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS=30
AUTH_REFRESH_COOKIE_PATH=/api/auth
AUTH_REFRESH_COOKIE_SAME_SITE=Lax
AUTH_REFRESH_COOKIE_SECURE=true
-GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false
GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 206c98b51..c90994847 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -233,6 +233,10 @@ npm run check:server-rs-ddd
- Rust 结构体:`AuthStoreSnapshot`
- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs`
+认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。
+
+`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
+
### `bark_battle_draft_config`
- Rust 结构体:`BarkBattleDraftConfigRow`
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 62a24ea86..a94a4d5a5 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -342,7 +342,7 @@ systemctl restart genarrative-api.service
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
```
-`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 与 auth-store 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox`、`/var/lib/genarrative/auth` 归属 `genarrative:genarrative`。
+`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会等待启动恢复,超时后继续监听但进入依赖不可用模式,所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表。
常用检查思路:
diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh
index 992604cda..3bf578986 100644
--- a/scripts/deploy/production-api-deploy.sh
+++ b/scripts/deploy/production-api-deploy.sh
@@ -205,7 +205,7 @@ ensure_runtime_dir() {
ensure_runtime_env_and_dirs() {
local api_env_file="$1"
- local tracking_enabled tracking_outbox_dir auth_store_path auth_store_dir
+ local tracking_enabled tracking_outbox_dir
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
@@ -214,19 +214,12 @@ ensure_runtime_env_and_dirs() {
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
- ensure_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")"
tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")"
if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then
ensure_runtime_dir "${tracking_outbox_dir}" "0750"
fi
-
- auth_store_path="$(read_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH")"
- if [[ -n "${auth_store_path}" ]]; then
- auth_store_dir="$(dirname "${auth_store_path}")"
- ensure_runtime_dir "${auth_store_dir}" "0750"
- fi
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh
index e54b42d00..c1d0c52ac 100755
--- a/scripts/jenkins-server-provision.sh
+++ b/scripts/jenkins-server-provision.sh
@@ -325,7 +325,6 @@ ensure_api_runtime_env_defaults() {
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
- ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
}
parse_json_string_field() {
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index 925990408..4b0d747c0 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -2,11 +2,12 @@ use axum::{
Router,
body::Body,
extract::{Extension, FromRef},
- http::Request,
+ http::{Request, StatusCode},
middleware,
response::Response,
routing::{get, post},
};
+use serde_json::json;
use tower_http::{
classify::ServerErrorsFailureClass,
trace::{DefaultOnRequest, TraceLayer},
@@ -18,6 +19,7 @@ use crate::{
backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled,
error_middleware::normalize_error_response,
+ http_error::AppError,
modules,
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
@@ -164,6 +166,96 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state)
}
+pub fn build_spacetime_unavailable_router(message: String) -> Router {
+ Router::new()
+ .fallback(spacetime_unavailable_handler)
+ .layer(Extension(SpacetimeUnavailableState {
+ message: message.into(),
+ }))
+ // 依赖不可用模式不挂业务 state,统一返回 503,并继续保留 request_id / API 版本 / 耗时响应头。
+ .layer(middleware::from_fn(normalize_error_response))
+ .layer(middleware::from_fn(propagate_request_id_header))
+ .layer(
+ TraceLayer::new_for_http()
+ .make_span_with(|request: &Request| {
+ let request_id =
+ resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
+ let route = crate::telemetry::observability_route(request.uri().path());
+ let scheme = crate::telemetry::resolve_request_scheme(request.headers());
+ let span_name = format!("{} {}", request.method(), route);
+
+ info_span!(
+ "http.request",
+ otel.kind = "server",
+ otel.name = %span_name,
+ otel.status_code = tracing::field::Empty,
+ http.response.status_code = tracing::field::Empty,
+ method = %request.method(),
+ http.request.method = %request.method(),
+ http.route = %route,
+ url.scheme = %scheme,
+ url.path = %request.uri().path(),
+ request_id = %request_id,
+ status = tracing::field::Empty,
+ latency_ms = tracing::field::Empty,
+ )
+ })
+ .on_request(DefaultOnRequest::new().level(Level::INFO))
+ .on_response(
+ |response: &axum::response::Response,
+ latency: std::time::Duration,
+ span: &Span| {
+ let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
+ let status = response.status().as_u16();
+ span.record("status", status);
+ span.record("http.response.status_code", status);
+ span.record(
+ "otel.status_code",
+ if response.status().is_server_error() {
+ "ERROR"
+ } else {
+ "OK"
+ },
+ );
+ span.record("latency_ms", latency_ms);
+ },
+ )
+ .on_failure(
+ |failure: ServerErrorsFailureClass,
+ latency: std::time::Duration,
+ span: &Span| {
+ let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
+ error!(
+ parent: span,
+ latency_ms,
+ failure = %failure,
+ "http request failed"
+ );
+ },
+ ),
+ )
+ .layer(middleware::from_fn(attach_request_context))
+}
+
+#[derive(Clone, Debug)]
+struct SpacetimeUnavailableState {
+ message: std::sync::Arc,
+}
+
+async fn spacetime_unavailable_handler(
+ Extension(state): Extension,
+ Extension(request_context): Extension,
+) -> Response {
+ AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
+ .with_message("SpacetimeDB 暂不可用,api-server 正在等待数据库恢复")
+ .with_details(json!({
+ "provider": "spacetimedb",
+ "reason": "spacetime_startup_unavailable",
+ "message": state.message.as_ref(),
+ }))
+ .into_response_with_context(Some(&request_context))
+}
+
async fn record_api_tracking_after_success(
axum::extract::State(state): axum::extract::State,
Extension(request_context): Extension,
@@ -368,7 +460,7 @@ mod tests {
use crate::{config::AppConfig, state::AppState};
- use super::build_router;
+ use super::{build_router, build_spacetime_unavailable_router};
const TEST_PASSWORD: &str = "secret123";
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
@@ -564,6 +656,38 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
+ let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/api/auth/login-options")
+ .header("x-request-id", "req-spacetime-unavailable")
+ .body(Body::empty())
+ .expect("request should build"),
+ )
+ .await
+ .expect("request should succeed");
+
+ assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+ assert_eq!(
+ response
+ .headers()
+ .get("x-request-id")
+ .and_then(|value| value.to_str().ok()),
+ Some("req-spacetime-unavailable")
+ );
+ let body = read_json_response(response).await;
+ assert_eq!(body["error"]["code"], "SERVICE_UNAVAILABLE");
+ assert_eq!(
+ body["error"]["details"]["reason"],
+ "spacetime_startup_unavailable"
+ );
+ assert_eq!(body["error"]["details"]["provider"], "spacetimedb");
+ }
+
#[tokio::test]
async fn creation_entry_route_disabled_returns_service_unavailable() {
let state = AppState::new(AppConfig::default()).expect("state should build");
diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs
index 079f7b5a2..cc846410e 100644
--- a/server-rs/crates/api-server/src/config.rs
+++ b/server-rs/crates/api-server/src/config.rs
@@ -11,7 +11,6 @@ use platform_speech::{
};
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
-const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
@@ -45,7 +44,6 @@ pub struct AppConfig {
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
- pub auth_store_path: PathBuf,
pub dev_password_entry_auto_register_enabled: bool,
pub sms_auth_enabled: bool,
pub sms_auth_provider: String,
@@ -184,7 +182,6 @@ impl Default for AppConfig {
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
- auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(),
@@ -433,9 +430,6 @@ impl AppConfig {
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
- if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
- config.auth_store_path = PathBuf::from(auth_store_path);
- }
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
{
diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs
index 46bf09762..cb0bd80bd 100644
--- a/server-rs/crates/api-server/src/creation_agent_document_input.rs
+++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs
@@ -236,7 +236,6 @@ mod tests {
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::{Value, json};
- use std::path::PathBuf;
use time::OffsetDateTime;
use tower::ServiceExt;
@@ -394,12 +393,7 @@ mod tests {
}
async fn build_test_state(label: &str) -> AppState {
- let mut config = AppConfig::default();
- config.auth_store_path = PathBuf::from(format!(
- ".codex-temp/api-server-auth-store-creation-doc-{label}.json"
- ));
- let _ = std::fs::remove_file(&config.auth_store_path);
-
- AppState::new(config).expect("state should build")
+ let _ = label;
+ AppState::new(AppConfig::default()).expect("state should build")
}
}
diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs
index b8e793c1f..ecd6635ee 100644
--- a/server-rs/crates/api-server/src/main.rs
+++ b/server-rs/crates/api-server/src/main.rs
@@ -107,9 +107,13 @@ use std::{
use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::time::timeout;
-use tracing::{info, warn};
+use tracing::{error, info};
-use crate::{app::build_router, config::AppConfig, state::AppState};
+use crate::{
+ app::{build_router, build_spacetime_unavailable_router},
+ config::AppConfig,
+ state::{AppState, AppStateInitError},
+};
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
@@ -156,14 +160,21 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
let otel_enabled = config.otel_enabled;
let listener = build_tcp_listener(bind_address, listen_backlog)?;
- let state = restore_app_state_for_startup(config)
- .await
- .map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
- state.puzzle_gallery_cache().spawn_cleanup_task();
- if let Some(outbox) = state.tracking_outbox() {
- outbox.spawn_worker();
- }
- let router = build_router(state);
+ let router = match restore_app_state_for_startup(config).await {
+ Ok(state) => {
+ state.puzzle_gallery_cache().spawn_cleanup_task();
+ if let Some(outbox) = state.tracking_outbox() {
+ outbox.spawn_worker();
+ }
+ build_router(state)
+ }
+ Err(AppStateInitError::DependencyUnavailable(message)) => {
+ build_spacetime_unavailable_router(message)
+ }
+ Err(error) => {
+ return Err(std::io::Error::other(format!("初始化应用状态失败:{error}")));
+ }
+ };
info!(
%bind_address,
@@ -192,7 +203,6 @@ fn build_tcp_listener(
async fn restore_app_state_for_startup(
config: AppConfig,
) -> Result {
- let fallback_config = config.clone();
match timeout(
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
AppState::try_restore_auth_store_from_spacetime(config),
@@ -201,11 +211,13 @@ async fn restore_app_state_for_startup(
{
Ok(result) => result,
Err(_) => {
- warn!(
+ error!(
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
- "启动恢复认证快照超时,跳过远端恢复并继续启动 api-server"
+ "启动等待 SpacetimeDB 恢复认证快照超时,api-server 将进入依赖不可用模式"
);
- AppState::new(fallback_config)
+ Err(state::AppStateInitError::DependencyUnavailable(
+ "SpacetimeDB 启动恢复认证快照超时".to_string(),
+ ))
}
}
}
diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs
index bfda79d0e..37a25b0a1 100644
--- a/server-rs/crates/api-server/src/state.rs
+++ b/server-rs/crates/api-server/src/state.rs
@@ -1,9 +1,8 @@
use std::{
collections::HashMap,
error::Error,
- fmt, fs,
+ fmt,
sync::{Arc, Mutex},
- time::{SystemTime, UNIX_EPOCH},
};
use axum::extract::FromRef;
@@ -300,6 +299,7 @@ pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
AuthStore(String),
+ DependencyUnavailable(String),
SmsProvider(SmsProviderError),
WechatPay(String),
Oss(OssError),
@@ -308,12 +308,12 @@ pub enum AppStateInitError {
impl AppState {
pub fn new(config: AppConfig) -> Result {
- #[cfg(test)]
- let auth_store = InMemoryAuthStore::default();
- #[cfg(not(test))]
- let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
- .map_err(AppStateInitError::AuthStore)?;
- Self::new_with_auth_store(config, auth_store)
+ Self::new_with_empty_auth_store(config)
+ }
+
+ pub fn new_with_empty_auth_store(config: AppConfig) -> Result {
+ // 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。
+ Self::new_with_auth_store(config, InMemoryAuthStore::default())
}
fn new_with_auth_store(
@@ -549,8 +549,8 @@ impl AppState {
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
)
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
- // 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 正式认证表用于跨进程恢复。
- // 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
+ // 当前进程内 auth_store 是认证请求的即时工作集;SpacetimeDB 正式认证表用于跨进程恢复。
+ // 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
#[cfg(not(test))]
if let Err(error) = self
.spacetime_client
@@ -577,64 +577,42 @@ impl AppState {
pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout,
});
- let mut candidates = Vec::new();
+ let mut spacetime_restore_available = false;
+ let mut restore_errors = Vec::new();
match spacetime_client
.export_auth_store_snapshot_from_tables()
.await
{
Ok(snapshot) => {
+ spacetime_restore_available = true;
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
snapshot,
AuthStoreRestoreSource::SpacetimeTables,
)? {
- candidates.push(candidate);
+ let state = Self::new_with_auth_store(config, candidate.auth_store)?;
+ info!(
+ source = candidate.source.as_str(),
+ updated_at_micros = candidate.updated_at_micros,
+ "已恢复认证快照"
+ );
+ return Ok(state);
}
}
Err(error) => {
warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败");
+ restore_errors.push(error.to_string());
}
}
- match spacetime_client.get_auth_store_snapshot().await {
- Ok(snapshot) => {
- if let Some(candidate) = auth_store_candidate_from_snapshot_record(
- snapshot,
- AuthStoreRestoreSource::SpacetimeSnapshot,
- )? {
- candidates.push(candidate);
- }
- }
- Err(error) => {
- warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败");
- }
+ if !spacetime_restore_available {
+ return Err(AppStateInitError::DependencyUnavailable(format!(
+ "SpacetimeDB 认证恢复不可用:{}",
+ restore_errors.join("; ")
+ )));
}
- if let Some(candidate) = auth_store_candidate_from_local_file(&config)? {
- candidates.push(candidate);
- }
-
- if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
- let source = candidate.source;
- let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
- let state = Self::new_with_auth_store(config, candidate.auth_store)?;
- info!(
- source = source.as_str(),
- updated_at_micros = candidate.updated_at_micros,
- "已恢复认证快照"
- );
- if should_sync_to_spacetime {
- if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
- warn!(
- error = %error,
- "本地认证快照回写 SpacetimeDB 失败,当前启动继续"
- );
- }
- }
- return Ok(state);
- }
-
- Self::new(config)
+ Self::new_with_empty_auth_store(config)
}
pub fn refresh_session_service(&self) -> &RefreshSessionService {
@@ -988,16 +966,12 @@ impl AppState {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthStoreRestoreSource {
SpacetimeTables,
- SpacetimeSnapshot,
- LocalFile,
}
impl AuthStoreRestoreSource {
fn as_str(self) -> &'static str {
match self {
Self::SpacetimeTables => "spacetime_tables",
- Self::SpacetimeSnapshot => "spacetime_snapshot",
- Self::LocalFile => "local_file",
}
}
}
@@ -1029,57 +1003,14 @@ fn auth_store_candidate_from_snapshot_record(
}))
}
-fn auth_store_candidate_from_local_file(
- config: &AppConfig,
-) -> Result
diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx
index e2d25871d..e8a0ef84a 100644
--- a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx
+++ b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx
@@ -130,12 +130,12 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
- ).toContain('w-[min(35rem,94vw)]');
+ ).toContain('w-[400px]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
- ).toContain('sm:w-[52rem]');
+ ).toContain('h-[400px]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
@@ -145,7 +145,12 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.getAttribute('data-ring-start-degrees'),
- ).toBe('155');
+ ).toBe('135');
+ expect(
+ screen
+ .getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
+ .getAttribute('data-ring-fill-start-degrees'),
+ ).toBe('135');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
@@ -186,12 +191,12 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByTestId('generation-hero-progress-ring-track')
.getAttribute('transform'),
- ).toBe('rotate(155 200 200)');
+ ).toBe('rotate(135 200 200)');
expect(
screen
.getByTestId('generation-hero-progress-ring-fill')
.getAttribute('transform'),
- ).toBe('rotate(155 200 200)');
+ ).toBe('rotate(135 200 200)');
expect(
screen
.getByTestId('generation-hero-progress-ring-fill')
--
2.52.0
From 5289d81baf1ddea9733190e439142eca9791f5a7 Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Wed, 27 May 2026 21:57:34 +0800
Subject: [PATCH 07/19] feat: record external generation runs
---
...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +-
.../api-server/src/big_fish/formal_assets.rs | 142 +++++++----
.../api-server/src/openai_image_generation.rs | 107 +++++++-
server-rs/crates/api-server/src/puzzle.rs | 4 +-
server-rs/crates/api-server/src/tracking.rs | 49 ++++
.../generation.rs | 240 ++++++++++++------
.../crates/api-server/src/wooden_fish.rs | 4 +-
.../crates/module-puzzle/src/application.rs | 18 +-
.../platform-hyper3d/src/response/tests.rs | 2 +-
server-rs/crates/spacetime-client/src/lib.rs | 18 +-
.../crates/spacetime-client/src/mapper.rs | 18 +-
.../crates/spacetime-client/src/puzzle.rs | 15 +-
.../crates/spacetime-module/src/puzzle.rs | 6 +-
13 files changed, 457 insertions(+), 169 deletions(-)
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 206c98b51..8bfe2e6ce 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -159,7 +159,7 @@ npm run check:server-rs-ddd
## 外部服务与资产
- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。
-- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
+- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
@@ -168,6 +168,7 @@ npm run check:server-rs-ddd
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
+- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
## SpacetimeDB 表目录
diff --git a/server-rs/crates/api-server/src/big_fish/formal_assets.rs b/server-rs/crates/api-server/src/big_fish/formal_assets.rs
index f11beed27..e32766115 100644
--- a/server-rs/crates/api-server/src/big_fish/formal_assets.rs
+++ b/server-rs/crates/api-server/src/big_fish/formal_assets.rs
@@ -1,4 +1,5 @@
use super::*;
+use crate::tracking::record_external_generation_run_after_success;
struct BigFishDashScopeSettings {
base_url: String,
@@ -39,52 +40,99 @@ pub(super) async fn generate_big_fish_formal_asset(
motion_key: Option<&str>,
generated_at_micros: i64,
) -> Result
{
- let session = state
- .spacetime_client()
- .get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
- .await
- .map_err(map_big_fish_client_error)?;
- let draft = session.draft.as_ref().ok_or_else(|| {
- AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
- "provider": "big-fish",
- "message": "玩法草稿尚未编译,不能生成正式图片。",
- }))
- })?;
- let context = build_big_fish_formal_asset_context(
- &session,
- draft,
- asset_kind,
- level,
- motion_key,
- generated_at_micros,
- )?;
- let settings = require_big_fish_dashscope_settings(state)?;
- let http_client = build_big_fish_dashscope_http_client(&settings)?;
- let generated = create_big_fish_text_to_image_generation(
- &http_client,
- &settings,
- context.prompt.as_str(),
- context.negative_prompt.as_str(),
- context.size.as_str(),
- )
- .await?;
- let downloaded = download_big_fish_remote_image(
- &http_client,
- generated.image_url.as_str(),
- "下载 Big Fish 正式图片失败",
- context.apply_transparent_background_post_process,
- )
- .await?;
+ let started_at_micros = current_utc_micros();
+ let request_payload = json!({
+ "assetKind": asset_kind,
+ "level": level,
+ "motionKey": motion_key,
+ "sessionId": session_id,
+ "ownerUserId": owner_user_id,
+ });
+ let outcome = async {
+ let session = state
+ .spacetime_client()
+ .get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
+ .await
+ .map_err(map_big_fish_client_error)?;
+ let draft = session.draft.as_ref().ok_or_else(|| {
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": "big-fish",
+ "message": "玩法草稿尚未编译,不能生成正式图片。",
+ }))
+ })?;
+ let context = build_big_fish_formal_asset_context(
+ &session,
+ draft,
+ asset_kind,
+ level,
+ motion_key,
+ generated_at_micros,
+ )?;
+ let settings = require_big_fish_dashscope_settings(state)?;
+ let http_client = build_big_fish_dashscope_http_client(&settings)?;
+ let generated = create_big_fish_text_to_image_generation(
+ &http_client,
+ &settings,
+ context.prompt.as_str(),
+ context.negative_prompt.as_str(),
+ context.size.as_str(),
+ )
+ .await?;
+ let downloaded = download_big_fish_remote_image(
+ &http_client,
+ generated.image_url.as_str(),
+ "下载 Big Fish 正式图片失败",
+ context.apply_transparent_background_post_process,
+ )
+ .await?;
- persist_big_fish_formal_asset(
- state,
- owner_user_id,
- &context,
- generated,
- downloaded,
- generated_at_micros,
- )
- .await
+ persist_big_fish_formal_asset(
+ state,
+ owner_user_id,
+ &context,
+ generated,
+ downloaded,
+ generated_at_micros,
+ )
+ .await
+ }
+ .await;
+ match outcome {
+ Ok(value) => {
+ record_external_generation_run_after_success(
+ state,
+ "dashscope",
+ "big_fish_text_to_image",
+ "大鱼正式图片生成",
+ request_payload,
+ started_at_micros,
+ true,
+ None,
+ None,
+ Some(json!({
+ "legacyPublicPath": value.clone(),
+ })),
+ )
+ .await;
+ Ok(value)
+ }
+ Err(error) => {
+ record_external_generation_run_after_success(
+ state,
+ "dashscope",
+ "big_fish_text_to_image",
+ "大鱼正式图片生成",
+ request_payload,
+ started_at_micros,
+ false,
+ Some(error.to_string()),
+ None,
+ None,
+ )
+ .await;
+ Err(error)
+ }
+ }
}
fn build_big_fish_formal_asset_context(
@@ -626,6 +674,10 @@ fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> App
}))
}
+fn current_utc_micros() -> i64 {
+ (time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
+}
+
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs
index 1c191fb21..c4e2a0f08 100644
--- a/server-rs/crates/api-server/src/openai_image_generation.rs
+++ b/server-rs/crates/api-server/src/openai_image_generation.rs
@@ -8,6 +8,7 @@ use platform_image::{
vector_engine_images_generation_url,
};
use serde_json::{Value, json};
+use time::OffsetDateTime;
use crate::{
external_api_audit::{
@@ -16,6 +17,7 @@ use crate::{
},
http_error::AppError,
state::AppState,
+ tracking::record_external_generation_run_after_success,
};
pub(crate) use platform_image::GPT_IMAGE_2_MODEL;
@@ -105,6 +107,14 @@ pub(crate) async fn create_openai_image_generation(
reference_images: &[String],
failure_context: &str,
) -> Result {
+ let started_at_micros = current_utc_micros();
+ let request_payload = json!({
+ "size": size,
+ "candidateCount": candidate_count,
+ "promptChars": prompt.chars().count(),
+ "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
+ "referenceImageCount": reference_images.len(),
+ });
let result = create_vector_engine_image_generation(
http_client,
&settings.provider_settings(),
@@ -116,7 +126,15 @@ pub(crate) async fn create_openai_image_generation(
failure_context,
)
.await;
- map_platform_image_result(settings, result).await
+ map_platform_image_result(
+ settings,
+ result,
+ "image_generation",
+ failure_context,
+ request_payload,
+ started_at_micros,
+ )
+ .await
}
pub(crate) async fn create_openai_image_edit(
@@ -128,6 +146,13 @@ pub(crate) async fn create_openai_image_edit(
reference_image: &OpenAiReferenceImage,
failure_context: &str,
) -> Result {
+ let started_at_micros = current_utc_micros();
+ let request_payload = json!({
+ "size": size,
+ "promptChars": prompt.chars().count(),
+ "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
+ "referenceImageCount": 1,
+ });
let result = create_vector_engine_image_edit(
http_client,
&settings.provider_settings(),
@@ -138,7 +163,15 @@ pub(crate) async fn create_openai_image_edit(
failure_context,
)
.await;
- map_platform_image_result(settings, result).await
+ map_platform_image_result(
+ settings,
+ result,
+ "image_edit",
+ failure_context,
+ request_payload,
+ started_at_micros,
+ )
+ .await
}
pub(crate) async fn create_openai_image_edit_with_references(
@@ -151,6 +184,14 @@ pub(crate) async fn create_openai_image_edit_with_references(
reference_images: &[OpenAiReferenceImage],
failure_context: &str,
) -> Result {
+ let started_at_micros = current_utc_micros();
+ let request_payload = json!({
+ "size": size,
+ "candidateCount": candidate_count,
+ "promptChars": prompt.chars().count(),
+ "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
+ "referenceImageCount": reference_images.len(),
+ });
let result = create_vector_engine_image_edit_with_references(
http_client,
&settings.provider_settings(),
@@ -162,7 +203,15 @@ pub(crate) async fn create_openai_image_edit_with_references(
failure_context,
)
.await;
- map_platform_image_result(settings, result).await
+ map_platform_image_result(
+ settings,
+ result,
+ "image_edit_with_references",
+ failure_context,
+ request_payload,
+ started_at_micros,
+ )
+ .await
}
pub(crate) async fn download_remote_image(
@@ -200,19 +249,57 @@ impl OpenAiImageSettings {
}
}
-async fn map_platform_image_result(
+async fn map_platform_image_result(
settings: &OpenAiImageSettings,
- result: Result,
-) -> Result {
+ result: Result,
+ operation: &'static str,
+ failure_context: &str,
+ request_payload: Value,
+ started_at_micros: i64,
+) -> Result {
match result {
- Ok(value) => Ok(value),
+ Ok(value) => {
+ if let Some(state) = settings.external_api_audit_state.as_ref() {
+ record_external_generation_run_after_success(
+ state,
+ VECTOR_ENGINE_PROVIDER,
+ operation,
+ failure_context,
+ request_payload,
+ started_at_micros,
+ true,
+ None,
+ Some(value.task_id.clone()),
+ Some(json!({
+ "imageCount": value.images.len(),
+ "actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
+ })),
+ )
+ .await;
+ }
+ Ok(value)
+ }
Err(error) => {
+ if let Some(state) = settings.external_api_audit_state.as_ref() {
+ record_external_generation_run_after_success(
+ state,
+ VECTOR_ENGINE_PROVIDER,
+ operation,
+ failure_context,
+ request_payload,
+ started_at_micros,
+ false,
+ Some(error.message().to_string()),
+ None,
+ None,
+ )
+ .await;
+ }
record_openai_image_failure_if_configured(settings, &error).await;
Err(map_platform_image_error(error))
}
}
}
-
pub(crate) async fn record_openai_image_failure_if_configured(
settings: &OpenAiImageSettings,
error: &PlatformImageError,
@@ -457,3 +544,7 @@ mod tests {
);
}
}
+
+fn current_utc_micros() -> i64 {
+ (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
+}
diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs
index 56cc3b730..b4fe7b413 100644
--- a/server-rs/crates/api-server/src/puzzle.rs
+++ b/server-rs/crates/api-server/src/puzzle.rs
@@ -62,8 +62,8 @@ use spacetime_client::{
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
- PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
- PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
+ PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
+ PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs
index 82670c353..f878902a2 100644
--- a/server-rs/crates/api-server/src/tracking.rs
+++ b/server-rs/crates/api-server/src/tracking.rs
@@ -53,6 +53,55 @@ struct RouteTrackingSpec {
scope_id: &'static str,
}
+pub async fn record_external_generation_run_after_success(
+ state: &AppState,
+ provider: &str,
+ operation: &str,
+ request_label: &str,
+ request_payload: Value,
+ started_at_micros: i64,
+ success: bool,
+ failure_reason: Option,
+ provider_request_id: Option,
+ result_payload: Option,
+) {
+ let completed_at_micros = current_utc_micros();
+ let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000;
+ let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation");
+ draft.scope_kind = RuntimeTrackingScopeKind::Module;
+ draft.scope_id = provider.to_string();
+ draft.metadata = json!({
+ "runId": format!("external-generation-{}", Uuid::new_v4()),
+ "provider": provider,
+ "operation": operation,
+ "requestLabel": request_label.trim(),
+ "requestPayload": request_payload,
+ "status": if success { "succeeded" } else { "failed" },
+ "success": success,
+ "failureReason": failure_reason,
+ "providerRequestId": provider_request_id,
+ "resultPayload": result_payload,
+ "startedAtMicros": started_at_micros,
+ "completedAtMicros": completed_at_micros,
+ "durationMs": duration_ms,
+ });
+
+ record_tracking_event_after_success(state, &external_generation_request_context(), draft).await;
+}
+
+fn current_utc_micros() -> i64 {
+ (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
+}
+
+fn external_generation_request_context() -> RequestContext {
+ RequestContext::new(
+ format!("external-generation-{}", Uuid::new_v4()),
+ "external generation run".to_string(),
+ std::time::Duration::ZERO,
+ false,
+ )
+}
+
pub async fn record_route_tracking_event_after_success(
state: &AppState,
request_context: &RequestContext,
diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs
index a48dff16f..7aca57919 100644
--- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs
+++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs
@@ -1,9 +1,12 @@
+use serde_json::json;
use shared_contracts::creation_audio;
-use crate::{http_error::AppError, state::AppState};
+use crate::{
+ http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success,
+};
use super::{
- clock::current_utc_iso_text,
+ clock::{current_utc_iso_text, current_utc_micros},
errors::{map_platform_audio_error, vector_engine_bad_gateway},
publish::wait_for_generated_audio_asset,
tasks::{create_background_music_task_response, create_sound_effect_task_response},
@@ -18,45 +21,69 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
seed: Option,
target: GeneratedCreationAudioTarget,
) -> Result {
+ let started_at_micros = current_utc_micros();
let normalized_prompt = platform_audio::normalize_limited_text(
&prompt,
"prompt",
platform_audio::VIDU_PROMPT_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
- let task =
- create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
- let target = AudioAssetBindingTarget {
- storage_scope: target.entity_kind.clone(),
- entity_kind: target.entity_kind,
- entity_id: target.entity_id,
- slot: target.slot,
- asset_kind: target.asset_kind,
- profile_id: target.profile_id,
- storage_prefix: target.storage_prefix,
- };
- let generated = wait_for_generated_audio_asset(
- state,
- owner_user_id,
- task.task_id.clone(),
- AudioAssetSlot::SoundEffect,
- target,
- )
- .await?;
- let audio_src = generated
- .audio_src
- .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
+ let request_payload = json!({
+ "kind": "sound_effect",
+ "promptChars": normalized_prompt.chars().count(),
+ "duration": duration,
+ "seed": seed,
+ "targetEntityKind": target.entity_kind,
+ "targetEntityId": target.entity_id,
+ "targetSlot": target.slot,
+ "targetAssetKind": target.asset_kind,
+ });
+ let outcome = async {
+ let task =
+ create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed)
+ .await?;
+ let target = AudioAssetBindingTarget {
+ storage_scope: target.entity_kind.clone(),
+ entity_kind: target.entity_kind,
+ entity_id: target.entity_id,
+ slot: target.slot,
+ asset_kind: target.asset_kind,
+ profile_id: target.profile_id,
+ storage_prefix: target.storage_prefix,
+ };
+ let generated = wait_for_generated_audio_asset(
+ state,
+ owner_user_id,
+ task.task_id.clone(),
+ AudioAssetSlot::SoundEffect,
+ target,
+ )
+ .await?;
+ let audio_src = generated
+ .audio_src
+ .ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
- Ok(creation_audio::CreationAudioAsset {
- task_id: generated.task_id,
- provider: generated.provider,
- asset_object_id: generated.asset_object_id,
- asset_kind: generated.asset_kind,
- audio_src,
- prompt: Some(normalized_prompt),
- title: None,
- updated_at: Some(current_utc_iso_text()),
- })
+ Ok::<_, AppError>(creation_audio::CreationAudioAsset {
+ task_id: generated.task_id,
+ provider: generated.provider,
+ asset_object_id: generated.asset_object_id,
+ asset_kind: generated.asset_kind,
+ audio_src,
+ prompt: Some(normalized_prompt),
+ title: None,
+ updated_at: Some(current_utc_iso_text()),
+ })
+ }
+ .await;
+ record_creation_audio_generation_run(
+ state,
+ "sound_effect",
+ request_payload,
+ started_at_micros,
+ &outcome,
+ )
+ .await;
+ outcome
}
pub(crate) async fn generate_background_music_asset_for_creation(
@@ -68,6 +95,7 @@ pub(crate) async fn generate_background_music_asset_for_creation(
model: Option,
target: GeneratedCreationAudioTarget,
) -> Result {
+ let started_at_micros = current_utc_micros();
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
&prompt,
"prompt",
@@ -80,43 +108,111 @@ pub(crate) async fn generate_background_music_asset_for_creation(
platform_audio::SUNO_TITLE_MAX_CHARS,
)
.map_err(map_platform_audio_error)?;
- let task = create_background_music_task_response(
- state,
- normalized_prompt.clone(),
- normalized_title.clone(),
- tags,
- model,
- )
- .await?;
- let target = AudioAssetBindingTarget {
- storage_scope: target.entity_kind.clone(),
- entity_kind: target.entity_kind,
- entity_id: target.entity_id,
- slot: target.slot,
- asset_kind: target.asset_kind,
- profile_id: target.profile_id,
- storage_prefix: target.storage_prefix,
- };
- let generated = wait_for_generated_audio_asset(
- state,
- owner_user_id,
- task.task_id.clone(),
- AudioAssetSlot::BackgroundMusic,
- target,
- )
- .await?;
- let audio_src = generated
- .audio_src
- .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
+ let request_payload = json!({
+ "kind": "background_music",
+ "promptChars": normalized_prompt.chars().count(),
+ "titleChars": normalized_title.chars().count(),
+ "hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()),
+ "model": model,
+ "targetEntityKind": target.entity_kind,
+ "targetEntityId": target.entity_id,
+ "targetSlot": target.slot,
+ "targetAssetKind": target.asset_kind,
+ });
+ let outcome = async {
+ let task = create_background_music_task_response(
+ state,
+ normalized_prompt.clone(),
+ normalized_title.clone(),
+ tags,
+ model,
+ )
+ .await?;
+ let target = AudioAssetBindingTarget {
+ storage_scope: target.entity_kind.clone(),
+ entity_kind: target.entity_kind,
+ entity_id: target.entity_id,
+ slot: target.slot,
+ asset_kind: target.asset_kind,
+ profile_id: target.profile_id,
+ storage_prefix: target.storage_prefix,
+ };
+ let generated = wait_for_generated_audio_asset(
+ state,
+ owner_user_id,
+ task.task_id.clone(),
+ AudioAssetSlot::BackgroundMusic,
+ target,
+ )
+ .await?;
+ let audio_src = generated
+ .audio_src
+ .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
- Ok(creation_audio::CreationAudioAsset {
- task_id: generated.task_id,
- provider: generated.provider,
- asset_object_id: generated.asset_object_id,
- asset_kind: generated.asset_kind,
- audio_src,
- prompt: Some(normalized_prompt),
- title: Some(normalized_title),
- updated_at: Some(current_utc_iso_text()),
- })
+ Ok::<_, AppError>(creation_audio::CreationAudioAsset {
+ task_id: generated.task_id,
+ provider: generated.provider,
+ asset_object_id: generated.asset_object_id,
+ asset_kind: generated.asset_kind,
+ audio_src,
+ prompt: Some(normalized_prompt),
+ title: Some(normalized_title),
+ updated_at: Some(current_utc_iso_text()),
+ })
+ }
+ .await;
+ record_creation_audio_generation_run(
+ state,
+ "background_music",
+ request_payload,
+ started_at_micros,
+ &outcome,
+ )
+ .await;
+ outcome
+}
+
+async fn record_creation_audio_generation_run(
+ state: &AppState,
+ operation: &'static str,
+ request_payload: serde_json::Value,
+ started_at_micros: i64,
+ outcome: &Result,
+) {
+ match outcome {
+ Ok(asset) => {
+ record_external_generation_run_after_success(
+ state,
+ asset.provider.as_str(),
+ operation,
+ "创作音频生成",
+ request_payload,
+ started_at_micros,
+ true,
+ None,
+ Some(asset.task_id.clone()),
+ Some(json!({
+ "assetObjectId": asset.asset_object_id,
+ "assetKind": asset.asset_kind,
+ "hasAudioSrc": !asset.audio_src.trim().is_empty(),
+ })),
+ )
+ .await;
+ }
+ Err(error) => {
+ record_external_generation_run_after_success(
+ state,
+ "vector-engine-audio",
+ operation,
+ "创作音频生成",
+ request_payload,
+ started_at_micros,
+ false,
+ Some(error.to_string()),
+ None,
+ None,
+ )
+ .await;
+ }
+ }
}
diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs
index 7763ea0e2..580555b59 100644
--- a/server-rs/crates/api-server/src/wooden_fish.rs
+++ b/server-rs/crates/api-server/src/wooden_fish.rs
@@ -20,8 +20,8 @@ use shared_contracts::wooden_fish::{
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
- WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
- WoodenFishWorksResponse,
+ WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorksResponse,
+ WoodenFishWorkspaceCreateRequest,
};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError;
diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs
index 0c0db1a15..df8b1c4fa 100644
--- a/server-rs/crates/module-puzzle/src/application.rs
+++ b/server-rs/crates/module-puzzle/src/application.rs
@@ -1811,7 +1811,10 @@ pub fn select_runtime_next_profile<'a>(
prefer_similar_work: bool,
) -> Option<&'a PuzzleWorkProfile> {
if prefer_similar_work {
- similar_work_profiles.first().copied().or(same_work_next_profile)
+ similar_work_profiles
+ .first()
+ .copied()
+ .or(same_work_next_profile)
} else {
same_work_next_profile.or_else(|| similar_work_profiles.first().copied())
}
@@ -3281,7 +3284,10 @@ mod tests {
assert_eq!(failed.generation_status, "failed");
assert_eq!(failed.levels[0].generation_status, "failed");
assert_eq!(failed.levels[1].generation_status, "ready");
- assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png"));
+ assert_eq!(
+ failed.levels[1].cover_image_src.as_deref(),
+ Some("/ready.png")
+ );
}
#[test]
@@ -3338,12 +3344,8 @@ mod tests {
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]);
let similar_work_profiles = [&similar_work];
- let selected = select_runtime_next_profile(
- Some(&same_work),
- &similar_work_profiles,
- true,
- )
- .expect("should select similar work first");
+ let selected = select_runtime_next_profile(Some(&same_work), &similar_work_profiles, true)
+ .expect("should select similar work first");
assert_eq!(selected.profile_id, "similar");
}
diff --git a/server-rs/crates/platform-hyper3d/src/response/tests.rs b/server-rs/crates/platform-hyper3d/src/response/tests.rs
index e93b1b941..e4016dfd0 100644
--- a/server-rs/crates/platform-hyper3d/src/response/tests.rs
+++ b/server-rs/crates/platform-hyper3d/src/response/tests.rs
@@ -1,11 +1,11 @@
use serde_json::json;
use shared_contracts::hyper3d as contract;
+use super::status::normalize_task_status;
use super::{
build_submit_response, extract_download_files, extract_job_statuses,
resolve_hyper3d_overall_status,
};
-use super::status::normalize_task_status;
#[test]
fn extracts_submit_response_from_nested_payload() {
diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs
index f8a5c00a4..f6bb3217c 100644
--- a/server-rs/crates/spacetime-client/src/lib.rs
+++ b/server-rs/crates/spacetime-client/src/lib.rs
@@ -53,15 +53,15 @@ pub use mapper::{
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
- PuzzleFormDraftRecord,
- PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
- PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
- PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
- PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
- PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
- PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
- PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
- PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
+ PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
+ PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
+ PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
+ PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
+ PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
+ PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
+ PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
+ PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
+ PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs
index 1dde49d09..3d6fd06a1 100644
--- a/server-rs/crates/spacetime-client/src/mapper.rs
+++ b/server-rs/crates/spacetime-client/src/mapper.rs
@@ -102,15 +102,15 @@ pub use self::puzzle::{
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
- PuzzleFormDraftRecord,
- PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
- PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
- PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
- PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
- PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
- PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
- PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
- PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
+ PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
+ PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
+ PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
+ PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
+ PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
+ PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
+ PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
+ PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
+ PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
};
diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs
index 0d730c0f5..25ec5ad9a 100644
--- a/server-rs/crates/spacetime-client/src/puzzle.rs
+++ b/server-rs/crates/spacetime-client/src/puzzle.rs
@@ -183,15 +183,12 @@ impl SpacetimeClient {
move |connection, sender| {
connection
.procedures()
- .mark_puzzle_draft_generation_failed_then(
- procedure_input,
- move |_, result| {
- let mapped = result
- .map_err(SpacetimeClientError::from_sdk_error)
- .and_then(map_puzzle_agent_session_procedure_result);
- send_once(&sender, mapped);
- },
- );
+ .mark_puzzle_draft_generation_failed_then(procedure_input, move |_, result| {
+ let mapped = result
+ .map_err(SpacetimeClientError::from_sdk_error)
+ .and_then(map_puzzle_agent_session_procedure_result);
+ send_once(&sender, mapped);
+ });
},
)
.await
diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs
index bb71ba1ef..e5eb0da22 100644
--- a/server-rs/crates/spacetime-module/src/puzzle.rs
+++ b/server-rs/crates/spacetime-module/src/puzzle.rs
@@ -10,8 +10,8 @@ use module_puzzle::{
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
- PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
- PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
+ PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput,
+ PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
@@ -2193,7 +2193,7 @@ fn advance_puzzle_next_level_tx(
&similar_work_profiles,
input.prefer_similar_work,
)
- .ok_or_else(|| "没有可用的下一关候选".to_string())?;
+ .ok_or_else(|| "没有可用的下一关候选".to_string())?;
let mut next_run = if similar_work_next_profile.is_some() {
module_puzzle::advance_to_new_work_first_level_at(
¤t_run,
--
2.52.0
From 48dd96d5cd21b8cad1d46e29063c5586435f97ee Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Wed, 27 May 2026 22:44:01 +0800
Subject: [PATCH 08/19] fix: prevent reused account ownership for orphan works
---
.hermes/shared-memory/decision-log.md | 8 +
.hermes/shared-memory/pitfalls.md | 8 +
...发运维】本地开发验证与生产运维-2026-05-15.md | 21 ++-
scripts/rebind-orphan-work-owners.mjs | 177 ++++++++++++++++++
scripts/rebind-orphan-work-owners.test.ts | 42 +++++
.../crates/api-server/src/bark_battle.rs | 13 +-
server-rs/crates/api-server/src/state.rs | 11 ++
.../crates/api-server/src/work_author.rs | 78 +++++++-
server-rs/crates/module-auth/src/domain.rs | 16 ++
server-rs/crates/module-auth/src/lib.rs | 101 +++++++++-
10 files changed, 450 insertions(+), 25 deletions(-)
create mode 100644 scripts/rebind-orphan-work-owners.mjs
create mode 100644 scripts/rebind-orphan-work-owners.test.ts
diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 1495b21d3..730465ac5 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -1071,6 +1071,14 @@
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 2026-05-27 微信新用户用户名与孤儿作品作者回退收口
+
+- 背景:用户数据清空后,旧作品的 `owner_user_id` 可能落到空洞或顺序号账号上,新注册用户会错误顶替历史作品;同时微信新用户默认用户名过于固定,不便于区分 openid。
+- 决策:微信新用户的用户名统一改为 `名字_openid`,内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格;作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,显示名固定为 `失效作者`,公开陶泥号固定为 `SY-00000000`。
+- 影响范围:`module-auth`、`api-server` 作品作者解析、`AppState` 启动初始化、历史孤儿作品离线回填脚本与相关文档。
+- 验证方式:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
+- 关联文档:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/api-server/src/work_author.rs`、`scripts/rebind-orphan-work-owners.mjs`。
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index 4529c30cc..c83d63dbd 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -1615,6 +1615,14 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile profile page matches the reference layout sections|profile scan action opens camera scanner instead of recharge panel"`。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 微信历史孤儿作品不要让新注册账号顶替
+
+- 现象:清空用户数据或迁移历史数据后,旧作品的 `owner_user_id` 为空或失效,新注册用户会因为顺序号复用或旧 ID 残留顶替作品归属,导致刚注册就看到别人的草稿或已发布作品。
+- 原因:作品作者解析曾经把缺失作者简单回退到普通登录用户,且微信新用户用户名 / 内部 ID 都太容易被误认或复用。
+- 处理:作品作者找不到真实账号时统一回退到占位作者 `wx-openid-placeholder`,展示名固定为 `失效作者`;微信新用户用户名改为 `名字_openid`,内部 `user_id` 改成不可复用的 UUID 风格;离线回填时先识别真实有效用户,再把孤儿作品表写回占位账号。
+- 验证:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`、`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。
+- 关联:`server-rs/crates/api-server/src/work_author.rs`、`server-rs/crates/module-auth/src/domain.rs`、`scripts/rebind-orphan-work-owners.mjs`。
+
## 访客推荐页上下滑不要绑定登录态
- 现象:访客模式进入移动端推荐页后,推荐内容可展示和点击底部“下一个”,但在作品信息区域上下滑不会切换推荐作品,表现为推荐页不能上下滑动。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 29769429f..5a5b07db9 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -1,4 +1,4 @@
-# 本地开发验证与生产运维
+# 本地开发验证与生产运维
更新时间:`2026-05-15`
@@ -413,4 +413,23 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
当前 `docs/` 只保留少量融合文档。新增稳定知识时优先更新现有文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。阶段性流水账、一次性修复记录和已关闭实验不要再新增成长期文档。
+## 微信登录与孤儿作品归属处理
+- 微信新用户的 `username` 统一拼为 `名字_openid`;若昵称或 openid 为空,则分别回退到 `微信旅人` 和 `openid`。
+- 微信新用户的内部 `user_id` 改为不可复用的 `user_` 前缀 UUID 风格,避免清库后旧作品被后来的顺序号账号顶替。
+- 当作品作者的 `owner_user_id` 找不到真实账号时,作品统一显示为占位作者:`失效作者`,公开陶泥号固定为 `SY-00000000`,占位账号 ID 为 `wx-openid-placeholder`。
+- 该占位账号只用于作品作者域,不扩展到全站其它身份域。
+- 如需把历史孤儿作品批量回填到占位作者,使用 `scripts/rebind-orphan-work-owners.mjs` 先基于当前 auth 快照识别有效用户,再把缺失作者对应的作品表写回为占位 ID;脚本输入输出都基于 SpacetimeDB 迁移 JSON。
+
+### 回填脚本用法
+
+```bash
+node scripts/rebind-orphan-work-owners.mjs --in --out
+node scripts/rebind-orphan-work-owners.mjs --in --dry-run
+node scripts/rebind-orphan-work-owners.mjs --in --out --placeholder-user-id wx-openid-placeholder
+```
+
+- `--in`:SpacetimeDB 导出的迁移 JSON。
+- `--out`:写回后的迁移 JSON 输出路径。
+- `--dry-run`:只统计回填行数,不写文件。
+- `--placeholder-user-id`:需要时可覆盖默认占位账号 ID。
diff --git a/scripts/rebind-orphan-work-owners.mjs b/scripts/rebind-orphan-work-owners.mjs
new file mode 100644
index 000000000..c3d6d356c
--- /dev/null
+++ b/scripts/rebind-orphan-work-owners.mjs
@@ -0,0 +1,177 @@
+#!/usr/bin/env node
+
+import { readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+
+export const DEFAULT_ORPHAN_WORK_OWNER_USER_ID = 'wx-openid-placeholder';
+
+export const WORK_OWNER_TABLES = [
+ 'custom_world_profile',
+ 'custom_world_gallery_entry',
+ 'custom_world_session',
+ 'custom_world_agent_session',
+ 'custom_world_draft_card',
+ 'puzzle_agent_session',
+ 'puzzle_work_profile',
+ 'bark_battle_draft_config',
+ 'bark_battle_published_config',
+ 'match3d_agent_session',
+ 'match3d_work_profile',
+ 'jump_hop_agent_session',
+ 'jump_hop_work_profile',
+ 'wooden_fish_agent_session',
+ 'wooden_fish_work_profile',
+ 'square_hole_agent_session',
+ 'square_hole_work_profile',
+ 'visual_novel_agent_session',
+ 'visual_novel_work_profile',
+ 'big_fish_creation_session',
+];
+
+const ROW_KEY_FIELDS = ['profile_id', 'work_id', 'session_id', 'draft_id', 'gallery_entry_id', 'id'];
+
+if (isCliEntry()) {
+ runCli(process.argv.slice(2)).catch((error) => {
+ console.error(
+ `[rebind-orphan-work-owners] ${error instanceof Error ? error.message : String(error)}`,
+ );
+ process.exit(1);
+ });
+}
+
+export function rebindOrphanWorkOwnersInMigration(
+ migration,
+ { placeholderUserId = DEFAULT_ORPHAN_WORK_OWNER_USER_ID, validUserIds = [] } = {},
+) {
+ if (!migration || !Array.isArray(migration.tables)) {
+ throw new Error('迁移 JSON 必须包含 tables 数组。');
+ }
+
+ const normalizedPlaceholderUserId = placeholderUserId.trim();
+ const validUserIdSet = new Set(
+ (Array.isArray(validUserIds) ? validUserIds : [])
+ .map((value) => String(value).trim())
+ .filter(Boolean),
+ );
+ validUserIdSet.add(normalizedPlaceholderUserId);
+
+ const reboundRows = [];
+ for (const table of migration.tables) {
+ if (!table || !WORK_OWNER_TABLES.includes(table.name) || !Array.isArray(table.rows)) {
+ continue;
+ }
+
+ for (const row of table.rows) {
+ if (!row || typeof row !== 'object') {
+ continue;
+ }
+ const currentOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id.trim() : '';
+ if (currentOwner === normalizedPlaceholderUserId || validUserIdSet.has(currentOwner)) {
+ continue;
+ }
+
+ const originalOwner = typeof row.owner_user_id === 'string' ? row.owner_user_id : '';
+ row.owner_user_id = normalizedPlaceholderUserId;
+ reboundRows.push({
+ table: table.name,
+ rowKey: resolveRowKey(row),
+ from: originalOwner,
+ to: normalizedPlaceholderUserId,
+ });
+ }
+ }
+
+ return { reboundRows, validUserCount: validUserIdSet.size };
+}
+
+function resolveRowKey(row) {
+ for (const field of ROW_KEY_FIELDS) {
+ const value = row[field];
+ if (typeof value === 'string' && value.trim()) {
+ return value;
+ }
+ }
+ return '';
+}
+
+async function runCli(argv) {
+ const options = parseCliArgs(argv);
+ const inputPath = path.resolve(options.in);
+ const outputPath = path.resolve(options.out);
+ const migration = JSON.parse(await readFile(inputPath, 'utf8'));
+ const result = rebindOrphanWorkOwnersInMigration(migration, {
+ placeholderUserId: options.placeholderUserId,
+ validUserIds: collectValidUserIds(migration),
+ });
+
+ if (!options.dryRun) {
+ await writeFile(outputPath, `${JSON.stringify(migration, null, 2)}\n`, 'utf8');
+ }
+
+ console.log(
+ `[rebind-orphan-work-owners] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},回填 ${result.reboundRows.length} 行`,
+ );
+}
+
+function parseCliArgs(argv) {
+ const options = {
+ in: '',
+ out: '',
+ placeholderUserId: DEFAULT_ORPHAN_WORK_OWNER_USER_ID,
+ dryRun: false,
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ const readValue = (name) => {
+ const value = argv[index + 1];
+ if (!value || value.startsWith('--')) {
+ throw new Error(`${name} 缺少参数值。`);
+ }
+ index += 1;
+ return value;
+ };
+
+ if (arg === '--in') {
+ options.in = readValue(arg);
+ } else if (arg === '--out') {
+ options.out = readValue(arg);
+ } else if (arg === '--placeholder-user-id') {
+ options.placeholderUserId = readValue(arg);
+ } else if (arg === '--dry-run') {
+ options.dryRun = true;
+ } else {
+ throw new Error(`未知参数: ${arg}`);
+ }
+ }
+
+ if (!options.in) {
+ throw new Error('必须传入 --in。');
+ }
+ if (!options.out && !options.dryRun) {
+ throw new Error('非 dry-run 必须传入 --out。');
+ }
+ return options;
+}
+
+function collectValidUserIds(migration) {
+ const result = new Set();
+ for (const table of migration.tables ?? []) {
+ if (!table || !Array.isArray(table.rows)) {
+ continue;
+ }
+ if (table.name === 'user_account') {
+ for (const row of table.rows) {
+ if (typeof row?.user_id === 'string' && row.user_id.trim()) {
+ result.add(row.user_id.trim());
+ }
+ }
+ }
+ }
+ return result;
+}
+
+function isCliEntry() {
+ const entry = process.argv[1];
+ return entry ? import.meta.url === `file://${entry.replace(/\\/gu, '/')}` : false;
+}
diff --git a/scripts/rebind-orphan-work-owners.test.ts b/scripts/rebind-orphan-work-owners.test.ts
new file mode 100644
index 000000000..b57335860
--- /dev/null
+++ b/scripts/rebind-orphan-work-owners.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { rebindOrphanWorkOwnersInMigration } from './rebind-orphan-work-owners.mjs';
+
+const placeholderUserId = 'wx-openid-placeholder';
+
+function table(name, rows) {
+ return { name, rows };
+}
+
+describe('rebindOrphanWorkOwnersInMigration', () => {
+ it('把作品表里认证表不存在的 owner_user_id 回填到占位用户', () => {
+ const migration = {
+ schema_version: 1,
+ exported_at_micros: 1,
+ tables: [
+ table('user_account', [{ user_id: 'user_alive' }, { user_id: placeholderUserId }]),
+ table('puzzle_work_profile', [
+ { profile_id: 'p1', owner_user_id: 'user_missing' },
+ { profile_id: 'p2', owner_user_id: 'user_alive' },
+ { profile_id: 'p3', owner_user_id: placeholderUserId },
+ ]),
+ table('puzzle_agent_session', [{ session_id: 'draft-1', owner_user_id: '' }]),
+ table('tracking_event', [{ event_id: 't1', owner_user_id: 'user_missing' }]),
+ ],
+ };
+
+ const result = rebindOrphanWorkOwnersInMigration(migration, {
+ placeholderUserId,
+ validUserIds: ['user_alive'],
+ });
+
+ expect(result.reboundRows).toEqual([
+ { table: 'puzzle_work_profile', rowKey: 'p1', from: 'user_missing', to: placeholderUserId },
+ { table: 'puzzle_agent_session', rowKey: 'draft-1', from: '', to: placeholderUserId },
+ ]);
+ expect(migration.tables[1].rows[0].owner_user_id).toBe(placeholderUserId);
+ expect(migration.tables[1].rows[1].owner_user_id).toBe('user_alive');
+ expect(migration.tables[1].rows[2].owner_user_id).toBe(placeholderUserId);
+ expect(migration.tables[2].rows[0].owner_user_id).toBe(placeholderUserId);
+ expect(migration.tables[3].rows[0].owner_user_id).toBe('user_missing');
+ });
+});
diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs
index 4610df88d..beb9d9402 100644
--- a/server-rs/crates/api-server/src/bark_battle.rs
+++ b/server-rs/crates/api-server/src/bark_battle.rs
@@ -51,6 +51,7 @@ use crate::{
platform_errors::map_oss_error,
request_context::RequestContext,
state::AppState,
+ work_author::resolve_work_author_by_user_id,
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
};
@@ -1015,17 +1016,7 @@ fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &
}
fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String {
- let display_name = if owner_user_id.trim().is_empty() {
- None
- } else {
- state
- .auth_user_service()
- .get_user_by_id(owner_user_id)
- .ok()
- .flatten()
- .map(|user| user.display_name)
- };
- normalize_author_display_name(display_name)
+ resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name
}
fn normalize_author_display_name(display_name: Option) -> String {
diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs
index 37a25b0a1..6dcf8b9c9 100644
--- a/server-rs/crates/api-server/src/state.rs
+++ b/server-rs/crates/api-server/src/state.rs
@@ -35,6 +35,9 @@ use crate::puzzle_gallery_cache::PuzzleGalleryCache;
use crate::tracking_outbox::TrackingOutbox;
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
use crate::wechat_provider::build_wechat_provider;
+use crate::work_author::{
+ ORPHAN_WORK_AUTHOR_DISPLAY_NAME, ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE, ORPHAN_WORK_OWNER_USER_ID,
+};
const ADMIN_ROLE: &str = "admin";
@@ -361,6 +364,14 @@ impl AppState {
)?)?;
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let auth_user_service = AuthUserService::new(auth_store.clone());
+ auth_user_service
+ .ensure_orphan_work_owner_user(
+ ORPHAN_WORK_OWNER_USER_ID,
+ ORPHAN_WORK_OWNER_USER_ID,
+ ORPHAN_WORK_AUTHOR_DISPLAY_NAME,
+ ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE,
+ )
+ .map_err(|error| AppStateInitError::AuthStore(error.to_string()))?;
let phone_auth_service = PhoneAuthService::new(auth_store.clone(), sms_provider);
let wechat_auth_state_service =
WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes);
diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs
index 38b4bea61..2afc24475 100644
--- a/server-rs/crates/api-server/src/work_author.rs
+++ b/server-rs/crates/api-server/src/work_author.rs
@@ -2,6 +2,10 @@ use module_auth::AuthUser;
use crate::state::{AppState, PuzzleApiState};
+pub const ORPHAN_WORK_OWNER_USER_ID: &str = "wx-openid-placeholder";
+pub const ORPHAN_WORK_AUTHOR_DISPLAY_NAME: &str = "失效作者";
+pub const ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE: &str = "SY-00000000";
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkAuthorSummary {
pub display_name: String,
@@ -45,21 +49,15 @@ fn resolve_work_author_by_user_id_with_service(
) -> WorkAuthorSummary {
let fallback_display_name =
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
- let fallback_public_user_code = normalize_optional_text(fallback_public_user_code);
+ let _fallback_public_user_code = normalize_optional_text(fallback_public_user_code);
let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
- return WorkAuthorSummary {
- display_name: fallback_display_name,
- public_user_code: fallback_public_user_code,
- };
+ return orphan_work_author_summary();
};
match auth_user_service.get_user_by_id(&owner_user_id) {
Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
- Ok(None) | Err(_) => WorkAuthorSummary {
- display_name: fallback_display_name,
- public_user_code: fallback_public_user_code,
- },
+ Ok(None) | Err(_) => orphan_work_author_summary(),
}
}
@@ -80,3 +78,65 @@ fn normalize_optional_text(value: Option<&str>) -> Option {
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
+
+fn orphan_work_author_summary() -> WorkAuthorSummary {
+ WorkAuthorSummary {
+ display_name: ORPHAN_WORK_AUTHOR_DISPLAY_NAME.to_string(),
+ public_user_code: Some(ORPHAN_WORK_AUTHOR_PUBLIC_USER_CODE.to_string()),
+ }
+}
+
+/// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id,避免把有效作品误转给占位账号。
+pub fn should_rebind_orphan_work_owner(
+ auth_user_service: &module_auth::AuthUserService,
+ owner_user_id: &str,
+) -> bool {
+ let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else {
+ return true;
+ };
+ if owner_user_id == ORPHAN_WORK_OWNER_USER_ID {
+ return false;
+ }
+
+ !matches!(auth_user_service.get_user_by_id(&owner_user_id), Ok(Some(_)))
+}
+
+#[cfg(test)]
+mod tests {
+ use module_auth::{AuthUserService, InMemoryAuthStore};
+
+ use super::*;
+
+ #[test]
+ fn orphan_work_author_summary_uses_placeholder_account() {
+ assert_eq!(
+ orphan_work_author_summary(),
+ WorkAuthorSummary {
+ display_name: "失效作者".to_string(),
+ public_user_code: Some("SY-00000000".to_string()),
+ }
+ );
+ }
+
+ #[test]
+ fn missing_author_resolves_to_placeholder_account() {
+ let service = AuthUserService::new(InMemoryAuthStore::default());
+
+ let author = resolve_work_author_by_user_id_with_service(
+ &service,
+ "user_missing",
+ Some("历史昵称"),
+ Some("SY-00000001"),
+ );
+
+ assert_eq!(author, orphan_work_author_summary());
+ }
+ #[test]
+ fn should_rebind_orphan_work_owner_detects_missing_and_empty_author() {
+ let service = AuthUserService::new(InMemoryAuthStore::default());
+
+ assert!(should_rebind_orphan_work_owner(&service, ""));
+ assert!(should_rebind_orphan_work_owner(&service, "user_missing"));
+ assert!(!should_rebind_orphan_work_owner(&service, ORPHAN_WORK_OWNER_USER_ID));
+ }
+}
diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs
index d8057f813..07077e80e 100644
--- a/server-rs/crates/module-auth/src/domain.rs
+++ b/server-rs/crates/module-auth/src/domain.rs
@@ -235,6 +235,22 @@ pub fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
+pub fn build_wechat_username(display_name: &str, provider_uid: &str) -> String {
+ let normalized_display_name = display_name.trim();
+ let normalized_provider_uid = provider_uid.trim();
+ let fallback_display_name = if normalized_display_name.is_empty() {
+ "微信旅人"
+ } else {
+ normalized_display_name
+ };
+ let fallback_provider_uid = if normalized_provider_uid.is_empty() {
+ "openid"
+ } else {
+ normalized_provider_uid
+ };
+ format!("{fallback_display_name}_{fallback_provider_uid}")
+}
+
// 公开陶泥号是稳定的公开检索键,不替代内部 user_id,仅用于展示、分享与搜索。
pub fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs
index c88e92748..46cbd15be 100644
--- a/server-rs/crates/module-auth/src/lib.rs
+++ b/server-rs/crates/module-auth/src/lib.rs
@@ -800,6 +800,21 @@ impl AuthUserService {
Self { store }
}
+ pub fn ensure_orphan_work_owner_user(
+ &self,
+ user_id: &str,
+ username: &str,
+ display_name: &str,
+ public_user_code: &str,
+ ) -> Result {
+ self.store.ensure_orphan_work_owner_user(
+ user_id,
+ username,
+ display_name,
+ public_user_code,
+ )
+ }
+
pub fn get_user_by_id(&self, user_id: &str) -> Result