});
fireEvent.touchEnd(card);
- expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
+ expect(
+ container.querySelector('.creation-work-card__swipe-button--danger'),
+ ).toBeTruthy();
expect(onOpenDraft).not.toHaveBeenCalled();
});
diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx
index 392282c4..00f38323 100644
--- a/src/components/custom-world-home/CustomWorldWorkCard.tsx
+++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx
@@ -676,43 +676,75 @@ export function CustomWorldWorkCard({
{displayTitle}
- {canUseShareAction ? (
-
- ) : null}
+
+ {canUseShareAction ? (
+
+ ) : null}
+ {onDelete ? (
+
+ ) : null}
+
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
index 48a1278c..301342ea 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 d1807475..5d82171f 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;
From 9cc36ea99ccd59c21b247016ea34565716ee8825 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com>
Date: Wed, 27 May 2026 21:56:19 +0800
Subject: [PATCH 4/7] Refine creation progress and wooden fish runtime
---
.hermes/shared-memory/decision-log.md | 16 +++
.hermes/shared-memory/pitfalls.md | 12 +-
...玩法创作】敲木鱼玩法模板PRD-2026-05-20.md | 6 +-
...玩法创作】生成页圆环布局口径-2026-05-23.md | 6 +-
.../crates/api-server/src/wooden_fish.rs | 11 +-
.../spacetime-module/src/wooden_fish.rs | 135 +++++++++++++++++-
.../CustomWorldGenerationView.test.tsx | 15 +-
src/components/GenerationProgressHero.tsx | 25 +++-
.../BarkBattleGeneratingView.test.tsx | 15 +-
9 files changed, 209 insertions(+), 32 deletions(-)
diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 1495b21d..f1e7957c 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -16,6 +16,14 @@
---
+## 2026-05-27 生成页总进度圆弧锁定固定画布
+
+- 背景:多轮圆环角度微调后,`GenerationProgressHero` 的 SVG 圆弧仍会出现底部开口偏斜的问题,且圆环还会随着容器宽度伸缩,导致 UI 看起来时大时小、位置漂移。
+- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角固定为 `135deg`,轨道和橘黄色填充都从同一个对称起点 `rotate(135 200 200)` 出发;`270deg` 扫描角配合正下方 `90deg` 留空,圆环本体改为固定 `400x400` 画布,不再跟随页面宽度缩放,外层布局只负责定位,不负责改动圆环样式。
+- 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。
+- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=135`、`data-ring-fill-start-degrees=135`,且圆环容器固定为 `h-[400px] w-[400px]`,track / fill transform 都是 `rotate(135 200 200)`。
+- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。
+
## 2026-05-26 平台跨流程错误统一用可复制来源弹窗展示
- 背景:拼图等生成链路可能同时存在多个草稿或游玩实例,页面内裸错误 banner 容易让用户误以为当前正在看的拼图失败,也不方便复制完整错误给开发排查。
@@ -201,6 +209,14 @@
- 验证方式:背景 prompt 单测应包含中央禁区硬约束,试玩图中央不再出现苹果或其它主题主体。
- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 2026-05-27 敲木鱼背景 prompt 不再写中央木鱼预设
+
+- 背景:背景 prompt 曾写入“木鱼预设在屏幕中央位置”,与“背景图中不包含新木鱼物品”“中央 40% 禁止出现主题主体”直接冲突,导致 image2 偶发把静态木鱼画回背景中心。
+- 决策:背景 prompt 只能写“中央主体预留区”“运行态叠放敲击物的留白区域”“只生成背景环境图”,不得再出现“木鱼预设在屏幕中央位置”或任何等价的中心主体正向描述。
+- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、敲木鱼 PRD、平台链路文档、背景 prompt 单测。
+- 验证方式:`wooden_fish_background_prompt_uses_hidden_image2_flow` 必须断言旧冲突句子不存在,并断言新的中央留白表述存在。
+- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 2026-05-21 外部 API 失败必须 OTLP 上报并落库
- 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障。
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index 4529c30c..89db8320 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -170,7 +170,7 @@
- 现象:苹果等主题试玩时,中央敲击物图带明显黑底;背景图中央还可能出现苹果主体,或背景环境图偶发变成纯绿色底,和“中央只叠加 hitObjectAsset”的运行态设定冲突。
- 原因:gpt-image-2 对“透明底”和“背景只做外围氛围”的遵循不稳定。若 hit object 直接入库,黑底会被当成真实像素展示;若背景 prompt 只有软描述,模型会把主题主体画进中央。第一步为了去背刻意要求绿幕图时,如果第二步参考图或 prompt 没有切断绿幕语义,背景图也可能继承纯绿色画布。
-- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 绿色背景主体图(纯绿色绿幕、单一 `#00FF00` 背景),再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。
+- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 绿色背景主体图(纯绿色绿幕、单一 `#00FF00` 背景),再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。不要在背景 prompt 写“木鱼预设在屏幕中央位置”或类似中心主体正向描述,运行态敲击物只能由前端叠放。
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并用花朵 / 苹果 / 玉米主题跑试玩图确认绿幕被去除、主体未被抠除、背景中央不出现主题主体,背景环境图不再出现纯绿色底。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
@@ -182,6 +182,14 @@
- 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并重新试玩确认返回按钮只剩圆形底色和中央左箭头。
- 关联:`server-rs/crates/api-server/src/wooden_fish.rs`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`.
+## 敲木鱼历史已发布作品缺返回按钮要补齐,不要靠推荐过滤
+
+- 现象:推荐页或公开列表中的历史敲木鱼作品点击运行态时报 `敲木鱼运行态需要完整作品配置`,但这类作品的敲击物、背景、音效和飘字都已完整,只是 `backButtonAsset` 为空。
+- 原因:早期已发布作品缺少统一的默认返回按钮快照;运行态启动时如果仍直接按完整配置校验,就会把可玩的历史作品拒掉。这个问题不应通过推荐流或公开列表过滤解决。
+- 处理:`spacetime-module` 在 `start_wooden_fish_run_tx` 和 work snapshot 构建时,若作品已发布且 `generationStatus=ready`,但仅缺 `backButtonAsset`,就补写内置默认返回按钮 `/UI/11_left_arrow.png`,再继续进入运行态。默认返回按钮以 `bundled-default` 资产快照写回 work profile,字段保持 `assetId=wooden-fish-default-back-button`、`imageObjectKey=public/UI/11_left_arrow.png`。
+- 验证:历史木鱼作品点击运行态不再报完整作品配置缺失;第一次进入后,work profile 里应补出 `backButtonAsset`。
+- 关联:`server-rs/crates/spacetime-module/src/wooden_fish.rs`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 敲木鱼创作生成不要沿用 15 秒会话超时
- 现象:敲木鱼工作台点击“生成”后,前端直接提示 `请求超时:15000ms`,但后端和 VectorEngine 未必已经失败。
@@ -1567,6 +1575,8 @@
2026-05-24 补充:生成页“预计等待 / 已耗时”卡片本身已经有标签,传给 `GenerationProgressHero` 的值只能是纯时间,例如 `4 分钟`、`1 分 15 秒`,不要再拼接“预计还需”或“已耗时”;两张时间卡也要和当前步骤卡一样保持半透明。拼图总进度初始帧必须允许显示 `0%`,不要再用 `Math.max(1, nextProgress)` 之类的保护把启动态抬到 `1%`。
+2026-05-27 补充:`generation-hero-progress-ring-fill` 里那个橘黄色小点不是背景噪点,而是 `strokeLinecap="round"` 在短弧段上的端点;当前圆环口径要求底部 `90deg` 开口居中对称,因此轨道和填充都应使用 `135deg` 起点。圆环本体现在固定为 `400x400`,排查时先看 `data-ring-start-degrees`、`data-ring-fill-start-degrees` 和容器尺寸,不要把尺寸伸缩误认成素材渲染问题。
+
## `dev:spacetime` 启动后 3101 又断开先查 publish 是否被 spacetime.json 干扰
- 现象:浏览器报 `Failed to initiate WebSocket connection`,目标为 `ws://127.0.0.1:3101/v1/database//subscribe`,端口检查发现 `3101` 没有长期监听;手动运行 `npm run dev:spacetime` 可看到 standalone 短暂启动后退出,发布阶段报 `No database target matches ''`。
diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
index fa2c56f6..6bf23f8e 100644
--- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
+++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
@@ -166,11 +166,11 @@ WF-*
1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`;
2. multipart 参考图固定为第一步敲击物图案抠图完成后的透明图;默认未生成新敲击物时使用内置默认敲击物图案的透明兜底图;
3. 尺寸固定竖屏 `9:16`;
-4. 背景环境图只适配新敲击物主题和画风,背景中不得包含新敲击物本体,也不得增加木槌互动物品;中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;
+4. 背景环境图只适配新敲击物主题和画风,背景中不得包含新敲击物本体,也不得增加木槌互动物品;中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;运行态的敲击物只在前端叠放,不允许出现在背景图提示词里。
5. 提示词严格使用:
```text
-生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。
+生成敲木鱼背景,要求主题、画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,只生成竖屏背景环境图,不生成、不描绘、不暗示新木鱼物品本体,也不要出现木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,中央区域是运行态叠放敲击物的留白区域,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。
主题为:(用户提供参考图或用户输入关键词)
```
@@ -384,6 +384,8 @@ finish
公开列表优先消费 `wooden_fish_gallery_card_view` 订阅缓存。公开详情如果卡片摘要不足以进入运行态,必须补读完整 work profile。
+历史已发布且 `generationStatus=ready` 的木鱼作品如果仅缺 `backButtonAsset`,运行态启动前必须补齐内置默认返回按钮 `/UI/11_left_arrow.png`,并持久写回 work profile;这类历史作品不应通过推荐流过滤隐藏。
+
## 13. 验收
1. 创作入口能看到 `敲木鱼` 模板;
diff --git a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md
index ef97e819..760a55d7 100644
--- a/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md
+++ b/docs/【玩法创作】生成页圆环布局口径-2026-05-23.md
@@ -12,8 +12,8 @@
- 生成页背景视频必须留在生成页容器内部,直接作为 `fixed inset-0` 的底层背景,不要再通过 portal 挂到 `document.body`;页面根容器使用 `z-[1]`、背景容器使用 `z-0`,确保顶部导航、圆环和当前步骤卡都稳定覆盖在视频之上。
- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。
- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。
-- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
-- 圆弧描边以圆心为中心整体按 `155deg` 起始;在当前 SVG 坐标系下,这相对 `160deg` 会向左逆时针回调 `5deg`。track 和 fill 都必须共用同一个 `rotate(155 200 200)` 变换,避免只改视觉起点却让填充和轨道错位。
+- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。圆环本体固定在 `400x400` 的 SVG 画布上,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再跟随页面宽度缩放,也不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
+- 圆弧描边以圆心为中心整体按 `135deg` 起始;`270deg` 扫描角配合 `90deg` 正下方缺口时,轨道和填充都从同一个对称起点出发,轨道保持 `rotate(135 200 200)`,填充端点也使用 `rotate(135 200 200)`。圆环本体尺寸固定,不允许再随容器边长伸缩,只能由外层布局决定放置位置。
- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。
- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。
@@ -27,5 +27,5 @@
- `src/components/CustomWorldGenerationView.test.tsx` 覆盖圆环主视觉和单步卡片。
- `src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx` 覆盖汪汪声浪生成页对齐后的圆环布局。
- 两个生成页都应在测试里断言页面根容器层级高于背景视频容器,且背景视频确实是页面子节点,避免 portal 背景把业务 UI 压住。
-- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[4%]`,圆弧 DOM 为 SVG,包含清晰的 track/fill circle 描边。
+- 还应断言圆弧正下方留空、圆环中心没有独立底色块,时间卡和总进度字号缩小后仍能在桌面与移动端正常排版;同时断言时间卡 `text-center`、标题行 `justify-center`、总进度内容区上移到 `pt-[2%]`,桌面端保持 `sm:pt-[1.5%]`,圆弧 DOM 为 SVG,包含清晰的 track/fill circle 描边。
- 页面在桌面和移动端都不应再出现生成步骤列表块,圆环和当前步骤卡不能被外层卡片嵌套出双层面板感。
diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs
index 7763ea0e..4a818996 100644
--- a/server-rs/crates/api-server/src/wooden_fish.rs
+++ b/server-rs/crates/api-server/src/wooden_fish.rs
@@ -758,7 +758,7 @@ fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String {
fn build_wooden_fish_background_prompt(prompt: &str) -> String {
format!(
- "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}",
+ "生成敲木鱼背景,要求主题、画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,只生成竖屏背景环境图,不生成、不描绘、不暗示新木鱼物品本体,也不要出现木槌互动物品。尺寸竖屏9:16。参考图必须是第一步敲击物抠图完成后的透明图,不继承任何绿色底色、绿幕底色或纯绿色画布,并要求最终输出完整不透明的背景环境图。中央主体预留区必须保持干净,中央区域是运行态叠放敲击物的留白区域,画面中央 40% 区域禁止出现主题主体、主体局部特写、主体轮廓影子、重复元素或主题主体的局部碎片;主题元素只允许出现在外围氛围,不得把主题物品画在画面中央,也不要把主题物品作为背景中心装饰。\n主题为:{}",
clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT)
)
}
@@ -1228,14 +1228,17 @@ mod tests {
fn wooden_fish_background_prompt_uses_hidden_image2_flow() {
let prompt = build_wooden_fish_background_prompt("苹果");
- assert!(prompt.contains(
- "生成敲木鱼背景,要求主题,画风与参考图保持高度一致,背景元素和颜色搭配与主题对应,木鱼预设在屏幕中央位置,木鱼主体周围元素保持干净,背景氛围围绕外围设计,背景环境图中不包含新木鱼物品,背景氛围中不增加木槌互动物品。"
- ));
+ assert!(prompt.contains("只生成竖屏背景环境图"));
+ assert!(prompt.contains("不生成、不描绘、不暗示新木鱼物品本体"));
+ assert!(prompt.contains("不要出现木槌互动物品"));
+ assert!(!prompt.contains("木鱼预设在屏幕中央位置"));
+ assert!(!prompt.contains("木鱼主体周围元素保持干净"));
assert!(prompt.contains("尺寸竖屏9:16"));
assert!(prompt.contains("抠图完成后的透明图"));
assert!(prompt.contains("不继承任何绿色底色"));
assert!(prompt.contains("完整不透明的背景环境图"));
assert!(prompt.contains("中央主体预留区"));
+ assert!(prompt.contains("中央区域是运行态叠放敲击物的留白区域"));
assert!(prompt.contains("禁止出现主题主体"));
assert!(prompt.contains("苹果"));
assert!(prompt.contains("不得把主题物品画在画面中央"));
diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs
index a8ef6954..18232d9e 100644
--- a/server-rs/crates/spacetime-module/src/wooden_fish.rs
+++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs
@@ -13,6 +13,10 @@ use serde::Serialize;
use serde::de::DeserializeOwned;
use spacetimedb::AnonymousViewContext;
+const DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID: &str = "wooden-fish-default-back-button";
+const DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_SRC: &str = "/UI/11_left_arrow.png";
+const DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_OBJECT_KEY: &str = "public/UI/11_left_arrow.png";
+
#[spacetimedb::view(accessor = wooden_fish_gallery_view, public)]
pub fn wooden_fish_gallery_view(ctx: &AnonymousViewContext) -> Vec {
let mut items = ctx
@@ -593,10 +597,14 @@ fn start_wooden_fish_run_tx(
input: WoodenFishRunStartInput,
) -> Result {
require_non_empty(&input.run_id, "wooden_fish run_id")?;
- let work = find_work(ctx, &input.profile_id)?;
+ let stored_work = find_work(ctx, &input.profile_id)?;
+ let work = backfill_historical_runtime_content(&stored_work);
if !is_publish_ready(&work) {
return Err("敲木鱼运行态需要完整作品配置".to_string());
}
+ if work.back_button_asset_json != stored_work.back_button_asset_json {
+ replace_work(ctx, &stored_work, clone_work(&work));
+ }
let snapshot = WoodenFishRunSnapshot {
run_id: input.run_id.clone(),
profile_id: input.profile_id.clone(),
@@ -740,6 +748,7 @@ fn build_session_snapshot(
}
fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result {
+ let row = backfill_historical_runtime_content(row);
Ok(WoodenFishWorkSnapshot {
work_id: row.work_id.clone(),
profile_id: row.profile_id.clone(),
@@ -775,7 +784,7 @@ fn build_work_snapshot(row: &WoodenFishWorkProfileRow) -> Result bool {
+ is_publish_ready_except_back_button(row)
+ && row
+ .back_button_asset_json
+ .as_deref()
+ .and_then(clean_optional)
+ .is_some()
+}
+
+fn is_publish_ready_except_back_button(row: &WoodenFishWorkProfileRow) -> bool {
!row.work_title.trim().is_empty()
&& !row.hit_object_asset_json.trim().is_empty()
&& row
@@ -1016,14 +1034,40 @@ fn is_publish_ready(row: &WoodenFishWorkProfileRow) -> bool {
.as_deref()
.and_then(clean_optional)
.is_some()
- && row
+ && !row.hit_sound_asset_json.trim().is_empty()
+ && !row.floating_words_json.trim().is_empty()
+ && row.generation_status == WOODEN_FISH_GENERATION_READY
+}
+
+fn backfill_historical_runtime_content(row: &WoodenFishWorkProfileRow) -> WoodenFishWorkProfileRow {
+ if row.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED
+ || !is_publish_ready_except_back_button(row)
+ || row
.back_button_asset_json
.as_deref()
.and_then(clean_optional)
.is_some()
- && !row.hit_sound_asset_json.trim().is_empty()
- && !row.floating_words_json.trim().is_empty()
- && row.generation_status == WOODEN_FISH_GENERATION_READY
+ {
+ return clone_work(row);
+ }
+
+ WoodenFishWorkProfileRow {
+ back_button_asset_json: Some(to_json_string(&default_wooden_fish_back_button_asset())),
+ ..clone_work(row)
+ }
+}
+
+fn default_wooden_fish_back_button_asset() -> WoodenFishImageAssetSnapshot {
+ WoodenFishImageAssetSnapshot {
+ asset_id: DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID.to_string(),
+ image_src: DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_SRC.to_string(),
+ image_object_key: DEFAULT_WOODEN_FISH_BACK_BUTTON_IMAGE_OBJECT_KEY.to_string(),
+ asset_object_id: DEFAULT_WOODEN_FISH_BACK_BUTTON_ASSET_ID.to_string(),
+ generation_provider: "bundled-default".to_string(),
+ prompt: "历史敲木鱼默认返回按钮".to_string(),
+ width: 28,
+ height: 28,
+ }
}
fn default_config_from_input(
@@ -1288,3 +1332,82 @@ fn clone_run(row: &WoodenFishRuntimeRunRow) -> WoodenFishRuntimeRunRow {
updated_at: row.updated_at,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn historical_published_work_without_back_button_gets_runtime_backfill() {
+ let row = published_ready_work_without_back_button();
+
+ assert!(!is_publish_ready(&row));
+ let repaired = backfill_historical_runtime_content(&row);
+ let snapshot = build_work_snapshot(&repaired).expect("历史作品补齐后应可映射运行态快照");
+
+ assert!(is_publish_ready(&repaired));
+ assert!(snapshot.publish_ready);
+ assert_eq!(
+ snapshot
+ .back_button_asset
+ .as_ref()
+ .map(|asset| asset.image_src.as_str()),
+ Some("/UI/11_left_arrow.png")
+ );
+ }
+
+ fn published_ready_work_without_back_button() -> WoodenFishWorkProfileRow {
+ let now = Timestamp::from_micros_since_unix_epoch(1_770_000_000_000_000);
+ WoodenFishWorkProfileRow {
+ profile_id: "wooden-fish-profile-history".to_string(),
+ work_id: "wooden-fish-profile-history".to_string(),
+ owner_user_id: "user-history".to_string(),
+ source_session_id: "wooden-fish-session-history".to_string(),
+ author_display_name: "敲木鱼玩家".to_string(),
+ work_title: "今日敲木鱼".to_string(),
+ work_description: String::new(),
+ theme_tags_json: to_json_string(&vec!["敲木鱼".to_string(), "解压".to_string()]),
+ hit_object_prompt: "默认敲击物图案,圆润木质质感,透明背景".to_string(),
+ hit_object_reference_image_src: String::new(),
+ hit_sound_prompt: String::new(),
+ hit_object_asset_json: to_json_string(&WoodenFishImageAssetSnapshot {
+ asset_id: "wooden-fish-hit-object-history".to_string(),
+ image_src: "/wooden-fish/default-hit-object.png".to_string(),
+ image_object_key: "public/wooden-fish/default-hit-object.png".to_string(),
+ asset_object_id: "wooden-fish-hit-object-history".to_string(),
+ generation_provider: "bundled-default".to_string(),
+ prompt: "默认敲击物图案".to_string(),
+ width: 1024,
+ height: 1024,
+ }),
+ hit_sound_asset_json: to_json_string(&WoodenFishAudioAssetSnapshot {
+ asset_id: "wooden-fish-hit-sound-history".to_string(),
+ audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(),
+ audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
+ asset_object_id: "wooden-fish-hit-sound-history".to_string(),
+ source: "bundled-default".to_string(),
+ prompt: Some("默认木鱼音".to_string()),
+ duration_ms: Some(3_000),
+ }),
+ floating_words_json: to_json_string(&default_floating_words()),
+ cover_image_src: "/wooden-fish/default-hit-object.png".to_string(),
+ generation_status: WOODEN_FISH_GENERATION_READY.to_string(),
+ publication_status: WOODEN_FISH_PUBLICATION_PUBLISHED.to_string(),
+ play_count: 0,
+ updated_at: now,
+ published_at: Some(now),
+ background_asset_json: Some(to_json_string(&WoodenFishImageAssetSnapshot {
+ asset_id: "wooden-fish-background-history".to_string(),
+ image_src: "/generated-wooden-fish-assets/history/background/image.png".to_string(),
+ image_object_key: "generated-wooden-fish-assets/history/background/image.png"
+ .to_string(),
+ asset_object_id: "wooden-fish-background-history".to_string(),
+ generation_provider: "image2".to_string(),
+ prompt: "历史背景".to_string(),
+ width: 1024,
+ height: 1536,
+ })),
+ back_button_asset_json: None,
+ }
+ }
+}
diff --git a/src/components/CustomWorldGenerationView.test.tsx b/src/components/CustomWorldGenerationView.test.tsx
index d20da96d..14971ec7 100644
--- a/src/components/CustomWorldGenerationView.test.tsx
+++ b/src/components/CustomWorldGenerationView.test.tsx
@@ -142,17 +142,22 @@ describe('CustomWorldGenerationView', () => {
screen
.getByRole('progressbar', { name: progressTitle })
.className,
- ).toContain('w-[min(35rem,94vw)]');
+ ).toContain('w-[400px]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
- ).toContain('sm:w-[52rem]');
+ ).toContain('h-[400px]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-start-degrees'),
- ).toBe('155');
+ ).toBe('135');
+ expect(
+ screen
+ .getByRole('progressbar', { name: progressTitle })
+ .getAttribute('data-ring-fill-start-degrees'),
+ ).toBe('135');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
@@ -193,12 +198,12 @@ describe('CustomWorldGenerationView', () => {
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')
diff --git a/src/components/GenerationProgressHero.tsx b/src/components/GenerationProgressHero.tsx
index 9fa0af3a..369258a6 100644
--- a/src/components/GenerationProgressHero.tsx
+++ b/src/components/GenerationProgressHero.tsx
@@ -4,8 +4,16 @@ import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
-const GENERATION_PROGRESS_RING_START_DEGREES = 155;
-const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270;
+const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
+const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
+// 中文注释:SVG 圆从 3 点钟方向起笔;起点放在 135deg,可让 90deg 开口居中落在正下方。
+const GENERATION_PROGRESS_RING_START_DEGREES =
+ GENERATION_PROGRESS_RING_BOTTOM_DEGREES +
+ GENERATION_PROGRESS_RING_GAP_DEGREES / 2;
+const GENERATION_PROGRESS_RING_FILL_START_DEGREES =
+ GENERATION_PROGRESS_RING_START_DEGREES;
+const GENERATION_PROGRESS_RING_SWEEP_DEGREES =
+ 360 - GENERATION_PROGRESS_RING_GAP_DEGREES;
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
const GENERATION_PROGRESS_RING_RADIUS = 166;
@@ -118,7 +126,9 @@ export function GenerationProgressHero({
const safeProgress = clampGenerationProgress(progressValue);
const ringGradientId = useId().replace(/:/g, '');
const ringMetrics = buildGenerationRingMetrics(safeProgress);
- const ringDegrees = Math.round((safeProgress / 100) * 270);
+ const ringDegrees = Math.round(
+ (safeProgress / 100) * GENERATION_PROGRESS_RING_SWEEP_DEGREES,
+ );
const ringTrackDasharray = `${ringMetrics.sweepLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
@@ -160,16 +170,19 @@ export function GenerationProgressHero({
diff --git a/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx b/src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx
index e2d25871..e8a0ef84 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')
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 5/7] 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 206c98b5..8bfe2e6c 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 f11beed2..e3276611 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 1c191fb2..c4e2a0f0 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 56cc3b73..b4fe7b41 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 82670c35..f878902a 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 a48dff16..7aca5791 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 7763ea0e..580555b5 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 0c0db1a1..df8b1c4f 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 e93b1b94..e4016dfd 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 f8a5c00a..f6bb3217 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 1dde49d0..3d6fd06a 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 0d730c0f..25ec5ad9 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 bb71ba1e..e5eb0da2 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,
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 6/7] 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 1495b21d..730465ac 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 4529c30c..c83d63db 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 29769429..5a5b07db 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 00000000..c3d6d356
--- /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 00000000..b5733586
--- /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 4610df88..beb9d940 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 37a25b0a..6dcf8b9c 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 38b4bea6..2afc2447 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 d8057f81..07077e80 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 c88e9274..46cbd15b 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