diff --git a/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs new file mode 100644 index 00000000..63e35176 --- /dev/null +++ b/.codex/skills/gpt-image-2-apimart/scripts/generate-anthro-cat-illustrations.mjs @@ -0,0 +1,351 @@ +import { Buffer } from 'node:buffer'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const skillRoot = path.resolve(__dirname, '..'); +const repoRoot = path.resolve(skillRoot, '..', '..', '..'); +const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations'); +const defaultTimeoutMs = 180000; + +const prompts = [ + { + id: 'cat-barista', + title: '咖啡师猫咪', + subject: + '一只奶油色猫咪像人一样双足站立,穿深绿色围裙,在温暖咖啡馆吧台前专注拉花,爪子扶着咖啡杯,蓬松尾巴自然弯起,童书级精致插画,柔和自然光,主体清晰。', + }, + { + id: 'cat-detective', + title: '侦探猫咪', + subject: + '一只黑白猫咪像侦探一样双足站在雨后街角,穿短风衣和小帽子,单爪拿放大镜,另一只爪插兜,路灯和湿润石板路反光,电影感但可爱,插画风格。', + }, + { + id: 'cat-dancer', + title: '舞者猫咪', + subject: + '一只橘猫以拟人舞者姿态单脚旋转,穿轻盈舞台披肩,前爪展开,尾巴形成优雅弧线,背景是暖色小剧场灯光,动作灵动,精致插画。', + }, + { + id: 'cat-knight', + title: '骑士猫咪', + subject: + '一只银灰猫咪像小骑士一样站在苔藓石台上,披短斗篷,双爪握着细剑指向地面,姿态勇敢但可亲,远处森林微光,奇幻插画风格。', + }, + { + id: 'cat-painter', + title: '画家猫咪', + subject: + '一只三花猫咪双足站在画架前,穿宽松蓝色工作衫,一爪拿画笔一爪托调色盘,鼻尖有颜料点,窗边画室阳光明亮,温柔手绘插画。', + }, + { + id: 'cat-astronaut', + title: '宇航员猫咪', + subject: + '一只白猫咪以拟人宇航员姿态站在月面,透明头盔内露出猫脸,尾巴在宇航服后轻轻翘起,爪子向远处蓝色星球敬礼,梦幻插画风格。', + }, +]; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 1) { + const raw = process.argv[index]; + if (raw.startsWith('--')) { + const next = process.argv[index + 1]; + if (next && !next.startsWith('--')) { + args.set(raw, next); + index += 1; + } else { + args.set(raw, true); + } + } +} + +function readDotenv(fileName) { + const filePath = path.join(repoRoot, fileName); + if (!existsSync(filePath)) { + return {}; + } + + const values = {}; + for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed); + if (!match) { + continue; + } + let value = match[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values[match[1]] = value; + } + return values; +} + +function resolveEnv() { + const loaded = { + ...readDotenv('.env.example'), + ...readDotenv('.env.local'), + ...readDotenv('.env.secrets.local'), + ...process.env, + }; + return { + baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') + .trim() + .replace(/\/+$/u, ''), + apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), + timeoutMs: Number.parseInt( + String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + 10, + ), + }; +} + +function buildVectorEngineImagesGenerationUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/generations` + : `${baseUrl}/v1/images/generations`; +} + +function buildPrompt(entry) { + return [ + '请生成一张高清 1:1 方形插画。', + `画面主体:${entry.subject}`, + '要求:猫咪保留清晰猫脸、猫耳、猫尾和毛发质感,但身体姿态像人一样自然;构图完整,角色占画面主体,适合作为项目插画素材。', + '避免:文字、水印、边框、按钮、UI 元素、低清晰度、过度写实恐怖感、畸形肢体、多余手指。', + ].join(''); +} + +function collectStringsByKey(value, targetKey, output) { + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); + return; + } + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, nested] of Object.entries(value)) { + if (key === targetKey) { + if (typeof nested === 'string' && nested.trim()) { + output.push(nested.trim()); + } + if (Array.isArray(nested)) { + nested.forEach((entry) => { + if (typeof entry === 'string' && entry.trim()) { + output.push(entry.trim()); + } + }); + } + } + collectStringsByKey(nested, targetKey, output); + } +} + +function extractImageUrls(payload) { + const urls = []; + collectStringsByKey(payload, 'url', urls); + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'image_url', urls); + return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url)); +} + +function extractBase64Images(payload) { + const values = []; + collectStringsByKey(payload, 'b64_json', values); + return values; +} + +function inferExtensionFromContentType(contentType) { + const normalized = contentType.split(';')[0]?.trim().toLowerCase(); + if (normalized === 'image/png') { + return 'png'; + } + if (normalized === 'image/webp') { + return 'webp'; + } + if (normalized === 'image/gif') { + return 'gif'; + } + return 'jpg'; +} + +function inferExtensionFromBytes(bytes) { + if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) { + return 'png'; + } + if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) { + return 'jpg'; + } + if ( + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'webp'; + } + return 'png'; +} + +async function fetchJson(url, options, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: abortController.signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + } + return JSON.parse(text); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +async function downloadUrl(url, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: abortController.signal }); + if (!response.ok) { + throw new Error(`download ${response.status}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + return { + bytes, + extension: inferExtensionFromContentType( + response.headers.get('content-type') || 'image/jpeg', + ), + }; + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`Generated image download timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +async function generateOne(env, entry, outDir) { + const requestBody = { + model: 'gpt-image-2-all', + prompt: buildPrompt(entry), + n: 1, + size: '1024x1024', + }; + const payload = await fetchJson( + buildVectorEngineImagesGenerationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }, + env.timeoutMs, + ); + + const urls = extractImageUrls(payload); + const b64Images = extractBase64Images(payload); + + let image; + if (urls[0]) { + image = await downloadUrl(urls[0], env.timeoutMs); + } else if (b64Images[0]) { + const bytes = Buffer.from(b64Images[0], 'base64'); + image = { + bytes, + extension: inferExtensionFromBytes(bytes), + }; + } else { + throw new Error(`VectorEngine returned no image for ${entry.id}`); + } + + mkdirSync(outDir, { recursive: true }); + const outputPath = path.join(outDir, `${entry.id}.${image.extension}`); + writeFileSync(outputPath, image.bytes); + return outputPath; +} + +const dryRun = args.has('--dry-run') || !args.has('--live'); +const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir)); +const limit = Number.parseInt(String(args.get('--limit') || '0'), 10); +const selectedPrompts = limit > 0 ? prompts.slice(0, limit) : prompts; + +if (dryRun) { + const env = resolveEnv(); + console.log( + JSON.stringify( + { + mode: 'dry-run', + outDir, + count: selectedPrompts.length, + hasBaseUrl: Boolean(env.baseUrl), + hasApiKey: Boolean(env.apiKey), + requests: selectedPrompts.map((entry) => ({ + id: entry.id, + title: entry.title, + body: { + model: 'gpt-image-2-all', + prompt: buildPrompt(entry), + n: 1, + size: '1024x1024', + }, + })), + }, + null, + 2, + ), + ); + process.exit(0); +} + +const env = resolveEnv(); +if (!env.baseUrl || !env.apiKey) { + console.error( + JSON.stringify({ + ok: false, + error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', + hasBaseUrl: Boolean(env.baseUrl), + hasApiKey: Boolean(env.apiKey), + }), + ); + process.exit(1); +} + +const generated = []; +for (const entry of selectedPrompts) { + console.log(`Generating ${entry.id}...`); + generated.push(await generateOne(env, entry, outDir)); +} + +console.log( + JSON.stringify( + { + ok: true, + count: generated.length, + files: generated, + }, + null, + 2, + ), +); diff --git a/.env b/.env new file mode 100644 index 00000000..e1ed925f --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# 微信小程序 web-view 登录配置。 +# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。 +WECHAT_MINI_PROGRAM_APP_ID="" +WECHAT_MINI_PROGRAM_APP_SECRET="" +WECHAT_JS_CODE_SESSION_ENDPOINT="" diff --git a/.env.example b/.env.example index 74c656dc..482669b7 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/" WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect" WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token" WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo" +WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session" +WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token" +WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber" WECHAT_STATE_TTL_MINUTES="15" WECHAT_MOCK_USER_ID="wx-mock-user" WECHAT_MOCK_UNION_ID="wx-mock-union" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 52963332..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - - master - -jobs: - verify: - runs-on: ubuntu-latest - timeout-minutes: 20 - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20.19.0 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Check encoding - run: npm run check:encoding - - - name: Lint - run: npm run lint:eslint - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm run test - - - name: Build - run: npm run build - - - name: Validate content - run: npm run check:content diff --git a/.gitignore b/.gitignore index d752b483..8abc3e04 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,8 @@ temp*build*/ # Local load-test data extracted from private migration files scripts/loadtest/data/*.local.json + +# Local load-test run artifacts +scripts/loadtest/data/k6-*.log +scripts/loadtest/data/k6-*summary*.md +scripts/loadtest/data/latest-*-prefix.txt diff --git a/.hermes/plans/2026-05-12_0616-remote-works-list-loadtest-diagnosis.md b/.hermes/plans/2026-05-12_0616-remote-works-list-loadtest-diagnosis.md new file mode 100644 index 00000000..32b7f1e2 --- /dev/null +++ b/.hermes/plans/2026-05-12_0616-remote-works-list-loadtest-diagnosis.md @@ -0,0 +1,206 @@ +# 远端作品列表压测排查报告 + +时间:2026-05-12 06:16 CST +目标:`http://82.157.175.59` +SSH:远端生产机 root 账号(具体私钥路径仅保留在本机环境,不写入仓库) + +## 背景 + +远端 `k6-works-list.js` 压测中: + +- smoke 通过。 +- baseline 10 VU:无 HTTP 错误,但 p95/p99 超阈值。 +- 50 RPS spike:`http_req_failed` / `works_list_shape_error_rate` 约 21.99%。 +- 100 RPS spike:`http_req_failed` / `works_list_shape_error_rate` 约 25.47%。 +- 从 k6 check 看,失败主要集中在 `puzzle_gallery_list`,`custom_world_gallery_list` 基本正常。 + +## 已完成排查 + +### 1. 服务器进程与资源 + +远端服务监听: + +- Rust api-server:`127.0.0.1:8082`,systemd 服务 `genarrative-api.service`。 +- SpacetimeDB:`127.0.0.1:3101`,systemd 服务 `spacetimedb.service`。 +- Nginx:公网 80 反代 `/api/*` 到 `127.0.0.1:8082`。 + +服务器规格/状态: + +- 2 vCPU。 +- 内存约 1.9GiB。 +- Swap 约 1.9GiB,已有约 600MiB 使用。 +- `/` 磁盘约 69%。 +- Rust api-server 当前 CPU 不高。 +- SpacetimeDB 当前 CPU 不高。 + +发现一个独立异常: + +- PM2 下旧 `server-node` 进程 `genarrative` 正在重启风暴。 +- cwd:`/work/Genarrative/server-node` +- 错误:连接 `127.0.0.1:5432` PostgreSQL 被拒绝。 +- PM2 restart 次数已超过 33 万。 +- 该进程不是当前公网 `/api/*` 使用的 Rust api-server,但会制造额外 CPU/内存/日志抖动。 + +### 2. 压测窗口服务端日志 + +子任务聚合了 2026-05-12 04:50-05:05 的 nginx 与 api-server 日志。 + +nginx access: + +- `/api/runtime/puzzle/gallery`:4661 次,全部 200。 +- `/api/runtime/custom-world-gallery`:4659 次,全部 200。 + +api-server journal: + +`/api/runtime/puzzle/gallery`: + +- completed:4661 +- status:200 全部 +- slow_request:0 +- latency_ms:min 13 / p50 30 / p90 43 / p95 50 / p99 62 / max 88 + +`/api/runtime/custom-world-gallery`: + +- completed:4659 +- status:200 全部 +- slow_request:0 +- latency_ms:min 0 / p50 1 / p90 5 / p95 7 / p99 13 / max 49 + +结论: + +- 在服务端视角,两个接口在该窗口都没有 5xx,也没有慢请求。 +- 这与 k6 客户端侧 30s timeout / failed check 存在明显不一致。 +- 需要进一步区分:客户端侧网络/连接耗尽/本机 k6 执行环境问题,还是 k6 统计混合/响应解析问题。 + +### 3. k6 脚本行为 + +文件:`scripts/loadtest/k6-works-list.js` + +无 `AUTH_TOKEN` 时,每轮 iteration 顺序请求两个接口: + +1. `GET /api/runtime/puzzle/gallery` +2. `GET /api/runtime/custom-world-gallery` + +`DETAIL_RATIO=0` 时不会请求详情。 + +`works_list_shape_error_rate` 不只代表字段结构错误,只要下面任意 check 失败都会计入: + +- status is 200 +- returns json object +- has collection +- list item shape + +因此 timeout、非 JSON、非 200、响应结构不符合都会表现为 shape error。 + +数据文件实际路径: + +- `scripts/loadtest/data/works-list.local.json` + +脚本里 `data/works-list.local.json` 是相对 k6 脚本文件解析的,因此本身合理。 + +### 4. 代码层疑似瓶颈 + +虽然这次远端服务端日志没有复现慢请求,但代码层仍发现一个真实性能隐患。 + +`/api/runtime/puzzle/gallery` 调用链: + +- `server-rs/crates/api-server/src/app.rs:1192` +- `server-rs/crates/api-server/src/puzzle.rs:1385-1409` +- `server-rs/crates/spacetime-client/src/puzzle.rs:367-381` +- `server-rs/crates/spacetime-module/src/puzzle.rs:430-443` +- `server-rs/crates/spacetime-module/src/puzzle.rs:1393-1404` + +关键实现: + +- `list_puzzle_gallery_tx` 对 `puzzle_work_profile().iter()` 全表扫描。 +- 再过滤 `publication_status == Published`。 +- 对每个公开作品调用 `build_puzzle_work_profile_from_row_with_recent_count`。 +- 该函数调用 `count_recent_public_work_plays(ctx, "puzzle", &row.profile_id, now_micros)`。 + +`count_recent_public_work_plays`: + +- 文件:`server-rs/crates/spacetime-module/src/runtime/profile.rs:1296-1321` +- 当前实现对 `public_work_play_daily_stat().iter()` 全表扫描过滤。 +- 但表定义已有复合索引: + - `server-rs/crates/spacetime-module/src/runtime/profile.rs:242-248` + - `by_public_work_play_daily_stat_work_day(source_type, profile_id, played_day)` +- 当前统计函数未使用该索引。 + +复杂度风险: + +```text +puzzle gallery ~= O(puzzle_work_profile 全表扫描 + Published作品数 * public_work_play_daily_stat 全表扫描) +``` + +`custom-world-gallery` 与 puzzle 的差异: + +- custom-world 使用 `CustomWorldGalleryEntry` 公开读模型表。 +- puzzle 直接从 `puzzle_work_profile` 即席拼装。 +- 两者都调用 recent count,但 puzzle 更容易受作品表规模和统计表规模影响。 + +## 当前判断 + +本次排查有两个层面的结论: + +1. 生产服务端日志没有证明 `puzzle/gallery` 在 04:50-05:05 窗口真的 30s 慢或 5xx。 + - api-server 记录的 p95 只有 50ms。 + - nginx 看到两个接口都是 200。 + - 所以 k6 侧的 30s timeout 需要进一步从客户端网络、连接池、Windows/k6 执行环境、summary 混合统计角度验证。 + +2. 代码层确实存在可修的性能隐患。 + - `count_recent_public_work_plays` 未使用已有索引。 + - puzzle gallery 对每个作品重复做 recent count。 + - puzzle gallery 未使用 `publication_status` 索引或读模型。 + +## 建议下一步 + +### A. 先处理服务器 PM2 重启风暴 + +建议确认旧 Node 服务是否仍需要。 + +如果不需要,应停止并禁用 PM2 中的旧 `server-node`: + +```bash +PM2_HOME=/home/ubuntu/.pm2 pm2 stop genarrative +PM2_HOME=/home/ubuntu/.pm2 pm2 delete genarrative +PM2_HOME=/home/ubuntu/.pm2 pm2 save +``` + +这是生产侧操作,执行前需要确认。 + +### B. 单接口短压验证客户端/服务端不一致 + +不要继续用混合脚本大压。 + +建议新增或临时使用单接口 k6 脚本,分别只测: + +- `/api/runtime/puzzle/gallery` +- `/api/runtime/custom-world-gallery` + +并在同一时间窗口并行采集: + +- k6 客户端 summary +- nginx access 请求数/状态码 +- api-server journal latency +- 本机到服务器网络错误/timeout + +目标是确认 timeout 是不是发生在客户端侧连接/网络,而不是服务端处理慢。 + +### C. 修复代码性能隐患 + +优先级建议: + +1. `count_recent_public_work_plays` 改为使用 `by_public_work_play_daily_stat_work_day` 复合索引,或至少改成批量统计,避免 N 次全表扫描。 +2. `list_puzzle_gallery_tx` 使用 `by_puzzle_work_publication_status` 索引查询 Published,或参考 custom-world 建立 `puzzle_gallery_entry` 公开读模型。 +3. gallery 列表页不要实时逐条扫描统计表,可维护读模型或批量聚合 `recent_play_count_7d`。 + +### D. 调整 k6 脚本输出 + +建议 k6 summary 按 endpoint tag 输出或新增单接口模式,否则 overall 指标会把 puzzle/custom-world 混在一起。 + +建议增加: + +- `ENDPOINT=puzzle_gallery_list` +- `ENDPOINT=custom_world_gallery_list` + +让脚本只跑一个 endpoint,避免诊断时混淆。 diff --git a/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md b/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md new file mode 100644 index 00000000..3a5d2fe4 --- /dev/null +++ b/.hermes/plans/2026-05-13_112225-visual-novel-one-line-genarrative-plan.md @@ -0,0 +1,343 @@ +# Genarrative 视觉小说“一句话生成”最小闭环落地计划 + +生成时间:2026-05-13 11:22 +工作区:`C:/proj/Genarrative/.worktrees/hermes-visual-novel` +参考文档:`C:/Users/DSK/Documents/Interactive-fiction/一句话生成视觉小说整体流程总结.md` + +## 1. 目标 + +把 Interactive-fiction 总结文档中的“一句话生成视觉小说”流程,映射并落地到 Genarrative 现有视觉小说能力中,优先做成一个可端到端验证的最小闭环: + +1. 用户在视觉小说入口输入一句话并选择画风。 +2. 前端进入生成过程页,展示分阶段进度。 +3. 后端创建视觉小说创作会话,并基于 seedText 生成 `VisualNovelResultDraft`。 +4. 生成完成后进入草稿结果页,可看到世界观、角色、场景、剧情阶段、开场选择。 +5. 草稿可编译/保存为作品 profile,并进入视觉小说运行态测试/正式游玩。 + +本计划只覆盖 Genarrative 内部最小闭环,不引入 Interactive-fiction 原项目的独立 TXT 播放记录、分享播放包、外部活动运营、独立账号/交易/资产系统。 + +## 2. 当前上下文与已发现实现 + +### 2.1 Interactive-fiction 总结文档提炼 + +参考文档将整体流程分为: + +- 输入侧:一句话创意、主题/风格、可选文档或素材。 +- 生成侧:理解意图、扩展世界观、角色、场景、剧情阶段、开场与选择。 +- 编辑侧:草稿页可查看和调整生成结果。 +- 运行侧:从草稿进入视觉小说游玩,支持剧情推进、玩家选择、历史与状态。 +- 资产侧:角色立绘、背景、音乐/音效可作为后续增强,最小闭环可先使用文字描述与空资产占位。 + +### 2.2 Genarrative 已有实现基础 + +已确认项目中视觉小说相关能力并非从零开始: + +- 前端入口表单: + - `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` + - 已有“一句话创作” textarea、6 个视觉画风选项、提交按钮“生成视觉小说草稿”。 +- 前端入口 payload/progress: + - `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` + - 已有 `VisualNovelEntryFormPayload`、锚点展示、一句话/画风生成进度步骤。 +- 前端平台主流程: + - `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + - 已接入 `createVisualNovelDraftFromForm`,会创建 session、stream message、进入 `visual-novel-generating`,完成后进入 `visual-novel-result`。 +- 前端 API client: + - `src/services/visual-novel-creation/visualNovelCreationClient.ts` + - 已封装 session/message/action/compile 接口。 +- 共享契约: + - `packages/shared/src/contracts/visualNovel.ts` + - 已定义 `VisualNovelResultDraft`、world/characters/scenes/storyPhases/opening/runtimeConfig/work/run/history 等结构。 +- 后端 API: + - `server-rs/crates/api-server/src/visual_novel.rs` + - 已有创建 session、发消息、流式消息、执行 action、compile、work、runtime run 等接口。 +- 后端 prompt: + - `server-rs/crates/api-server/src/prompt/visual_novel.rs` + - 已有 `VISUAL_NOVEL_CREATION_SYSTEM_PROMPT`、结构化输出契约、runtime GM prompt、repair prompt。 +- SpacetimeDB 模块: + - `server-rs/crates/spacetime-module/src/visual_novel.rs` + - 已有 session/message/work/run/history/event 表与 procedure。 +- 文档参考: + - `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` + - `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md` + - `docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md` + +### 2.3 关键实现判断 + +当前项目已经实现了视觉小说的主要骨架,本次不应大规模重写。更合理的落地方式是补齐“一句话生成”闭环中最容易断裂的点: + +- 入口输入与画风信息是否被稳定传给后端 prompt。 +- 后端生成 draft 后是否自动保存/关联可编辑 work profile。 +- 生成过程页是否能清晰展示 Interactive-fiction 文档中提到的阶段。 +- 结果页是否有足够的字段展示与继续游玩入口。 +- 运行态是否能基于 opening/choices 正常启动,而不依赖尚未生成的图片/音乐资产。 + +## 3. 拟采用方案 + +### 3.1 最小闭环范围 + +本次优先实现: + +1. “一句话 + 视觉画风”作为 `sourceMode: 'idea'` 的 seedText。 +2. 后端生成完整 `VisualNovelResultDraft`,包括: + - world + - 3-6 个角色 + - 3-8 个场景 + - 3-6 个剧情阶段 + - opening narration/firstDialogue/2-4 个 choices + - runtimeConfig +3. 若 LLM 输出失败,使用 repair 或确定性 fallback,保证可回到草稿页并显示错误/警告。 +4. 结果页支持保存/编译为 work profile。 +5. work profile 支持启动 runtime run,opening 能展示初始场景、旁白、对话和选择。 + +暂不做或仅预留: + +- 真实图片/音乐生成队列。 +- 多文档解析导入的完整链路。 +- 复杂分镜/节点图编辑器。 +- 外部 Interactive-fiction 项目的播放器、TXT 记录包、分享活动、独立账号系统。 + +### 3.2 与 Genarrative 架构的映射 + +| Interactive-fiction 概念 | Genarrative 落点 | +| --- | --- | +| 一句话创意 | `VisualNovelEntryFormPayload.ideaText` / `seedText` | +| 画风/主题 | `seedText` 中的“视觉画风/画风要求”,后续可结构化为 metadata | +| 世界观设定 | `VisualNovelResultDraft.world` | +| 角色设定 | `VisualNovelResultDraft.characters` | +| 场景设定 | `VisualNovelResultDraft.scenes` | +| 剧情阶段/章节 | `VisualNovelResultDraft.storyPhases` | +| 开场文本与选项 | `VisualNovelResultDraft.opening` | +| 运行时剧情推进 | `VisualNovelRuntimeStep[]` + run snapshot/history | +| 发布/作品库 | `VisualNovelWorkProfileRecord` / works API | + +## 4. 分步计划 + +### Step 1:补齐入口 payload 与生成过程语义 + +涉及文件: + +- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +任务: + +1. 保持现有 6 个画风选项,但确认每个 option 的 prompt 会进入 `seedText`。 +2. 将生成过程阶段从当前 3 步细化为更贴合参考文档的 4-5 步,例如: + - 理解一句话创意 + - 扩展世界观与玩家身份 + - 设计角色/场景/剧情阶段 + - 生成开场与选择 + - 准备可编辑草稿 +3. 生成过程页的 anchor 保留“一句话”和“视觉画风”,必要时增加“生成目标:视觉小说草稿”。 +4. 确认 `createVisualNovelDraftFromForm` 对失败状态会保留返回入口/重试能力。 + +验收点:提交一句话后能进入 `visual-novel-generating`,看到阶段进度;完成后进入 `visual-novel-result`。 + +### Step 2:增强后端 creation prompt 与 fallback 约束 + +涉及文件: + +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `server-rs/crates/api-server/src/visual_novel.rs` +- 如已有 domain crate:`server-rs/crates/module-visual-novel/**` 或相关 normalize/validate 文件 + +任务: + +1. 在 creation prompt 中显式吸收 Interactive-fiction 的“一句话生成”目标: + - 从 seedText 提取核心创意、视觉风格、故事类型。 + - 生成可直接运行的 opening 和 choices。 + - 图片/音乐资产先置 null,但必须有可生成图像的描述。 +2. 强化输出约束: + - `opening.sceneId` 必须指向存在且 availability 为 `opening` 的 scene。 + - `opening.initialChoices` 必须 2-4 个。 + - `storyPhases[0]` 必须包含 opening scene 和主要角色。 + - `publishReady` 的判定与 validationIssues 一致。 +3. 检查 `submit_visual_novel_message_turn` / `resolve_action_draft` / compile 相关代码: + - 如果 LLM 失败,是否已有 fallback;没有则补确定性 fallback draft。 + - 如果 draft 不完整,是否会 normalize/repair 并写入 session。 +4. 保留现有“不要输出旧 TXT 播放记录、分享播放包、外部商业字段”的约束,避免把参考项目的外部概念误并入 Genarrative。 + +验收点:后端给定 seedText 时,返回 session.draft 不为空且满足共享契约。 + +### Step 3:确认草稿结果页、保存/编译与作品库链路 + +涉及文件: + +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- `src/components/visual-novel-creation/**` +- `src/services/visual-novel-works*` 或相关 visual novel works client +- `server-rs/crates/api-server/src/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +任务: + +1. 查找并确认 `visual-novel-result` 页面组件: + - 是否显示 workTitle/workDescription/world/characters/scenes/storyPhases/opening。 + - 是否有保存/发布/开始试玩按钮。 +2. 确认 `compileVisualNovelWorkProfile` 或 `executeVisualNovelAction({kind:'compile_work_profile'})` 会生成/更新 work profile。 +3. 确认作品架上使用 `profileId` 而不是 sessionId 作为稳定作品 ID。 +4. 如果结果页缺少“一句话来源/画风”的可视化提示,可在结果页或 summary 中补轻量展示,避免用户以为画风丢失。 + +验收点:生成完成后能保存为作品;作品出现在“我的作品/创作架”;再次打开能读取同一 draft。 + +### Step 4:确认运行态 opening 闭环 + +涉及文件: + +- `src/components/visual-novel-runtime/**` +- `src/services/visual-novel-runtime*` +- `server-rs/crates/api-server/src/visual_novel.rs` +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +任务: + +1. 启动 visual novel work run 时,优先使用 `draft.opening` 生成第一轮 runtime snapshot/history。 +2. 如果没有图片/音乐,前端 runtime shell 必须可用文字 fallback,不应白屏或阻断游玩。 +3. 玩家选择 `choice` 后,后端 runtime GM prompt 生成下一轮 `VisualNovelRuntimeStep[]`。 +4. 确认正式游玩入口调用 `work_play_start`,并满足已有埋点约定: + - `scope_kind=work` + - `scope_id=稳定作品 ID` + - metadata 包含 `playType/workId/sourceRoute/userId` 等。 + +验收点:从生成出的作品进入运行态,能看到 opening 并点击至少一个选择推进一轮。 + +### Step 5:补测试与文档 + +涉及文件: + +- 前端测试:按仓库现有测试布局查找 `*.test.ts` / `*.test.tsx` +- Rust 测试:`server-rs/crates/api-server/src/**` 或 domain crate tests +- 文档:可追加到 `docs/technical/` 或 `.hermes/shared-memory/decision-log.md`(如团队约定需要) + +建议测试: + +1. TypeScript 单元测试: + - `buildVisualNovelEntryGenerationProgress` 阶段输出。 + - `buildVisualNovelEntryGenerationAnchorEntries` 能展示一句话和画风。 +2. Rust 单元测试: + - creation prompt 包含 seedText、sourceMode、输出契约。 + - draft normalize/fallback 能生成合法 opening/choices。 + - runtime opening 或 first-step 构造不依赖图片/音乐。 +3. 集成/手工测试文档: + - 访问平台视觉小说入口。 + - 输入一句话。 + - 选择画风。 + - 点击生成。 + - 查看结果页。 + - 保存作品。 + - 启动试玩并点击选择。 + +## 5. 可能改动文件清单 + +高概率改动: + +- `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +- `src/components/visual-novel-creation/visualNovelEntryGeneration.ts` +- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +- `server-rs/crates/api-server/src/prompt/visual_novel.rs` +- `server-rs/crates/api-server/src/visual_novel.rs` +- `packages/shared/src/contracts/visualNovel.ts` + +中概率改动: + +- `src/components/visual-novel-runtime/**` +- `src/services/visual-novel-creation/**` +- `src/services/visual-novel-runtime/**` +- `src/services/visual-novel-works/**` +- `server-rs/crates/spacetime-module/src/visual_novel.rs` +- `server-rs/crates/spacetime-client/**` 生成/绑定文件,若 SpacetimeDB contract 需要更新 + +低概率/仅文档: + +- `docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md` +- `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` +- `.hermes/shared-memory/decision-log.md` + +## 6. 验证计划 + +### 6.1 静态检查 + +在 worktree 根目录执行: + +```bash +npm run typecheck +``` + +如仓库无统一 typecheck,则按 package scripts 选择最接近的前端类型检查命令。 + +### 6.2 前端定向测试 + +优先运行与 visual novel / platform entry 相关测试,如存在: + +```bash +npm test -- visual-novel +npm test -- platform-entry +``` + +若仓库使用 vitest: + +```bash +npm run test -- visual-novel +``` + +### 6.3 Rust 定向测试 + +在 `server-rs` 下运行 visual novel 相关测试: + +```bash +cargo test -p api-server visual_novel +cargo test -p shared-contracts visual_novel +``` + +如改动 SpacetimeDB module: + +```bash +cargo test -p spacetime-module visual_novel +``` + +### 6.4 人工验收步骤 + +1. 启动本地 dev 栈。 +2. 访问 Genarrative 主站。 +3. 进入创作/视觉小说入口。 +4. 输入:`一个雨夜,失忆的高中生在旧图书馆发现一本会回应她心声的日记。` +5. 选择任一画风,例如“映画动画”。 +6. 点击“生成视觉小说草稿”。 +7. 预期:进入生成过程页,能看到分阶段进度。 +8. 预期:完成后进入草稿结果页,包含标题、简介、世界观、角色、场景、剧情阶段和 opening choices。 +9. 点击保存/编译作品。 +10. 从作品入口进入试玩。 +11. 预期:opening 文本出现,至少 2 个选择可点击;点击后剧情继续推进一轮。 + +## 7. 风险、权衡与开放问题 + +### 7.1 风险 + +- 现有视觉小说代码已较完整,贸然新增一套 parallel pipeline 会制造重复逻辑;应复用当前 `VisualNovelResultDraft` 与 creation agent flow。 +- LLM 输出不稳定可能导致草稿结构不完整;需要 normalize/repair/fallback 确保最小闭环。 +- 视觉/音乐资产生成未接入时,UI 必须接受 null asset,否则运行态可能白屏。 +- `PlatformEntryFlowShellImpl.tsx` 文件很大,改动需局部、谨慎,避免影响其他玩法入口。 +- 若改动 SpacetimeDB 表结构,可能牵涉 publish、client binding、清库/迁移;最小闭环阶段应尽量避免 schema 变更。 + +### 7.2 权衡 + +- 先让文字版视觉小说完整跑通,再补角色立绘/背景图生成。 +- 先用 `seedText` 承载画风,再考虑把 `visualStyleId/Label/Prompt` 结构化进 draft metadata。 +- 先用现有 result/work/runtime 页面闭环,不引入新编辑器。 + +### 7.3 开放问题 + +1. 用户是否要求把 Interactive-fiction 原项目中的具体 UI 样式/页面布局迁移到 Genarrative?当前计划只迁移流程语义,不迁移独立 UI。 +2. 画风是否需要成为作品可编辑字段?当前以 seedText/prompt 影响生成内容,后续可在 draft 中增加 metadata。 +3. 文档导入模式是否本期要做?当前计划聚焦一句话模式,document 模式只保留契约能力。 +4. 是否需要真实图片/音乐生成?当前计划作为后续增强,不纳入最小闭环。 + +## 8. 建议实施顺序 + +1. 先做只改 prompt/progress/少量前端展示的轻量闭环修补。 +2. 运行前后端定向测试,确认现有能力是否已足够。 +3. 如果后端没有 fallback 或 normalize,再补 Rust 层确定性兜底。 +4. 手工跑通“一句话 -> 生成 -> 结果页 -> 保存 -> 试玩”。 +5. 最后再考虑是否需要资产生成、文档导入、结构化画风 metadata。 diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 980871b5..b5f66024 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,20 +16,132 @@ --- +## 2026-05-14 抓大鹅物品素材 sheet 改用 APIMart nanobanana + +- 背景:抓大鹅 2D 五视角物品素材仍沿用 5x5 sheet、绿幕去背、切图、OSS 转存和 `generatedItemAssets` 持久化,但用户要求物品素材图片生成步骤改用 APIMart 已接好的 nanobanana / Gemini 图片模型。 +- 决策:抓大鹅物品素材 sheet 生图固定走 APIMart `POST {APIMART_BASE_URL}/images/generations`,模型为 `gemini-3.1-flash-image-preview`,`size = 1:1`,`resolution = 1K`,`official_fallback = true`;响应优先读图片 URL 或 base64,缺图片时按 `task_id` 轮询 `/tasks/{task_id}`。封面、9:16 纯背景图、1:1 容器 UI 图、音频、切图、OSS、扣费和运行态消费链路保持不变。 +- 影响范围:`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/api-server/src/config.rs`、`deploy/env/api-server.env.example`、抓大鹅素材生成技术文档。 +- 验证方式:执行 `cargo test -p api-server match3d_material_sheet --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server from_env_reads_non_public_models_and_urls --manifest-path server-rs\Cargo.toml`、`cargo check -p api-server --manifest-path server-rs\Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## 2026-05-13 认证运行期同步直接导入正式认证表 + +- 背景:`auth_store_snapshot` 是 Stage 1 整包快照过渡表,主键固定 `default`,会让所有用户状态集中在一条 `snapshot_json` 中;Stage 2/3 已有 `user_account/auth_identity/refresh_session` 正式认证表,继续刷新 `default` 容易让运行时真相和表拆分目标混在一起。 +- 决策:运行期认证变更继续由 `module-auth` 生成一致内存快照,但 `api-server` 改为调用 `import_auth_store_snapshot_json` 直接覆盖导入 `user_account/auth_identity/refresh_session`;`auth_store_projection_meta/default` 只记录正式认证表最近一次导入时间;`upsert_auth_store_snapshot` 与 `import_auth_store_snapshot` 仅保留为旧库迁移和兜底入口。 +- 影响范围:`spacetime-module` auth procedures/tables、`spacetime-client` auth facade/bindings、`api-server` 认证同步和启动恢复、SpacetimeDB 表目录与认证 Stage 3 文档。 +- 验证方式:执行 `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-13 微信小程序支付以后端通知为唯一入账事实 + +- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。 +- 决策:`paymentChannel = "mock"` 继续创建即 paid 订单并立即入账;`paymentChannel = "wechat_mp"` 先在 `profile_recharge_order` 写入 `pending` 订单,再由 `api-server` 调微信支付 JSAPI 下单并返回小程序 `wx.requestPayment` 参数。小程序或 H5 的支付成功回调只触发刷新,不直接发放泥点或会员;最终入账只由 `/api/profile/recharge/wechat/notify` 验签、解密并确认 `trade_state = SUCCESS` 后完成。`provider_transaction_id` 保存微信支付平台交易号,用于对账、查单、退款和客服排障。 +- 影响范围:`profile_recharge_order` 表、SpacetimeDB 充值 procedure、`api-server` 微信支付客户端、小程序 native 支付页、H5 充值弹窗与共享 contract。 +- 验证方式:执行 `npm run typecheck`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`cargo test -p module-runtime recharge --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_pay --manifest-path server-rs/Cargo.toml`,后端联调仍用 `npm run api-server` 和 `/healthz`。 +- 关联文档:`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。 + +## 2026-05-13 修改密码后全设备强制下线 + +- 背景:修改密码原本只递增 `token_version`,旧 access token 会失效,但旧 refresh cookie 仍可通过 `/api/auth/refresh` 重新签发新 token,不符合“改密后全设备强制下线”的账号安全预期。 +- 决策:`POST /api/auth/password/change` 成功后必须在同一认证真相更新中撤销该用户全部 active `refresh_session`,继续递增 `token_version`,响应清除当前 refresh cookie;前端 `changePassword` 成功后清空本地 access token 并回到未登录态。用户需要使用新密码重新登录。 +- 影响范围:`module-auth` 修改密码用例、`api-server` password management route、`AuthGate`、`authService`、密码登录/重置技术文档。 +- 验证方式:执行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml password_change_allows_login_with_new_password_only -- --nocapture`、`npm run test -- AuthGate.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`、`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`。 + +## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线 + +- 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。 +- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。 +- 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。 +- 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run api-server` 检查 `/healthz`。 +- 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 + +## 2026-05-12 抓大鹅入口素材风格改为 2D 常见素材风格 + +- 背景:抓大鹅草稿素材生成已经收敛为多视角 2D 图片素材,但入口页和旧参考图仍沿用黏土、低多边形、塑料、木雕、体素、金属等偏 3D 素材语言,容易让后续生成链路和用户预期继续漂移。 +- 决策:抓大鹅创作入口 `2D素材风格` 固定为 `扁平图标 / 赛璐璐卡通 / 像素复古 / 手绘水彩 / 贴纸描边 / 厚涂图标 / 自定义`;默认风格为 `flat-icon`。入口参考图统一由 `npm run assets:match3d-style-references -- --live` 调用 VectorEngine `gpt-image-2-all` 生成,输出到 `public/match3d-style-references/`。旧 3D 风格参考图不再保留为入口资产。 +- 影响范围:`Match3DAgentWorkspace`、抓大鹅入口交互测试、Match3D PRD、素材生成流水线技术文档、F1 入口文档和 `public/match3d-style-references/` 静态资产。 +- 验证方式:执行 `npm run test -- src\components\match3d-creation\Match3DAgentWorkspace.interaction.test.tsx`、`cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml`、`npm run typecheck`、`npm run check:encoding`,并人工抽查 `.tmp/match3d-style-preview.png`。 +- 关联文档:`docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`、`docs/technical/MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md`。 + +## 2026-05-12 拼图与抓大鹅草稿背景音乐按纯音乐自动生成 + +- 背景:拼图和抓大鹅需要在草稿生成阶段直接产出可试听、可重生成、可进入运行态循环播放的背景音乐。 +- 决策:复用通用 VectorEngine Suno 创作音频链路,不新增 SpacetimeDB 表;拼图音乐保存到首关 `PuzzleDraftLevel.backgroundMusic`,运行态通过 `PuzzleRuntimeLevelSnapshot.backgroundMusic` 下发;抓大鹅音乐保存到首个 `generatedItemAssets[].backgroundMusic`。两者草稿生成都使用 `title` 驱动、`prompt = ""`、`make_instrumental = true`;自动草稿阶段必须拿到可播放 `audioSrc` 才能返回成功,失败时停留在生成页并允许重试同一 session/profile。结果页内的手动重新生成继续作为已有草稿的补救入口。 +- 影响范围:`api-server` 音频生成、拼图草稿编译、抓大鹅草稿编译、Puzzle/Match3D 结果页和运行态音频播放。 +- 验证方式:检查草稿 response / work detail 中的 `backgroundMusic.audioSrc`,运行态开局后隐藏 audio 循环播放;执行音频相关后端 check、前端 typecheck 和编码检查。 +- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 + +- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 +- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 +- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 +- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。 + +## 2026-05-12 抓大鹅结果页素材编辑统一走作品级资产面板 + +- 背景:抓大鹅结果页需要支持碰面图上传 / AI 重绘、物品素材独立预览、单项删除和批量新增,且不能把素材编辑继续做成列表内联展开或前端临时状态。 +- 决策:结果页 `作品信息` 的碰面图点击打开独立面板,参考图可来自本地上传、物品素材和 UI 素材;AI 重绘统一调用 `POST /api/creation/match3d/works/{profileId}/cover-image` 并转存到 `generated-match3d-assets`。`素材配置 > 物品` 列表项点击打开独立预览面板,不再提供单项重新生成按钮;单项删除和批量新增都写回同一份 `generated_item_assets_json`。批量新增调用 `POST /api/creation/match3d/works/{profileId}/item-assets`,复用草稿生成的 2D 素材图、5x5 切图、OSS 上传和可选点击音效链路,仅作用于新增物品,不新增 SpacetimeDB 表。 +- 影响范围:Match3D 结果页、Match3D works shared contracts、`api-server` Match3D 作品路由、生成资产历史类型和草稿恢复路径。 +- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run typecheck`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 + +## 2026-05-12 平台法律文档入口与登录协议确认 + +- 背景:生产发布需要在个人页展示用户协议、隐私政策、免责声明和备案号;登录页首次登录需要显式确认法律协议。 +- 决策:法律文档内容读取 `media/files/*.md`,统一通过 `LegalDocumentModal` 独立弹窗展示;“我的”页常用功能区固定 3 列,设置入口下方展示法律信息和 `京ICP备2026025677号` 外链。登录弹窗用 `genarrative.auth.legal-consent.v1` 记录本机确认,首次未勾选时短信 / 密码登录按钮禁用,法律链接不自动勾选。 +- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。 +- 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。 +- 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。 +## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 + +- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 +- 决策:小程序壳在 `pending_bind_phone` 时暂不打开 H5,先展示原生 `button open-type="getPhoneNumber"`;用户同意后把 `bindgetphonenumber` 返回的 `code` 作为 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`。后端通过微信 `stable_token` 与 `getuserphonenumber` 换取平台验证后的手机号,再复用现有微信待绑定账号合并逻辑并重新签发 active 系统 token。H5 旧短信验证码绑定流程继续作为非小程序环境兜底。 +- 影响范围:`miniprogram/pages/web-view/index.*`、`server-rs/crates/platform-auth`、`server-rs/crates/api-server/src/wechat_auth.rs`、认证共享契约、微信小程序 web-view 壳技术文档。 +- 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。 +- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。 + +## 2026-05-13 宝贝爱画先作为寓教于乐独立本地 Demo 落地 + +- 背景:第三关 `宝贝爱画` 需要默认出现在“发现 / 寓教于乐”板块下方,但本阶段只验证画板、手部绘制、绘画魔法和本地保存闭环,不进入创作模板、公开作品或正式持久化。 +- 决策:`baby-love-drawing / 宝贝爱画` 先作为独立运行态接入,入口由发现页寓教于乐默认卡片打开,并支持 `/runtime/baby-love-drawing` 直达;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时前端不展示频道/卡片且直达路由回落主应用。绘画魔法统一走 `POST /api/creation/edutainment/baby-love-drawing/magic` 后端安全代理,使用 VectorEngine `gpt-image-2-all` 与原始画布 Data URL 参考图生成绘本风图片;保存只写 localStorage,正式持久化后续再设计。 +- 影响范围:`packages/shared/src/contracts/edutainmentBabyDrawing.ts`、`src/components/edutainment-runtime/BabyLoveDrawingRuntimeShell.tsx`、`src/services/edutainment-baby-drawing/`、`src/routing/appRoutes.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`server-rs/crates/api-server/src/edutainment_baby_drawing.rs`、`src/index.css`、宝贝爱画 PRD 与技术方案。 +- 验证方式:执行宝贝爱画 model/runtime/service/route 定向测试、`npm run typecheck`、定向 ESLint、`cargo test -p api-server edutainment_baby_drawing --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server resolves_runtime_paths_to_creation_type_ids --manifest-path server-rs/Cargo.toml` 和编码检查;真实魔法生成需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。 +- 关联文档:`docs/prd/BABY_LOVE_DRAWING_EDUTAINMENT_LEVEL_PRD_2026-05-13.md`、`docs/technical/BABY_LOVE_DRAWING_RUNTIME_DEMO_IMPLEMENTATION_2026-05-13.md`。 + +## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包 + +- 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图,运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。 +- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。视觉包包含 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。 +- 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、宝贝识物 PRD 与技术方案。 +- 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。 +- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 + ## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路 - 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。 - 决策:新增 `/api/creation/audio/*` 通用创作音频路由,后端统一负责 VectorEngine 音频任务、OSS 转存、`asset_object` 与 `asset_entity_binding` 写入;视觉小说旧路由保留并复用同一持久化逻辑。拼图背景音乐暂存到首关 `levels_json[0].backgroundMusic/background_music`;抓大鹅背景音乐暂存到 `generated_item_assets_json[0].backgroundMusic/background_music`,单物体点击音效存到对应 item 的 `clickSound/click_sound`。本轮不新增 SpacetimeDB 表和字段。 +- 2026-05-12 补充:抓大鹅入口页新增 `generateClickSound` 开关,默认关闭;开启时 `match3d_compile_draft` 在生成首批 2D 物品素材后并行生成各物品点击音效,并继续复用通用创作音频路由的 OSS、资产绑定和扣费口径。 - 影响范围:拼图结果页、抓大鹅结果页、抓大鹅运行态音频播放、通用创作音频 shared contracts、`api-server` 音频路由和资产绑定。 - 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck`、`cargo test -p api-server vector_engine_audio_generation`、`cargo test -p shared-contracts creation_audio`、`cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。 - 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`。 +## 2026-05-11 寓教于乐公开作品使用独立 `edutainment` 来源接入 + +- 背景:`宝贝识物` 首关需要通过创作模板发布后进入寓教于乐板块,同时关闭入口时必须从发现页、搜索、详情深链、作品号和历史入口完全不可见;若继续落入 RPG 默认公共作品链路,容易出现误启动、误改造或近似标签误归类。 +- 决策:寓教于乐公开作品在前端公共作品模型中使用 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match`、`templateName = 宝贝识物`;进入“发现 / 寓教于乐”频道仍必须携带精确等于 `寓教于乐` 的公开标签,不因模板名或近似标签自动归类。公开详情、推荐运行态、改造、编辑、点赞和分享链路都必须显式识别 `edutainment`,不得回落到 RPG 默认处理。 +- 影响范围:公开作品卡、发现页频道、作品号搜索、公开详情深链、分享、作品架聚合、后续儿童动作 Demo 模板的发布结果展示。 +- 验证方式:执行第4线程定向单测、前端类型检查、ESLint 与编码检查;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时确认精确 `寓教于乐` 作品不可通过任何公开入口访问。 +- 关联文档:`docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`、`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。 + ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 -- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位。 -- 决策:热身舞台统一采用绘本草地视觉语言,真实背景图默认输出到 `public/child-motion-demo/picture-book-grass-stage.webp`,生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 +- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。 +- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。 -- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 应能写出默认背景文件。 +- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only ` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only ` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。 - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。 ## 2026-05-10 方洞挑战从创作页入口和作品架隐藏 @@ -122,10 +234,10 @@ - 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。 - 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md`。 -## 2026-05-05 text-game 作为百梦幕间文字游戏模板接入 +## 2026-05-05 text-game 作为陶泥儿幕间文字游戏模板接入 -- 背景:团队希望参考 MOKU / 幕间类 AI 文游,设计可在百梦内落地的 AI 文字游戏模板,但不能把外部平台社区、支付、榜单、论坛、账号或私有存档迁入 Genarrative。 -- 决策:新增 `text-game` 作为百梦 AI 原生文字游戏模板口径,展示名可用“幕间”或“幕间文字”;它与 `visual-novel` 分离,重点是 AI GM、自由行动、状态后果、长期记忆、章节目标和轻量剧本模拟器;入口、作品、发布、资产、钱包、埋点、存档和广场全部复用百梦平台接口;禁止新增 replay、外部社区、外部支付、外部榜单和私有存档系统。 +- 背景:团队希望参考 MOKU / 幕间类 AI 文游,设计可在陶泥儿内落地的 AI 文字游戏模板,但不能把外部平台社区、支付、榜单、论坛、账号或私有存档迁入 Genarrative。 +- 决策:新增 `text-game` 作为陶泥儿 AI 原生文字游戏模板口径,展示名可用“幕间”或“幕间文字”;它与 `visual-novel` 分离,重点是 AI GM、自由行动、状态后果、长期记忆、章节目标和轻量剧本模拟器;入口、作品、发布、资产、钱包、埋点、存档和广场全部复用陶泥儿平台接口;禁止新增 replay、外部社区、外部支付、外部榜单和私有存档系统。 - 影响范围:后续 `text-game` shared contracts、`module-text-game`、SpacetimeDB 表、`api-server` 路由、前端入口 / workspace / result / runtime、平台作品架和发现聚合。 - 验证方式:后续落地时确认路由使用 `/api/creation/text-game/*` 与 `/api/runtime/text-game/*`;确认正式业务真相在 Rust / SpacetimeDB 后端;确认没有 `replay` 能力和外部平台功能误入;确认 `text-game` 不复用 `visual-novel` step 契约作为运行态真相。 - 关联文档:`docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md`。 @@ -253,10 +365,18 @@ ## 2026-05-10 抓大鹅草稿元信息由 gpt-4o 生成 - 背景:抓大鹅草稿生成需要基于入口题材设定生成作品名称,结果页作品信息要对齐拼图草稿,不再把封面和作品名称拆成两个模块。 -- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会产出 3 个物品图片并立即调用 Rodin 生成 GLB,图片和模型一起写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].modelSrc` / `modelObjectKey`,默认积木只做兜底。 -- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息` 与 `3D素材` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。 +- 决策:`match3d_compile_draft` 使用 `gpt-4o` 生成 `gameName` 与 3 到 6 个标签;`summary` 默认保持空字符串;标签可由结果页 `作品信息` Tab 手动编辑或再次 AI 生成。草稿生成会按难度产出多视角 2D 物品图片并写入 `generated_item_assets_json`,运行态必须优先消费 `generatedItemAssets[].imageViews[]`,默认积木只做兜底。 +- 影响范围:`api-server` Match3D 编译、Match3D works 标签接口、结果页 `作品信息` 与 `素材配置` Tab、运行态 `Match3DRuntimeShell` / `Match3DPhysicsBoard`、生成进度和 Match3D 技术文档。 - 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并用 `npm run api-server` 检查 `/healthz`。 -- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`、`docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md`。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`;`docs/technical/MATCH3D_RODIN_ASSET_TAB_2026-05-10.md` 仅作历史参考。 + +## 2026-05-12 抓大鹅物品种类从消除次数中拆出并改为 2D 五视角素材 + +- 背景:结果页草稿素材已经能生成和预览,但标准 / 硬核难度仍可能按 `clearCount` 误判需要 12 / 20 种素材,且继续生产 GLB 会拉长草稿生成耗时。 +- 决策:难度配置统一使用 `物品种类`:轻松 3、标准 9、进阶 15、硬核 21;历史硬核 `clearCount=20` 在运行态升为 21 组三消。新草稿和批量新增不再调用 Rodin、不再生成 GLB。每个物品生成 5 个不同 2D 视角,单张 1K 素材图固定按 5x5 切割,最多承载 5 个物品;超过 5 个物品时由 `api-server` 自动分批并行生图。发布必须校验已生成 `image_ready` 且有 `imageViews[]` 或首图引用的素材数量满足当前难度;试玩通过 `itemTypeCountOverride` 自动降到可用 2D 素材数量。历史模型字段只作为旧数据兼容,不再进入新生产链路。 +- 影响范围:Match3D 结果页、运行态启动契约、`module-match3d` 初始 run 生成、SpacetimeDB start input / restart、发布校验和 Match3D 技术文档。 +- 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`、相关后端 check / tests。 +- 关联文档:`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 2026-05-07 移动端整页缩放由入口统一锁定 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 9f63fe06..b56a1bcb 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,6 +22,22 @@ - 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并用 bucket=`xushi-dev`、object_key=`generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png` 覆盖签名生成。 - 关联:`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/platform-oss/README.md`。 +## generated 音频路径进运行态前要先换签 + +- 现象:草稿页 audio 控件能播放背景音乐,但拼图或抓大鹅运行态开局后背景音乐不响,Network 可能出现裸 `/generated-*-assets/...mp3` 私有路径 403。 +- 原因:生成音乐转存到 OSS 私有对象后,`audioSrc` 是 generated legacy path;浏览器 `