diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..9de0f169 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.codegraph/config.json b/.codegraph/config.json new file mode 100644 index 00000000..7af60ad8 --- /dev/null +++ b/.codegraph/config.json @@ -0,0 +1,143 @@ +{ + "version": 1, + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.jsx", + "**/*.py", + "**/*.go", + "**/*.rs", + "**/*.java", + "**/*.c", + "**/*.h", + "**/*.cpp", + "**/*.hpp", + "**/*.cc", + "**/*.cxx", + "**/*.cs", + "**/*.php", + "**/*.rb", + "**/*.swift", + "**/*.kt", + "**/*.kts", + "**/*.dart", + "**/*.svelte", + "**/*.vue", + "**/*.liquid", + "**/*.pas", + "**/*.dpr", + "**/*.dpk", + "**/*.lpr", + "**/*.dfm", + "**/*.fmx", + "**/*.scala", + "**/*.sc" + ], + "exclude": [ + "**/.git/**", + "**/node_modules/**", + "**/vendor/**", + "**/Pods/**", + "**/dist/**", + "**/build/**", + "**/out/**", + "**/bin/**", + "**/obj/**", + "**/target/**", + "**/*.min.js", + "**/*.bundle.js", + "**/.next/**", + "**/.nuxt/**", + "**/.svelte-kit/**", + "**/.output/**", + "**/.turbo/**", + "**/.cache/**", + "**/.parcel-cache/**", + "**/.vite/**", + "**/.astro/**", + "**/.docusaurus/**", + "**/.gatsby/**", + "**/.webpack/**", + "**/.nx/**", + "**/.yarn/cache/**", + "**/.pnpm-store/**", + "**/storybook-static/**", + "**/.expo/**", + "**/web-build/**", + "**/ios/Pods/**", + "**/ios/build/**", + "**/android/build/**", + "**/android/.gradle/**", + "**/__pycache__/**", + "**/.venv/**", + "**/venv/**", + "**/site-packages/**", + "**/dist-packages/**", + "**/.pytest_cache/**", + "**/.mypy_cache/**", + "**/.ruff_cache/**", + "**/.tox/**", + "**/.nox/**", + "**/*.egg-info/**", + "**/.eggs/**", + "**/go/pkg/mod/**", + "**/target/debug/**", + "**/target/release/**", + "**/.gradle/**", + "**/.m2/**", + "**/generated-sources/**", + "**/.kotlin/**", + "**/.dart_tool/**", + "**/.vs/**", + "**/.nuget/**", + "**/artifacts/**", + "**/publish/**", + "**/cmake-build-*/**", + "**/CMakeFiles/**", + "**/bazel-*/**", + "**/vcpkg_installed/**", + "**/.conan/**", + "**/Debug/**", + "**/Release/**", + "**/x64/**", + "**/.pio/**", + "**/release/**", + "**/*.app/**", + "**/*.asar", + "**/DerivedData/**", + "**/.build/**", + "**/.swiftpm/**", + "**/xcuserdata/**", + "**/Carthage/Build/**", + "**/SourcePackages/**", + "**/__history/**", + "**/__recovery/**", + "**/*.dcu", + "**/.composer/**", + "**/storage/framework/**", + "**/bootstrap/cache/**", + "**/.bundle/**", + "**/tmp/cache/**", + "**/public/assets/**", + "**/public/packs/**", + "**/.yardoc/**", + "**/coverage/**", + "**/htmlcov/**", + "**/.nyc_output/**", + "**/test-results/**", + "**/.coverage/**", + "**/.idea/**", + "**/logs/**", + "**/tmp/**", + "**/temp/**", + "**/_build/**", + "**/docs/_build/**", + "**/site/**" + ], + "languages": [], + "frameworks": [], + "maxFileSize": 1048576, + "extractDocstrings": true, + "trackCallSites": true +} \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..7cef809b --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,23 @@ +# Genarrative 项目级 Codex 配置。 +# 这里仅保存可进入仓库的 hook 配置与脚本;个人 token、MCP server、模型路由仍放在个人 ~/.codex/config.toml。 + +[features] +hooks = true + +# Codex 准备执行 git commit 前检查 TypeScript / admin-web / api-server 编译错误。 +# 脚本也可手动运行: +# node .codex/hooks/pre-submit-compile-check.mjs +[[hooks.PreToolUse]] +matcher = "Bash|shell_command|functions.shell_command" +command = "node .codex/hooks/pre-submit-compile-check.mjs" +timeout = 180 +statusMessage = "提交前检查编译错误" + +# Codex 每次工具修改文件后执行:同步 CodeGraph 索引。 +# 脚本也可手动运行: +# node .codex/hooks/post-edit-codegraph-sync.mjs +[[hooks.PostToolUse]] +matcher = "Bash|Edit|MultiEdit|Write|apply_patch|shell_command|functions.shell_command|functions.apply_patch" +command = "node .codex/hooks/post-edit-codegraph-sync.mjs" +timeout = 60 +statusMessage = "更新 CodeGraph 索引" diff --git a/.codex/hooks/post-edit-codegraph-sync.mjs b/.codex/hooks/post-edit-codegraph-sync.mjs new file mode 100644 index 00000000..864eb041 --- /dev/null +++ b/.codex/hooks/post-edit-codegraph-sync.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..', '..'); +const logDir = resolve(repoRoot, '.codex', 'logs'); +const hasCodegraphConfig = existsSync(resolve(repoRoot, '.codegraph', 'config.json')); +const npmCommand = process.platform === 'win32' ? 'cmd' : 'npm'; + +if (!hasCodegraphConfig) { + console.log('[codex-hook] 未发现 .codegraph/config.json,跳过 CodeGraph 同步。'); + process.exit(0); +} + +const result = spawnSync(npmCommand, process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run codegraph:sync'] : ['run', 'codegraph:sync'], { + cwd: repoRoot, + shell: false, + encoding: 'utf8', + env: { + ...process.env, + NO_COLOR: process.env.NO_COLOR ?? '1', + }, +}); + +mkdirSync(logDir, { recursive: true }); +if (result.stdout) { + process.stdout.write(result.stdout); +} +if (result.stderr) { + process.stderr.write(result.stderr); +} + +if (result.error) { + console.error(`[codex-hook] CodeGraph 同步启动失败:${result.error.message}`); + process.exit(1); +} + +if (result.signal) { + console.error(`[codex-hook] CodeGraph 同步被信号终止:${result.signal}`); + process.exit(1); +} + +if ((result.status ?? 0) !== 0) { + console.error('[codex-hook] CodeGraph 同步失败,请手动运行 npm run codegraph:sync 查看详情。'); + process.exit(result.status ?? 1); +} + +console.log('[codex-hook] CodeGraph 已同步。'); diff --git a/.codex/hooks/pre-submit-compile-check.mjs b/.codex/hooks/pre-submit-compile-check.mjs new file mode 100644 index 00000000..97a5b305 --- /dev/null +++ b/.codex/hooks/pre-submit-compile-check.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..', '..'); +const npmCommand = process.platform === 'win32' ? 'cmd' : 'npm'; +const hookInput = readHookInput(); + +if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) { + process.exit(0); +} + +const validationSteps = [ + { + label: 'TypeScript typecheck', + command: npmCommand, + args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'], + }, + { + label: 'Admin web typecheck', + command: npmCommand, + args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'], + }, + { + label: 'Rust api-server compile check', + command: 'cargo', + args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], + }, +]; + +for (const step of validationSteps) { + const result = runStep(step); + if (!result.ok) { + const reason = `[codex-hook] 提交前编译检查失败:${step.label}。请修复编译错误后再提交。`; + console.error(reason); + if (hookInput) { + console.log(JSON.stringify({ decision: 'block', reason })); + process.exit(0); + } + process.exit(result.status ?? 1); + } +} + +console.error('[codex-hook] 提交前编译检查通过。'); + +function runStep(step) { + console.error(`[codex-hook] ${step.label}`); + const result = spawnSync(step.command, step.args, { + cwd: repoRoot, + shell: false, + encoding: 'utf8', + env: { + ...process.env, + NO_COLOR: process.env.NO_COLOR ?? '1', + }, + }); + + if (result.stdout) { + process.stderr.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + + if (result.error) { + console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`); + return { ok: false, status: 1 }; + } + + if (result.signal) { + console.error(`[codex-hook] ${step.label} 被信号终止:${result.signal}`); + return { ok: false, status: 1 }; + } + + return { + ok: (result.status ?? 0) === 0, + status: result.status ?? 1, + }; +} + +function readHookInput() { + try { + const rawInput = readFileSync(0, 'utf8').trim(); + if (!rawInput) { + return null; + } + return JSON.parse(rawInput); + } catch { + return null; + } +} + +function extractShellCommand(input) { + const candidates = [ + input?.tool_input?.command, + input?.toolInput?.command, + input?.tool_args?.command, + input?.toolArgs?.command, + input?.arguments?.command, + input?.params?.command, + input?.command, + ]; + + const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0); + if (command) { + return command; + } + + const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd; + if (Array.isArray(shellCommand)) { + return shellCommand.join(' '); + } + + return ''; +} + +function isGitCommitCommand(command) { + return /(^|[;&|]\s*)git(?:\.exe)?\b[\s\S]{0,200}\bcommit\b/iu.test(command); +} diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 924b7785..3fa96f2a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,32 @@ --- +## 2026-05-21 外部 API 失败必须 OTLP 上报并落库 + +- 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障。 +- 决策:外部 API 调用未成功时,`api-server` 必须同时发送 OTLP 失败观测并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2-all` 图片生成 / 编辑适配器记录 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`,metadata 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。 +- 落库方式:优先复用 tracking outbox 异步批量写入;outbox 不可写或因保护阈值拒绝时回退同步直写 SpacetimeDB。不新增 SpacetimeDB 表,不让 reducer 做外部 I/O。 +- 影响范围:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/telemetry.rs`、tracking outbox、后端架构文档和开发运维文档。 +- 验证方式:执行 `cargo test -p api-server external_api_audit --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-05-21 拼图参考图主链改为 OSS assetObjectId 与只读签名 URL + +- 背景:release 上拼图图生图生成草稿时,旧链路把上传图转成 Data URL/base64 放进创作 action JSON body,容易先触发 Nginx `413 Request Entity Too Large`,也让外部模型调用前的 HTTP body 过大。 +- 决策:浏览器参考图先通过资产直传票据上传 OSS,并确认 `asset_object`;拼图 action 主链只提交 `referenceImageAssetObjectId(s)`。`api-server` 按当前登录用户校验 asset owner、bucket、kind、图片 MIME 和大小后签发 OSS 只读 URL,传给 VectorEngine 的 generation fallback 使用;需要 edits multipart 时由后端用该签名 URL 拉取字节,不再让前端把图片塞进 JSON body。 +- 兼容边界:旧 `referenceImageSrc(s)` Data URL 与历史 `/generated-*` 路径仅保留给旧草稿、旧入口和迁移期请求;调大 Nginx `client_max_body_size` 只作为兼容兜底,不是长期创作主链。 +- 影响范围:拼图创作前端、`packages/shared` / `shared-contracts` action DTO、`api-server` 拼图 VectorEngine 编排、资产确认和 `spacetime-client` 资产读取 facade。 +- 验证方式:前端 payload 中 AI 重绘优先出现 `referenceImageAssetObjectId(s)` 且 `referenceImageSrc(s)` 不再携带 Data URL;后端 `puzzle_vector_engine_generation_prefers_signed_reference_url`、`puzzle_reference_image_sources_prefer_asset_object_ids`、`puzzle_asset_object_reference_requires_matching_owner` 通过。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-05-21 Nginx 通用 API 入口放行创作参考图请求体 + +- 背景:release 上拼图结果页重绘动作携带参考图 Data URL 时,Nginx access log 出现 `413`、`request_time=0.000`、`upstream_status=-`,说明请求被反代层默认 1 MiB 上限拦截,未进入 `api-server`。 +- 决策:发布、开发服和容器 Nginx 模板的通用 `location ~ ^/api(?:/|$)` 统一设置 `client_max_body_size 64m`。该值只作为反代放行和旧 Data URL 请求兼容兜底,具体业务请求体和图片字节上限继续由 `api-server` 路由 `DefaultBodyLimit`、OSS asset 确认和业务校验控制,不能替代接口级限制;拼图参考图长期主链见同日 `OSS assetObjectId` 决策。 +- 影响范围:`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`、Nginx README、生产运维文档和 release 排障口径。 +- 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413,access log 应出现有效 `upstream_status`。 +- 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs @@ -39,8 +65,9 @@ - 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试;继续在单文件内迭代会降低定位和评审效率。 - 决策:原超大 `puzzle.rs` 改为同名入口 `server-rs/crates/api-server/src/puzzle.rs` 加 `server-rs/crates/api-server/src/puzzle/` 子模块目录。`puzzle.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。 +- 2026-05-21 追加决策:拼图 HTTP/BFF handler 不再直接提取完整 `AppState`,统一通过 `PuzzleApiState` 暴露拼图能力需要的 SpacetimeDB facade、gallery cache、OSS、作者查询、LLM 和少量配置快照。`modules/puzzle.rs` 仍接收全局 `AppState` 以挂接鉴权和回到全局路由树,但内部路由先 `.with_state(PuzzleApiState::from_ref(&state))`,handler 使用 `State`。确需复用计费、外部失败审计等仍要求 `AppState` 的横切 helper 时,先经 `PuzzleApiState::root_state()` 显式过渡,后续再继续收窄。 - 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`,SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`。 -- 影响范围:`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。 +- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。 - 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -716,3 +743,11 @@ - 影响范围:平台入口、推荐流、公开详情、试玩启动、跳一跳运行态、`api-server` / SpacetimeDB 公开投影和 shared contracts。 - 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 + +- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。 +- 决策:平台亮色主题的主色回收到暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;后台管理也同步切换到同一暖橙体系。主题变量优先通过 `src/index.css` 的 `--platform-*` token 统一控制,零散组件只做必要的局部替换。 +- 影响范围:主站平台壳层、常用表单 / 按钮 / 卡片 / 背景、后台管理 UI、业务进度条和小游戏结果条的通用强调色。 +- 验证方式:优先检查 `src/index.css` 与 `apps/admin-web/src/styles/admin.css` 是否还存在旧粉色主色;再用编码检查和可执行的本地 typecheck / build 验证。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index f1268ce4..bb3daa40 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -112,6 +112,24 @@ SpacetimeDB bindings 生成: npm run spacetime:generate ``` +CodeGraph 本地语义索引: + +```bash +npm run codegraph:init +npm run codegraph:status +npm run codegraph:sync +npm run codegraph:index +``` + +`.codegraph/config.json` 可随仓库共享;`.codegraph/codegraph.db`、缓存和日志为本机生成物,不提交。 + +Codex 项目级 hook 保存在 `.codex/config.toml` 与 `.codex/hooks/`: + +- `PreToolUse` hook:`node .codex/hooks/pre-submit-compile-check.mjs`,Codex 准备执行 `git commit` 前检查 `npm run typecheck`、`npm run admin-web:typecheck`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 +- `PostToolUse` hook:`node .codex/hooks/post-edit-codegraph-sync.mjs`,工具修改文件后执行 `npm run codegraph:sync`。 + +个人 token、模型路由、MCP server 仍属于个人环境;需要时由成员本机执行 `codegraph install` 或查看 `codegraph install --print-config codex`,不要提交个人全局配置。 + ## 常用检查命令 - 后端通用用户行为埋点统一通过 `record_tracking_event_and_return` procedure、`SpacetimeRuntimeClient::record_tracking_event(...)` 与 api-server `tracking` 中间件写入 `tracking_event` / `tracking_daily_stat`;后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 默认排除;作品级游玩埋点统一使用 `work_play_start`,详细事件清单见 `docs/technical/BACKEND_TRACKING_EVENT_COVERAGE_2026-05-09.md`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 163fa7cf..e1f5ff2d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -46,6 +46,30 @@ - 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。 - 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## release tracking outbox 权限错误先查 env 缺失 + +- 现象:release 机器 `journalctl -u genarrative-api.service` 每秒刷 `tracking outbox 定时封存 active 文件失败 error=Permission denied (os error 13)` 和 `tracking outbox 批量写入 SpacetimeDB 失败`。 +- 原因:旧 `/etc/genarrative/api-server.env` 没有 `GENARRATIVE_TRACKING_OUTBOX_DIR` 时,api-server 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`;systemd 工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户不能在其中创建 `server-rs`。 +- 处理:补齐 `GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox` 及 batch/flush/max 配置,创建并授权 `/var/lib/genarrative/tracking-outbox` 给 `genarrative:genarrative`,再重启 `genarrative-api.service`。Server-Provision 与 API-Deploy 会保留旧 env 但自动补缺这些运行态路径。 +- 验证:`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` 应指向 `/var/lib/genarrative/tracking-outbox`;重启后当前 PID 不再出现 `Permission denied (os error 13)`。 +- 关联:`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 外部 API 失败没法追溯先查 external_api_call_failure + +- 现象:VectorEngine 图片生成 / 编辑接口对前端只表现为 `502` / `504` 或“上游服务请求失败”,但难以区分是请求发送失败、上游 429/5xx、响应解析失败、未返回图片,还是下载图片失败。 +- 原因:外部 API 失败如果只靠普通日志,不一定能和 OTLP 指标、trace 与 SpacetimeDB 历史查询稳定关联;重启后也容易丢失上下文。 +- 处理:先查 OTLP 指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,再查 `tracking_event` 中 `event_key = 'external_api_call_failure'` 的 `metadata_json`。当前通用 VectorEngine `gpt-image-2-all` 适配器会记录 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。 +- 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。 +- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## release 创作接口 413 先查是否还在提交 Data URL + +- 现象:release 上 `POST /api/runtime/puzzle/agent/sessions/{session_id}/actions` 携带参考图 Data URL 时返回 `413 Request Entity Too Large`,access log 显示 `request_time=0.000`、`upstream_status=-`。 +- 原因:Nginx 默认 `client_max_body_size` 只有 1 MiB,请求在反代层被拒绝,根本没有到达 `api-server`;即使模板放宽到 `64m`,把图片 base64 放进创作 JSON body 仍会放大请求体并把上限问题推给下一层。 +- 处理:长期修复不是继续调大 Nginx,而是让浏览器先走 `/api/assets/direct-upload-tickets` 直传 OSS,再 `/api/assets/objects/confirm` 确认 `asset_object`,拼图 action 只提交 `referenceImageAssetObjectId(s)`;后端校验 owner / bucket / kind / MIME / size 后签只读 URL 给 VectorEngine。Nginx `client_max_body_size 64m` 只保留为旧客户端和兼容输入兜底,发布后仍需 `nginx -t && nginx -s reload`。 +- 验证:前端 action payload 不应再出现大段 `data:image/...;base64`;`nginx -T 2>/dev/null | grep client_max_body_size` 可确认反代兜底;再次提交参考图时 access log 应有正常 `upstream_status`,后端测试 `puzzle_reference_image_sources_prefer_asset_object_ids` / `puzzle_asset_object_reference_requires_matching_owner` 应通过。 +- 关联:`src/services/puzzle-works/puzzleAssetClient.ts`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 汪汪声浪入口不要再回到独立配置阶段 - 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。 @@ -860,7 +884,7 @@ - 现象:抓大鹅结果页看似有容器生成入口,但真实生成出的局内容器不像 `pot-fused-reference.png`,或进入试玩后仍被默认圆形锅壳、金色边框和径向底色覆盖/裁切。 - 原因:`/v1/images/generations` 的 `image` 数组更适合弱参考文生图,难以稳定锁定大尺寸轻俯视容器构图;即使生成了容器图,如果运行态继续保留默认 `rounded-full` 锅壳和 `overflow-hidden`,生成图也会被默认视觉覆盖或裁掉。 -- 处理:抓大鹅 `1:1` 容器 UI 图必须用 VectorEngine `POST /v1/images/edits` multipart,把 `public/match3d-background-references/pot-fused-reference.png` 作为 `image` part 上传;共享 GPT-image-2 HTTP client 承载 multipart 时强制 HTTP/1.1。`Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。 +- 处理:抓大鹅 `1:1` 容器 UI 图必须用 VectorEngine `POST /v1/images/edits` multipart,参考 `public/match3d-background-references/pot-fused-reference.png` 的透明容器图作为 `image` part;该参考图属于后端生图协议输入,需通过 `include_bytes!` 编译进 `api-server`,不能在运行时按当前工作目录读取 `public/`。共享 GPT-image-2 HTTP client 承载 multipart 时强制 HTTP/1.1。`Match3DRuntimeShell` 在容器图换签并成功加载后,把棋盘外壳切为透明和 `overflow-visible`,只在容器缺失或加载失败时使用默认圆形容器。 - 验证:执行 `cargo test -p api-server vector_engine --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server match3d_background --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/match3d-result/Match3DResultView.test.tsx`;真实联调看容器生成请求是否命中 `/v1/images/edits`,局内 `match3d-container-image` 是否渲染且 `match3d-board` 不再含默认 `rounded-full`。 - 关联:`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index 93ee4007..162c9678 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -1,6 +1,6 @@ :root { - color: #17212b; - background: #eef3f6; + color: #3d1f10; + background: #f8efe7; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -16,7 +16,7 @@ body { min-width: 320px; min-height: 100vh; margin: 0; - background: #eef3f6; + background: #f8efe7; } button, @@ -49,31 +49,31 @@ button:disabled { .admin-loading-screen { gap: 12px; - color: #5c6b77; + color: #7b6150; } .admin-loading-mark { width: 24px; height: 24px; - border: 3px solid #d1dde6; - border-top-color: #126e82; + border: 3px solid #e1ccbb; + border-top-color: #b6623f; border-radius: 50%; animation: admin-spin 0.8s linear infinite; } .admin-login-screen { background: - linear-gradient(145deg, rgba(18, 110, 130, 0.12), transparent 36%), - linear-gradient(315deg, rgba(165, 94, 54, 0.12), transparent 34%), - #eef3f6; + linear-gradient(145deg, rgba(204, 117, 76, 0.14), transparent 36%), + linear-gradient(315deg, rgba(226, 171, 134, 0.16), transparent 34%), + #f8efe7; } .admin-login-panel, .admin-panel { - border: 1px solid #d8e2e8; + border: 1px solid #e1ccbb; border-radius: 8px; background: #ffffff; - box-shadow: 0 12px 36px rgba(23, 33, 43, 0.08); + box-shadow: 0 12px 36px rgba(112, 57, 30, 0.08); } .admin-login-panel { @@ -92,14 +92,14 @@ button:disabled { .admin-login-brand h1 { margin: 0; - color: #17212b; + color: #3d1f10; font-size: 26px; line-height: 1.16; } .admin-login-brand span, .admin-brand span { - color: #667682; + color: #8f7868; font-size: 12px; } @@ -108,10 +108,10 @@ button:disabled { width: 38px; height: 38px; place-items: center; - border: 1px solid #bcd2db; + border: 1px solid #dfc8b7; border-radius: 8px; - color: #126e82; - background: #e7f3f5; + color: #b6623f; + background: #f4e5d7; } .admin-brand-icon-large { @@ -129,7 +129,7 @@ button:disabled { display: flex; flex-direction: column; gap: 24px; - border-right: 1px solid #d8e2e8; + border-right: 1px solid #e1ccbb; background: #ffffff; padding: 22px 18px; } @@ -164,13 +164,13 @@ button:disabled { gap: 10px; min-height: 42px; padding: 0 12px; - color: #52616d; + color: #755a49; background: transparent; } .admin-nav-button[data-active="true"] { - color: #0f5666; - background: #e7f3f5; + color: #8f3f27; + background: #f4e5d7; } .admin-main { @@ -184,7 +184,7 @@ button:disabled { align-items: center; justify-content: flex-end; gap: 12px; - border-bottom: 1px solid #d8e2e8; + border-bottom: 1px solid #e1ccbb; background: rgba(255, 255, 255, 0.86); padding: 0 24px; } @@ -200,7 +200,7 @@ button:disabled { } .admin-user small { - color: #667682; + color: #8f7868; } .admin-content { @@ -232,7 +232,7 @@ button:disabled { .admin-page-heading h2, .admin-panel-heading h3 { margin: 0; - color: #17212b; + color: #3d1f10; } .admin-page-heading h2 { @@ -241,7 +241,7 @@ button:disabled { .admin-page-heading p { margin: 3px 0 0; - color: #667682; + color: #8f7868; } .admin-panel { @@ -256,7 +256,7 @@ button:disabled { } .admin-panel-heading span { - color: #667682; + color: #8f7868; font-size: 13px; } @@ -314,12 +314,12 @@ button:disabled { } .admin-table tbody tr[data-clickable="true"]:hover { - background: #f5fafb; + background: #fff7f0; } .admin-text-button:hover, .admin-text-button:focus-visible { - color: #126e82; + color: #b6623f; text-decoration: underline; outline: none; } @@ -345,7 +345,7 @@ button:disabled { } .admin-query-summary { - color: #667682; + color: #8f7868; font-size: 12px; font-weight: 650; } @@ -354,7 +354,7 @@ button:disabled { display: grid; min-width: 0; gap: 7px; - color: #4c5c68; + color: #6f5848; font-size: 13px; font-weight: 650; } @@ -372,16 +372,16 @@ button:disabled { .admin-field textarea { width: 100%; min-height: 42px; - border: 1px solid #cbd8e0; + border: 1px solid #dfc8b7; border-radius: 8px; - color: #17212b; - background: #fbfdfe; + color: #3d1f10; + background: #fffdf9; padding: 9px 11px; outline: none; } .admin-field-note { - color: #667682; + color: #8f7868; font-size: 12px; font-weight: 500; line-height: 1.45; @@ -395,8 +395,8 @@ button:disabled { .admin-field input:focus, .admin-field select:focus, .admin-field textarea:focus { - border-color: #126e82; - box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16); + border-color: #b6623f; + box-shadow: 0 0 0 3px rgba(204, 117, 76, 0.16); } .admin-combobox { @@ -419,16 +419,16 @@ button:disabled { align-items: center; justify-content: center; min-height: 42px; - border: 1px solid #cbd8e0; + border: 1px solid #dfc8b7; border-left: 0; border-radius: 0 8px 8px 0; - color: #52616d; - background: #fbfdfe; + color: #755a49; + background: #fffdf9; } .admin-combobox:focus-within .admin-combobox-toggle { - border-color: #126e82; - box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16); + border-color: #b6623f; + box-shadow: 0 0 0 3px rgba(204, 117, 76, 0.16); } .admin-combobox-menu { @@ -440,10 +440,10 @@ button:disabled { display: grid; max-height: 260px; overflow: auto; - border: 1px solid #cbd8e0; + border: 1px solid #dfc8b7; border-radius: 8px; background: #ffffff; - box-shadow: 0 16px 40px rgba(23, 33, 43, 0.14); + box-shadow: 0 16px 40px rgba(112, 57, 30, 0.14); padding: 6px; } @@ -453,7 +453,7 @@ button:disabled { width: 100%; border: 0; border-radius: 7px; - color: #17212b; + color: #3d1f10; background: transparent; padding: 9px 10px; text-align: left; @@ -461,12 +461,12 @@ button:disabled { .admin-combobox-option:hover, .admin-combobox-option:focus-visible { - background: #e7f3f5; + background: #f4e5d7; outline: none; } .admin-combobox-option span { - color: #0f5666; + color: #8f3f27; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; @@ -475,7 +475,7 @@ button:disabled { .admin-combobox-option small, .admin-combobox-empty { - color: #667682; + color: #8f7868; font-size: 12px; font-weight: 500; line-height: 1.45; @@ -495,7 +495,7 @@ button:disabled { justify-content: flex-end; gap: 8px; min-height: 42px; - color: #4c5c68; + color: #6f5848; font-size: 13px; font-weight: 650; } @@ -503,7 +503,7 @@ button:disabled { .admin-switch-field input { width: 18px; height: 18px; - accent-color: #126e82; + accent-color: #b6623f; } .admin-primary-button, @@ -522,7 +522,7 @@ button:disabled { z-index: 80; display: grid; place-items: center; - background: rgba(23, 33, 43, 0.42); + background: rgba(61, 31, 16, 0.34); padding: 16px; } @@ -530,10 +530,10 @@ button:disabled { display: grid; width: min(100%, 420px); gap: 16px; - border: 1px solid #d8e2e8; + border: 1px solid #e1ccbb; border-radius: 10px; background: #ffffff; - box-shadow: 0 22px 60px rgba(23, 33, 43, 0.24); + box-shadow: 0 22px 60px rgba(112, 57, 30, 0.24); padding: 18px; } @@ -543,10 +543,10 @@ button:disabled { max-height: min(90dvh, 760px); gap: 16px; overflow: auto; - border: 1px solid #d8e2e8; + border: 1px solid #e1ccbb; border-radius: 10px; background: #ffffff; - box-shadow: 0 22px 60px rgba(23, 33, 43, 0.24); + box-shadow: 0 22px 60px rgba(112, 57, 30, 0.24); padding: 18px; } @@ -557,7 +557,7 @@ button:disabled { .admin-confirm-warning { border: 1px solid #efc894; border-radius: 8px; - color: #8a5a1b; + color: #8f4b26; background: #fffaf3; padding: 10px 12px; font-size: 13px; @@ -576,26 +576,26 @@ button:disabled { .admin-primary-button { color: #ffffff; - background: #126e82; + background: #b6623f; } .admin-secondary-button, .admin-icon-button { - border: 1px solid #cbd8e0; - color: #2f4550; + border: 1px solid #dfc8b7; + color: #4b2412; background: #ffffff; } .admin-danger-button { color: #ffffff; - background: #a44242; + background: #a6402f; } .admin-ghost-button { width: 34px; height: 34px; - color: #52616d; - background: #eef3f6; + color: #755a49; + background: #f8efe7; } .admin-ghost-button.admin-query-reset-button { @@ -608,7 +608,7 @@ button:disabled { .admin-text-button { display: inline; border: 0; - color: #0f5666; + color: #8f3f27; background: transparent; padding: 0; text-align: left; @@ -616,9 +616,9 @@ button:disabled { } .admin-alert { - border: 1px solid #efc0bd; + border: 1px solid #e2b9a4; border-radius: 8px; - color: #8a2f2f; + color: #8f3f27; background: #fff4f3; padding: 10px 12px; font-size: 13px; @@ -638,7 +638,7 @@ button:disabled { } .admin-info-list dt { - color: #667682; + color: #8f7868; font-size: 12px; } @@ -646,7 +646,7 @@ button:disabled { min-width: 0; margin: 0; overflow-wrap: anywhere; - color: #17212b; + color: #3d1f10; font-size: 13px; font-weight: 650; } @@ -666,26 +666,26 @@ button:disabled { .admin-table th, .admin-table td { - border-bottom: 1px solid #e4edf2; + border-bottom: 1px solid #eaded2; padding: 10px; text-align: left; vertical-align: top; } .admin-table th { - color: #667682; + color: #8f7868; font-size: 12px; } .admin-table td small { display: block; margin-top: 3px; - color: #667682; + color: #8f7868; font-size: 12px; } .admin-muted-text { - color: #86939c; + color: #a38f80; } .admin-tag-list { @@ -699,10 +699,10 @@ button:disabled { display: inline-flex; max-width: 100%; align-items: center; - border: 1px solid #cbdfe6; + border: 1px solid #dfc8b7; border-radius: 999px; - background: #eef7f8; - color: #0f5666; + background: #f7eadf; + color: #8f3f27; padding: 3px 8px; font-size: 12px; font-weight: 750; @@ -744,7 +744,7 @@ button:disabled { gap: 6px; max-width: 100%; border: 0; - color: #667682; + color: #8f7868; background: transparent; padding: 0; text-align: left; @@ -773,7 +773,7 @@ button:disabled { .admin-table-sort-button:hover, .admin-table-sort-button:focus-visible, .admin-table-sort-button[data-active="true"] { - color: #0f5666; + color: #8f3f27; outline: none; } @@ -782,7 +782,7 @@ button:disabled { max-height: 160px; margin: 0; overflow: auto; - color: #2f4550; + color: #4b2412; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; @@ -801,18 +801,18 @@ button:disabled { } .admin-status-ok { - color: #17623c; - background: #e6f5ed; + color: #2f7b46; + background: #edf8ef; } .admin-status-pending { - color: #8a5a1b; - background: #fff4df; + color: #8f4b26; + background: #fdf1e5; } .admin-status-error { - color: #8a2f2f; - background: #fff1ef; + color: #8f3f27; + background: #fff0e9; } .admin-error-list { @@ -820,7 +820,7 @@ button:disabled { gap: 8px; margin: 0; padding-left: 18px; - color: #8a5a1b; + color: #8f4b26; overflow-wrap: anywhere; } @@ -830,7 +830,7 @@ button:disabled { } .admin-subsection-heading { - color: #4c5c68; + color: #6f5848; font-size: 13px; font-weight: 650; } @@ -850,7 +850,7 @@ button:disabled { .admin-header-row input { min-width: 0; min-height: 38px; - border: 1px solid #cbd8e0; + border: 1px solid #dfc8b7; border-radius: 8px; padding: 8px 10px; } @@ -863,10 +863,10 @@ button:disabled { max-height: 520px; margin: 0; overflow: auto; - border: 1px solid #dce6ec; + border: 1px solid #eaded2; border-radius: 8px; - background: #17212b; - color: #e9f2f4; + background: #3d1f10; + color: #fdf9f5; padding: 14px; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; @@ -880,19 +880,19 @@ button:disabled { display: grid; min-height: 140px; place-items: center; - border: 1px dashed #cbd8e0; + border: 1px dashed #dfc8b7; border-radius: 8px; - color: #667682; - background: #fbfdfe; + color: #8f7868; + background: #fffdf9; } .admin-segmented-control { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; - border: 1px solid #d8e2e8; + border: 1px solid #e1ccbb; border-radius: 8px; - background: #eef3f6; + background: #f8efe7; padding: 4px; } @@ -900,15 +900,15 @@ button:disabled { min-height: 36px; border: 0; border-radius: 6px; - color: #52616d; + color: #755a49; background: transparent; font-weight: 700; } .admin-segmented-control button[data-active="true"] { - color: #0f5666; + color: #8f3f27; background: #ffffff; - box-shadow: 0 2px 8px rgba(23, 33, 43, 0.08); + box-shadow: 0 2px 8px rgba(112, 57, 30, 0.08); } .admin-bottom-nav { @@ -964,7 +964,7 @@ button:disabled { z-index: 20; display: grid; grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); - border-top: 1px solid #d8e2e8; + border-top: 1px solid #e1ccbb; background: rgba(255, 255, 255, 0.94); padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); backdrop-filter: blur(10px); @@ -974,15 +974,15 @@ button:disabled { display: grid; gap: 4px; min-height: 48px; - color: #667682; + color: #8f7868; background: transparent; font-size: 11px; font-weight: 700; } .admin-bottom-nav-button[data-active="true"] { - color: #0f5666; - background: #e7f3f5; + color: #8f3f27; + background: #f4e5d7; } } diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 2799af16..239b5c4c 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -170,6 +170,8 @@ http { location ~ ^/api(?:/|$) { default_type application/json; + # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 + client_max_body_size 64m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index 2dfa2110..054734de 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -2,6 +2,12 @@ 本配置片段由 `scripts/jenkins-server-provision.sh` 在安装 Nginx 站点配置时展开。 +## 请求体大小 + +- 生产、开发服和容器模板都在通用 `location ~ ^/api(?:/|$)` 内设置 `client_max_body_size 64m`。 +- 该值只用于让携带参考图 Data URL 的创作接口抵达 `api-server`;不要把它当作业务上传上限。Rust 路由仍通过 `DefaultBodyLimit` 和解码后字节校验限制具体接口,例如拼图参考图路由只放宽到 12 MiB 请求体,图片字节继续按业务规则拒绝。 +- 若线上看到 `413 Request Entity Too Large`,并且 access log 里 `request_time=0.000 upstream_status=-`,通常是 Nginx 没有加载该模板或未 reload;先执行 `nginx -T | grep client_max_body_size` 和 `nginx -t` 再检查 `api-server`。 + ## gzip - `deploy/nginx/genarrative.conf` 与 `deploy/nginx/genarrative-dev-http.conf` 默认开启 gzip。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 63234e30..b7c0cdaa 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -190,6 +190,8 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; + # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 + client_max_body_size 64m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 023a96f8..c26e9bbb 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -210,6 +210,8 @@ server { # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。 location ~ ^/api(?:/|$) { default_type application/json; + # 中文注释:创作接口会携带参考图 Data URL,Nginx 只放行到 api-server;真实大小限制仍由路由 DefaultBodyLimit 和业务字节校验负责。 + client_max_body_size 64m; limit_conn genarrative_api_conn 64; limit_req zone=genarrative_api_rps burst=64 nodelay; diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index af9d5cb2..981e1e07 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -78,13 +78,16 @@ npm run check:server-rs-ddd 1. 每个能力 Module 只暴露 `router(state) -> Router`,由 `app.rs` 统一 `.merge(...)`。 2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。 -3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。 -4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。 -5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。 +3. 能力 Module 可在路由内部用 `FromRef` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。 +4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。 +5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。 +6. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。 +7. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。 拼图 `api-server` 内部拆分: - `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。 +- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State`,不得重新改回 `State`。 - `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。 - `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。 - `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。 @@ -115,7 +118,8 @@ npm run check:server-rs-ddd 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 -6. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 +6. 拼图图生图参考图主链不得再把大图 Data URL 塞进创作 JSON body;前端先直传 OSS 并提交 `referenceImageAssetObjectId(s)`,`api-server` 校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取,Data URL / `/generated-*` 仅作为旧请求兼容。 +7. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 @@ -155,10 +159,11 @@ npm run check:server-rs-ddd - 图片生成:VectorEngine / APIMart / DashScope,密钥只在后端环境变量中。 - Match3D 物品 sheet:VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`;图集 prompt、切图、透明化和切片持久化走 `generated_asset_sheets` 通用模块,Match3D 只补题材 / 风格 / 五视角设定和字段映射。 - Match3D 封面和 9:16 纯背景:VectorEngine `/v1/images/generations`。 -- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。 +- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;拼图和抓大鹅生成入口暂时关闭,通用 `/api/creation/audio/*` 对相关目标返回 `410 Gone`;敲木鱼 `hit_sound` 目标例外开放,复用 VectorEngine Vidu 音效生成、OSS 私有对象、`asset_object` 和 entity binding 链路,目标字段固定为 `entityKind='wooden_fish_work'`、`slot='hit_sound'`、`assetKind='wooden_fish_hit_sound'`、`storagePrefix='wooden_fish_assets'`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 +- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2-all` 图片生成 / 编辑适配器在 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段失败时记录 `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。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 ## SpacetimeDB 表目录 @@ -707,6 +712,7 @@ npm run check:server-rs-ddd - Rust 结构体:`TrackingEvent` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` - 写入:关键业务埋点同步调用单条 procedure;普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`。outbox 到达批量阈值时先封存 active 文件并切新 active,后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。 +- 外部 API 失败:`event_key = external_api_call_failure` 使用同一张表落库;它是供应商失败审计事实,不新增 SpacetimeDB 表,查询时按 `module_key = 'external-api'` 或 `scope_kind = module AND scope_id = ''` 过滤。 ### `treasure_record` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 1d554b8f..c7e144df 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -98,6 +98,33 @@ SpacetimeDB bindings: npm run spacetime:generate ``` +## CodeGraph 本地代码索引 + +项目已安装 `@colbymchenry/codegraph` 作为开发期依赖,用于在本地生成语义代码索引,辅助 AI / IDE 做符号搜索、调用关系和影响范围分析。索引目录为 `.codegraph/`,其中 `config.json` 可提交,数据库、缓存和日志由 `.codegraph/.gitignore` 保持本机私有。 + +首次拉取或需要重建索引时: + +```bash +npm install +npm run codegraph:init +``` + +日常使用: + +```bash +npm run codegraph:status +npm run codegraph:sync +npm run codegraph:index +``` + +Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`: + +- `PreToolUse` hook 会在 Codex 准备执行 `git commit` 前运行 `node .codex/hooks/pre-submit-compile-check.mjs`,依次执行 `npm run typecheck`、`npm run admin-web:typecheck`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,发现编译错误会阻止本次提交。 +- `PostToolUse` hook 会在 Codex 工具修改文件后运行 `node .codex/hooks/post-edit-codegraph-sync.mjs`,执行 `npm run codegraph:sync` 刷新本地语义索引。 +- 如果某个 Codex 客户端版本尚未自动加载项目级 hook,可先手动运行 `node .codex/hooks/pre-submit-compile-check.mjs` 与 `node .codex/hooks/post-edit-codegraph-sync.mjs`;个人模型、token、MCP server 仍放在个人 `~/.codex/config.toml`,不要提交。 + +若要把 CodeGraph 接到 Codex CLI / Cursor / Claude Code 等 MCP 客户端,按本机 agent 配置执行 `codegraph install` 或参考 `codegraph install --print-config codex` 输出;不要把个人全局 agent 配置、token 或本机绝对路径提交到仓库。 + ## 后端改动验收 后端代码修改后,按变更范围选择: @@ -157,7 +184,7 @@ Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 -`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。 +`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 50 HTTP req/s 首版压测优化口径: @@ -168,7 +195,7 @@ Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该 - Windows 下载阶段如果出现 `curl: (18)` 或响应体截断,流水线会保留同名 `.download` 临时文件并用 `curl -C -` 断点续传;只有完整返回但 SHA256 digest 仍不匹配时才删除临时文件后重新下载。目标 Linux 节点仍只接收 `stash/unstash` 带过去的本地下载件,不回退外网下载。 - Windows 下载阶段如果走代理,在 `Genarrative-Server-Provision` 参数 `PROVISION_DOWNLOAD_PROXY` 填写 Windows Jenkins 节点可访问的 HTTP 代理,例如 `http://127.0.0.1:7890`;不要填写目标 release 机器视角的 `127.0.0.1`,除非代理确实运行在该 Windows 节点本机。Linux 目标机阶段会强制要求使用本地下载件,缺少文件直接失败,不再回退到外网下载。 - `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。 -- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 +- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。通用 `/api` location 设置 `client_max_body_size 64m` 只是反代兜底,防止旧客户端或兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;长期主链不得依赖大 JSON body 承载图片,拼图参考图应先直传 OSS,只向创作接口提交 `referenceImageAssetObjectId(s)`,由后端签只读 URL 给外部模型读取。真实业务上限仍由 Rust 路由 `DefaultBodyLimit`、资产确认时 OSS HEAD 和解码后字节校验控制。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否仍在提交 Data URL 而不是 `assetObjectId`。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 - 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 - 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。 - 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。 @@ -197,6 +224,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 +- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2-all` 图片生成 / 编辑失败会输出 `外部 API 调用失败` trace/log,并记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`;同时写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 - SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 - 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 - Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 @@ -252,6 +280,16 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms 个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。 +外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。常用查询: + +```sql +SELECT event_id, scope_id AS provider, metadata_json, occurred_at +FROM tracking_event +WHERE event_key = 'external_api_call_failure' +ORDER BY occurred_at DESC +LIMIT 50; +``` + tracking outbox 默认配置: ```env @@ -264,6 +302,17 @@ GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456 outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。 +release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序: + +```bash +install -d -o genarrative -g genarrative -m 0750 /var/lib/genarrative/tracking-outbox +grep -n '^GENARRATIVE_TRACKING_OUTBOX' /etc/genarrative/api-server.env +systemctl restart genarrative-api.service +journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13' +``` + +`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 与 auth-store 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox`、`/var/lib/genarrative/auth` 归属 `genarrative:genarrative`。 + 常用检查思路: ```sql diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 6d35324e..0ace52cb 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -46,7 +46,7 @@ - 图像输入复用 `CreativeImageInputPanel`。 - 结果页每关画面编辑和素材配置里的 UI 背景生成也复用 `CreativeImageInputPanel`;三处只共享受控 UI 模块,不共享数据源、状态、action 或存储位置:入口页继续写 `formDraft` 与草稿编译 payload,关卡画面写 `levels[].pictureReference/pictureDescription` 并触发 `generate_puzzle_images`,UI 背景写 `levels[0].uiBackgroundPrompt/uiBackgroundImage*` 并触发 `generate_puzzle_ui_background`。通用图片面板的展示图和 AI 重绘参考图能力必须分开控制:结果页正式关卡图只作为预览图,不因存在 `displayImageSrc` 自动暴露 AI 重绘开关;只有本地上传、历史选择或已保存 `pictureReference` 可作为重绘参考图时,才显示 AI 重绘开关并把状态带入 `generate_puzzle_images`。 -- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化。 +- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;主链要求浏览器先经 `/api/assets/direct-upload-tickets` 直传 OSS 并确认 `asset_object`,创作 action 只提交 `referenceImageAssetObjectId(s)`,由后端换取 OSS 只读签名 URL 给 VectorEngine 读取;本地上传 Data URL 与历史 `/generated-*` 图片路径仅保留为旧草稿、旧入口或未迁移客户端的兼容输入。 - 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。 - 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。 - 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。 @@ -136,7 +136,7 @@ 6. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 7. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。 8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 -9. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。 +9. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。该容器参考图由后端内嵌到 `api-server`,不要依赖运行时当前目录下的 `public/` 文件。 10. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 11. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 7b539e81..607782c8 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -94,6 +94,7 @@ server-rs + Axum + SpacetimeDB 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。 +11. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。 ## 文案与编码 diff --git a/package-lock.json b/package-lock.json index b30a634e..3756b11e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "vite": "^6.2.0" }, "devDependencies": { + "@colbymchenry/codegraph": "^0.8.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.0", @@ -301,6 +302,64 @@ "node": ">=6.9.0" } }, + "node_modules/@clack/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", + "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", + "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.3.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@colbymchenry/codegraph": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph/-/codegraph-0.8.0.tgz", + "integrity": "sha512-VvEdio2gP1i8mOgGWPPytzIB0UPoVO0kahjx7suFIuwuVdwKhFjSCrpCONtfcNCVhlQ3g+EMR5VbBKWXBf7F6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^1.3.0", + "commander": "^14.0.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "jsonc-parser": "^3.3.1", + "node-sqlite3-wasm": "^0.8.30", + "picomatch": "^4.0.3", + "sisteransi": "^1.0.5", + "tree-sitter-wasms": "^0.1.11", + "web-tree-sitter": "^0.25.3" + }, + "bin": { + "codegraph": "dist/bin/codegraph.js" + }, + "engines": { + "node": ">=20.0.0 <25.0.0" + }, + "optionalDependencies": { + "better-sqlite3": "^12.4.1" + } + }, "node_modules/@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -2288,6 +2347,28 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -2299,6 +2380,46 @@ "node": ">=6.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -2354,6 +2475,32 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2465,6 +2612,14 @@ "node": "*" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -2504,6 +2659,16 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2598,6 +2763,23 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -2610,6 +2792,17 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2737,6 +2930,17 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -3083,6 +3287,17 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3129,6 +3344,33 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -3173,6 +3415,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3276,6 +3526,14 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3379,6 +3637,14 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3557,6 +3823,28 @@ "node": ">= 6" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3608,6 +3896,14 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3776,6 +4072,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4181,6 +4484,20 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -4193,6 +4510,25 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -4271,17 +4607,60 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "node_modules/node-sqlite3-wasm": { + "version": "0.8.57", + "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.57.tgz", + "integrity": "sha512-9sME3Agp6vqevHVgMvCV4PMsoTHjuwxjhiooNMiMjjPO3Ea3QbmyAbZn2H9Ko1rkTi2Oo8skv9Y3HvS+rSMcMA==", + "dev": true, + "license": "MIT" + }, "node_modules/nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", @@ -4436,7 +4815,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true, "engines": { "node": ">=12" }, @@ -4504,6 +4882,35 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4566,6 +4973,18 @@ "url": "https://github.com/sponsors/lupomontero" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4618,6 +5037,34 @@ } ] }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -4653,6 +5100,22 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4790,6 +5253,28 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4854,6 +5339,62 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4883,6 +5424,17 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4967,6 +5519,38 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5057,6 +5641,16 @@ "node": ">=14" } }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5094,6 +5688,20 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5210,6 +5818,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -6746,6 +7362,21 @@ "node": ">=14" } }, + "node_modules/web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/emscripten": "^1.40.0" + }, + "peerDependenciesMeta": { + "@types/emscripten": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -7194,6 +7825,47 @@ "@babel/helper-validator-identifier": "^7.28.5" } }, + "@clack/core": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz", + "integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==", + "dev": true, + "requires": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + } + }, + "@clack/prompts": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz", + "integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==", + "dev": true, + "requires": { + "@clack/core": "1.3.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + } + }, + "@colbymchenry/codegraph": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph/-/codegraph-0.8.0.tgz", + "integrity": "sha512-VvEdio2gP1i8mOgGWPPytzIB0UPoVO0kahjx7suFIuwuVdwKhFjSCrpCONtfcNCVhlQ3g+EMR5VbBKWXBf7F6w==", + "dev": true, + "requires": { + "@clack/prompts": "^1.3.0", + "better-sqlite3": "^12.4.1", + "commander": "^14.0.2", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "jsonc-parser": "^3.3.1", + "node-sqlite3-wasm": "^0.8.30", + "picomatch": "^4.0.3", + "sisteransi": "^1.0.5", + "tree-sitter-wasms": "^0.1.11", + "web-tree-sitter": "^0.25.3" + } + }, "@dimforge/rapier3d-compat": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", @@ -8377,11 +9049,51 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "optional": true + }, "baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==" }, + "better-sqlite3": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", + "dev": true, + "optional": true, + "requires": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "optional": true, + "requires": { + "file-uri-to-path": "1.0.0" + } + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "optional": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -8414,6 +9126,17 @@ "update-browserslist-db": "^1.2.0" } }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "optional": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -8485,6 +9208,13 @@ "get-func-name": "^2.0.2" } }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "optional": true + }, "cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -8517,6 +9247,12 @@ "delayed-stream": "~1.0.0" } }, + "commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8590,6 +9326,16 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "optional": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, "deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -8599,6 +9345,13 @@ "type-detect": "^4.0.0" } }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "optional": true + }, "deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8692,6 +9445,16 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, + "end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "optional": true, + "requires": { + "once": "^1.4.0" + } + }, "enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -8932,6 +9695,13 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "optional": true + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8974,6 +9744,30 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true + }, + "fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "requires": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "requires": { + "fast-string-width": "^3.0.2" + } + }, "fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -9004,6 +9798,13 @@ "flat-cache": "^3.0.4" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "optional": true + }, "fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9069,6 +9870,13 @@ "tslib": "^2.4.0" } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "optional": true + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9140,6 +9948,13 @@ "resolve-pkg-maps": "^1.0.0" } }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "optional": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9263,6 +10078,13 @@ "debug": "4" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "optional": true + }, "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9301,6 +10123,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "optional": true + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9424,6 +10253,12 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==" }, + "jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true + }, "keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9637,6 +10472,13 @@ "mime-db": "1.52.0" } }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "optional": true + }, "minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -9646,6 +10488,20 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "optional": true + }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "optional": true + }, "mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -9698,17 +10554,49 @@ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==" }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "optional": true + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dev": true, + "optional": true, + "requires": { + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "optional": true + } + } + }, "node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "node-sqlite3-wasm": { + "version": "0.8.57", + "resolved": "https://registry.npmjs.org/node-sqlite3-wasm/-/node-sqlite3-wasm-0.8.57.tgz", + "integrity": "sha512-9sME3Agp6vqevHVgMvCV4PMsoTHjuwxjhiooNMiMjjPO3Ea3QbmyAbZn2H9Ko1rkTi2Oo8skv9Y3HvS+rSMcMA==", + "dev": true + }, "nwsapi": { "version": "2.2.23", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", @@ -9822,8 +10710,7 @@ "picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "peer": true + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" }, "pkg-types": { "version": "1.3.1", @@ -9866,6 +10753,27 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9906,6 +10814,17 @@ "punycode": "^2.3.1" } }, + "pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "optional": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9934,6 +10853,28 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "optional": true + } + } + }, "react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -9960,6 +10901,18 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==" }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "optional": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10052,6 +11005,13 @@ "queue-microtask": "^1.2.2" } }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "optional": true + }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10103,6 +11063,31 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "optional": true + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "optional": true, + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -10126,6 +11111,16 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10184,6 +11179,33 @@ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==" }, + "tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "optional": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10252,6 +11274,15 @@ "punycode": "^2.3.0" } }, + "tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "dev": true, + "requires": { + "tree-sitter-wasms": "^0.1.11" + } + }, "ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -10276,6 +11307,16 @@ "get-tsconfig": "^4.7.5" } }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10350,6 +11391,13 @@ "requires-port": "^1.0.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "optional": true + }, "vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -11025,6 +12073,13 @@ "xml-name-validator": "^4.0.0" } }, + "web-tree-sitter": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.10.tgz", + "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", + "dev": true, + "requires": {} + }, "webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", diff --git a/package.json b/package.json index 4f65c0b6..9a6a6da8 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,11 @@ "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts", - "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke" + "check:content": "npm run check:data && npm run check:overrides && npm run check:smoke", + "codegraph:init": "codegraph init -i .", + "codegraph:index": "codegraph index .", + "codegraph:sync": "codegraph sync .", + "codegraph:status": "codegraph status ." }, "dependencies": { "@tailwindcss/vite": "^4.1.14", @@ -73,6 +77,7 @@ "vite": "^6.2.0" }, "devDependencies": { + "@colbymchenry/codegraph": "^0.8.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.0", diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 9e6d2cb3..501b8cc4 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -52,6 +52,8 @@ export type PuzzleAgentActionRequest = pictureDescription?: string; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; } @@ -63,6 +65,8 @@ export type PuzzleAgentActionRequest = pictureDescription?: string; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; @@ -73,6 +77,8 @@ export type PuzzleAgentActionRequest = promptText?: string | null; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; candidateCount?: number; diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index f216f263..2859bb73 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -51,6 +51,8 @@ export interface CreatePuzzleAgentSessionRequest { pictureDescription?: string; referenceImageSrc?: string | null; referenceImageSrcs?: string[]; + referenceImageAssetObjectId?: string | null; + referenceImageAssetObjectIds?: string[]; imageModel?: string | null; aiRedraw?: boolean; } diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 55601b18..992604cd 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -120,6 +120,115 @@ PY fi } +read_env_value() { + local file_path="$1" + local key="$2" + + if [[ ! -f "${file_path}" ]]; then + return 0 + fi + + local python_script=' +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +key = sys.argv[2] +if not path.exists(): + raise SystemExit(0) +for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + current_key, value = line.split("=", 1) + if current_key == key: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("\"", "'\''"): + value = value[1:-1] + print(value) + raise SystemExit(0) +' + + if [[ -r "${file_path}" ]]; then + python3 -c "${python_script}" "${file_path}" "${key}" + else + if ! sudo -n true >/dev/null 2>&1; then + echo "[production-api-deploy] 当前用户无权读取 ${file_path},且 sudo -n 不可用;无法检查运行态环境变量。" >&2 + exit 1 + fi + sudo -n python3 -c "${python_script}" "${file_path}" "${key}" + fi +} + +ensure_env_value() { + local file_path="$1" + local key="$2" + local default_value="$3" + local current_value + + current_value="$(read_env_value "${file_path}" "${key}")" + if [[ -n "${current_value}" ]]; then + return + fi + + echo "[production-api-deploy] 补齐 api-server 环境变量: ${key} -> ${file_path}" + write_env_value "${file_path}" "${key}" "${default_value}" +} + +run_privileged() { + if [[ "$(id -u)" -eq 0 ]]; then + "$@" + return + fi + if ! sudo -n true >/dev/null 2>&1; then + echo "[production-api-deploy] 当前用户不是 root,且 sudo -n 不可用;无法执行: $*" >&2 + exit 1 + fi + sudo -n "$@" +} + +ensure_runtime_dir() { + local path="$1" + local mode="$2" + + if [[ -z "${path}" ]]; then + return + fi + if [[ "${path}" != /* ]]; then + echo "[production-api-deploy] 运行态目录必须使用绝对路径,避免写入只读发布目录: ${path}" >&2 + exit 1 + fi + + echo "[production-api-deploy] 确保运行态目录可写: ${path}" + run_privileged install -d -o genarrative -g genarrative -m "${mode}" "${path}" +} + +ensure_runtime_env_and_dirs() { + local api_env_file="$1" + local tracking_enabled tracking_outbox_dir auth_store_path auth_store_dir + + # 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。 + # 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。 + ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED" "true" + ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR" "/var/lib/genarrative/tracking-outbox" + ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" + ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000" + ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456" + ensure_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json" + + tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")" + tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")" + if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then + ensure_runtime_dir "${tracking_outbox_dir}" "0750" + fi + + auth_store_path="$(read_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH")" + if [[ -n "${auth_store_path}" ]]; then + auth_store_dir="$(dirname "${auth_store_path}")" + ensure_runtime_dir "${auth_store_dir}" "0750" + fi +} + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="" VERSION="" @@ -243,6 +352,8 @@ if [[ -n "${SPACETIME_SERVER_URL}" ]]; then write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_SERVER_URL" "${SPACETIME_SERVER_URL}" fi +ensure_runtime_env_and_dirs "${API_ENV_FILE}" + mkdir -p "$(dirname "${CURRENT_LINK}")" ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index b584b90b..e54b42d0 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -292,6 +292,42 @@ write_env_value() { chown root:root "${file}" } +ensure_env_value() { + local file="$1" + local key="$2" + local default_value="$3" + local current_value + + current_value="$(read_env_value "${file}" "${key}")" + if [[ -n "${current_value}" ]]; then + return + fi + + echo "[server-provision] 补齐 api-server 环境变量: ${key} -> ${file}" + if [[ "${DRY_RUN}" != "true" ]]; then + write_env_value "${file}" "${key}" "${default_value}" + fi +} + +ensure_api_runtime_env_defaults() { + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure api-server runtime env defaults in ${API_ENV_FILE}" + return + fi + if [[ ! -f "${API_ENV_FILE}" ]]; then + echo "[server-provision] 环境文件不存在,无法补齐 api-server 运行态目录变量: ${API_ENV_FILE}" >&2 + exit 1 + fi + + # 已存在的生产 env 会被保留,不会整文件覆盖;这里仅补后续版本新增的运行态写入路径。 + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED" "true" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_DIR" "/var/lib/genarrative/tracking-outbox" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456" + ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json" +} + parse_json_string_field() { local json="$1" local key="$2" @@ -437,11 +473,55 @@ validate_nginx_tls() { fi } +disable_nginx_default_sites_enabled() { + local moves_file="$1" + local sites_enabled="/etc/nginx/sites-enabled" + local sites_disabled="/etc/nginx/sites-disabled" + local stamp source target base + local candidates=("${sites_enabled}/default" "${sites_enabled}/default."*) + + stamp="$(date +%Y%m%d%H%M%S)" + for source in "${candidates[@]}"; do + if [[ ! -e "${source}" && ! -L "${source}" ]]; then + continue + fi + + base="$(basename "${source}")" + target="${sites_disabled}/${base}.disabled-${stamp}" + echo "[server-provision] 禁用 Debian 默认 Nginx 站点,避免与 Genarrative server_name 冲突: ${source} -> ${target}" + mkdir -p "${sites_disabled}" + mv "${source}" "${target}" + printf "%s\t%s\n" "${target}" "${source}" >>"${moves_file}" + done +} + +restore_nginx_default_sites_enabled() { + local moves_file="$1" + local target source + + if [[ ! -f "${moves_file}" ]]; then + return + fi + + while IFS=$'\t' read -r target source || [[ -n "${target:-}" ]]; do + if [[ -z "${target:-}" || -z "${source:-}" ]]; then + continue + fi + if [[ -e "${target}" || -L "${target}" ]]; then + mkdir -p "$(dirname "${source}")" + if [[ ! -e "${source}" && ! -L "${source}" ]]; then + echo "[server-provision] 恢复 Debian 默认 Nginx 站点: ${target} -> ${source}" + mv "${target}" "${source}" + fi + fi + done <"${moves_file}" +} + install_nginx_config_with_rollback() { local config_target="/etc/nginx/conf.d/genarrative.conf" local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf" local config_source - local rendered_config rendered_snippet config_backup snippet_backup + local rendered_config rendered_snippet config_backup snippet_backup disabled_sites local had_config="false" local had_snippet="false" @@ -459,6 +539,7 @@ install_nginx_config_with_rollback() { echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}" if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ disable /etc/nginx/sites-enabled/default* if present" echo "+ nginx -t" echo "+ nginx -s reload" return @@ -468,6 +549,7 @@ install_nginx_config_with_rollback() { rendered_snippet="$(mktemp)" config_backup="$(mktemp)" snippet_backup="$(mktemp)" + disabled_sites="$(mktemp)" if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then validate_nginx_tls render_nginx_https_config >"${rendered_config}" @@ -487,6 +569,7 @@ install_nginx_config_with_rollback() { install -m 0644 "${rendered_config}" "${config_target}" install -m 0644 "${rendered_snippet}" "${snippet_target}" + disable_nginx_default_sites_enabled "${disabled_sites}" if ! nginx -t; then echo "[server-provision] nginx -t 失败,恢复写入前的 Nginx 配置。" >&2 @@ -500,13 +583,14 @@ install_nginx_config_with_rollback() { else rm -f "${snippet_target}" fi - rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" + restore_nginx_default_sites_enabled "${disabled_sites}" + rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" "${disabled_sites}" exit 1 fi echo "+ nginx -s reload" nginx -s reload - rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" + rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}" "${disabled_sites}" } cleanup_placeholder_nginx_config() { @@ -625,6 +709,7 @@ if [[ ! -f "${API_ENV_FILE}" ]]; then else echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" fi +ensure_api_runtime_env_defaults if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then sync_otelcol_install diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 33d46ae5..1c709b96 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -394,9 +394,13 @@ pub async fn confirm_asset_object( let result = state .spacetime_client() .confirm_asset_object( - build_confirm_asset_object_upsert_input(oss_client, payload) - .await - .map_err(map_confirm_asset_object_prepare_error)?, + build_confirm_asset_object_upsert_input( + oss_client, + payload, + authenticated.claims().user_id(), + ) + .await + .map_err(map_confirm_asset_object_prepare_error)?, ) .await .map_err(map_confirm_asset_object_error)?; @@ -592,6 +596,7 @@ fn supported_asset_history_kind_message() -> String { async fn build_confirm_asset_object_upsert_input( oss_client: &platform_oss::OssClient, payload: ConfirmAssetObjectRequest, + authenticated_owner_user_id: &str, ) -> Result { let configured_bucket = oss_client.config_bucket().to_string(); let resolved_bucket = payload @@ -629,6 +634,14 @@ async fn build_confirm_asset_object_upsert_input( { return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch); } + let owner_user_id = normalize_optional_value(payload.owner_user_id).or_else(|| { + let owner = authenticated_owner_user_id.trim(); + if owner.is_empty() { + None + } else { + Some(owner.to_string()) + } + }); let now_micros = current_utc_micros(); build_asset_object_upsert_input( @@ -645,7 +658,7 @@ async fn build_confirm_asset_object_upsert_input( normalize_optional_value(payload.content_hash), payload.asset_kind, payload.source_job_id, - payload.owner_user_id, + owner_user_id, payload.profile_id, payload.entity_id, now_micros, diff --git a/server-rs/crates/api-server/src/edutainment_baby_object.rs b/server-rs/crates/api-server/src/edutainment_baby_object.rs index 0458a4e6..5fcd8687 100644 --- a/server-rs/crates/api-server/src/edutainment_baby_object.rs +++ b/server-rs/crates/api-server/src/edutainment_baby_object.rs @@ -1049,6 +1049,7 @@ mod tests { base_url: "https://vector.example".to_string(), api_key: "secret".to_string(), request_timeout_ms: 180_000, + external_api_audit_state: None, }); assert_eq!( diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs new file mode 100644 index 00000000..2c609792 --- /dev/null +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -0,0 +1,372 @@ +use axum::http::StatusCode; +use module_runtime::RuntimeTrackingScopeKind; +use serde_json::{Value, json}; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{state::AppState, tracking::TrackingEventDraft}; + +pub(crate) const EXTERNAL_API_FAILURE_EVENT_KEY: &str = "external_api_call_failure"; +pub(crate) const EXTERNAL_API_AUDIT_MODULE_KEY: &str = "external-api"; + +#[derive(Clone, Debug)] +pub(crate) struct ExternalApiFailureDraft { + pub(crate) provider: &'static str, + pub(crate) endpoint: String, + pub(crate) operation: String, + pub(crate) failure_stage: &'static str, + pub(crate) status_code: Option, + pub(crate) status_class: Option<&'static str>, + pub(crate) timeout: bool, + pub(crate) retryable: bool, + pub(crate) error_message: String, + pub(crate) error_source: Option, + pub(crate) raw_excerpt: Option, + pub(crate) latency_ms: Option, + pub(crate) prompt_chars: Option, + pub(crate) reference_image_count: Option, + pub(crate) image_model: Option<&'static str>, +} + +impl ExternalApiFailureDraft { + pub(crate) fn new( + provider: &'static str, + endpoint: impl Into, + operation: impl Into, + failure_stage: &'static str, + error_message: impl Into, + ) -> Self { + Self { + provider, + endpoint: endpoint.into(), + operation: operation.into(), + failure_stage, + status_code: None, + status_class: None, + timeout: false, + retryable: false, + error_message: error_message.into(), + error_source: None, + raw_excerpt: None, + latency_ms: None, + prompt_chars: None, + reference_image_count: None, + image_model: None, + } + } + + pub(crate) fn with_status_code(mut self, status_code: Option) -> Self { + self.status_code = status_code; + self + } + + pub(crate) fn with_optional_status_class(mut self, status_class: Option<&'static str>) -> Self { + self.status_class = status_class; + self + } + + pub(crate) fn with_timeout(mut self, timeout: bool) -> Self { + self.timeout = timeout; + self + } + + pub(crate) fn with_retryable(mut self, retryable: bool) -> Self { + self.retryable = retryable; + self + } + + pub(crate) fn with_error_source(mut self, error_source: Option) -> Self { + self.error_source = error_source; + self + } + + pub(crate) fn with_raw_excerpt(mut self, raw_excerpt: Option) -> Self { + self.raw_excerpt = raw_excerpt; + self + } + + pub(crate) fn with_latency_ms(mut self, latency_ms: Option) -> Self { + self.latency_ms = latency_ms; + self + } + + pub(crate) fn with_prompt_chars(mut self, prompt_chars: Option) -> Self { + self.prompt_chars = prompt_chars; + self + } + + pub(crate) fn with_reference_image_count( + mut self, + reference_image_count: Option, + ) -> Self { + self.reference_image_count = reference_image_count; + self + } + + pub(crate) fn with_image_model(mut self, image_model: Option<&'static str>) -> Self { + self.image_model = image_model; + self + } +} + +/// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。 +pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str { + status_class(Some(status_code.as_u16())) +} + +/// 中文注释:外部供应商失败同时进入 OTLP 和 tracking_event;失败审计不能反向阻断主业务错误返回。 +pub(crate) async fn record_external_api_failure(state: &AppState, draft: ExternalApiFailureDraft) { + record_external_api_failure_otlp(&draft); + + let tracking_event = build_external_api_failure_tracking_draft(&draft); + if let Some(outbox) = state.tracking_outbox() { + match outbox + .enqueue(crate::tracking::build_tracking_event_input( + tracking_event.clone(), + )) + .await + { + Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {} + Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => { + tracing::warn!( + provider = draft.provider, + endpoint = %draft.endpoint, + operation = %draft.operation, + failure_stage = draft.failure_stage, + reason, + "外部 API 失败审计写入 outbox 被保护阈值拒绝,回退同步直写 SpacetimeDB" + ); + crate::tracking::record_tracking_event_after_success( + state, + &audit_request_context(), + tracking_event, + ) + .await; + } + Err(error) => { + tracing::warn!( + provider = draft.provider, + endpoint = %draft.endpoint, + operation = %draft.operation, + failure_stage = draft.failure_stage, + error = %error, + "外部 API 失败审计写入 outbox 失败,回退同步直写 SpacetimeDB" + ); + crate::tracking::record_tracking_event_after_success( + state, + &audit_request_context(), + tracking_event, + ) + .await; + } + } + return; + } + + crate::tracking::record_tracking_event_after_success( + state, + &audit_request_context(), + tracking_event, + ) + .await; +} + +pub(crate) fn build_external_api_failure_tracking_draft( + failure: &ExternalApiFailureDraft, +) -> TrackingEventDraft { + let mut draft = TrackingEventDraft::new( + EXTERNAL_API_FAILURE_EVENT_KEY, + EXTERNAL_API_AUDIT_MODULE_KEY, + ); + draft.scope_kind = RuntimeTrackingScopeKind::Module; + draft.scope_id = failure.provider.to_string(); + draft.metadata = build_external_api_failure_metadata(failure); + draft +} + +fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Value { + let mut metadata = json!({ + "provider": failure.provider, + "endpoint": failure.endpoint, + "operation": failure.operation, + "failureStage": failure.failure_stage, + "statusCode": failure.status_code, + "statusClass": failure.status_class.unwrap_or_else(|| status_class(failure.status_code)), + "timeout": failure.timeout, + "retryable": failure.retryable, + "errorMessage": truncate_field(failure.error_message.as_str(), 1_000), + "occurredAt": current_utc_iso_text(), + }); + + if let Some(latency_ms) = failure.latency_ms { + metadata["latencyMs"] = json!(latency_ms); + } + if let Some(prompt_chars) = failure.prompt_chars { + metadata["promptChars"] = json!(prompt_chars); + } + if let Some(reference_image_count) = failure.reference_image_count { + metadata["referenceImageCount"] = json!(reference_image_count); + } + if let Some(image_model) = failure.image_model { + metadata["imageModel"] = json!(image_model); + } + if let Some(source) = failure + .error_source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata["errorSource"] = json!(truncate_field(source, 1_000)); + } + if let Some(excerpt) = failure + .raw_excerpt + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + metadata["rawExcerpt"] = json!(truncate_field(excerpt, 800)); + } + + metadata +} + +pub(crate) fn is_retryable_external_api_failure( + status_code: Option, + timeout: bool, + connect: bool, +) -> bool { + timeout + || connect + || status_code.is_some_and(|status| { + status == StatusCode::TOO_MANY_REQUESTS.as_u16() + || status == StatusCode::REQUEST_TIMEOUT.as_u16() + || status >= 500 + }) +} + +fn record_external_api_failure_otlp(failure: &ExternalApiFailureDraft) { + crate::telemetry::record_external_api_failure( + failure.provider, + failure.failure_stage, + failure + .status_class + .unwrap_or_else(|| status_class(failure.status_code)), + failure.retryable, + ); + + tracing::error!( + provider = failure.provider, + endpoint = %failure.endpoint, + operation = %failure.operation, + failure_stage = failure.failure_stage, + status_code = failure.status_code, + status_class = failure.status_class.unwrap_or_else(|| status_class(failure.status_code)), + timeout = failure.timeout, + retryable = failure.retryable, + latency_ms = failure.latency_ms, + prompt_chars = failure.prompt_chars, + reference_image_count = failure.reference_image_count, + image_model = failure.image_model, + error = %failure.error_message, + "外部 API 调用失败" + ); +} + +fn status_class(status_code: Option) -> &'static str { + match status_code { + Some(100..=199) => "1xx", + Some(200..=299) => "2xx", + Some(300..=399) => "3xx", + Some(400..=499) => "4xx", + Some(500..=599) => "5xx", + Some(_) => "unknown", + None => "transport", + } +} + +fn audit_request_context() -> crate::request_context::RequestContext { + crate::request_context::RequestContext::new( + format!("external-api-audit-{}", Uuid::new_v4()), + "external-api audit".to_string(), + std::time::Duration::ZERO, + false, + ) +} + +fn truncate_field(value: &str, max_chars: usize) -> String { + value.chars().take(max_chars).collect() +} + +fn current_utc_iso_text() -> String { + shared_kernel::format_rfc3339(OffsetDateTime::now_utc()) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + + #[test] + fn external_api_failure_tracking_draft_uses_module_scope_and_safe_metadata() { + let draft = build_external_api_failure_tracking_draft( + &ExternalApiFailureDraft::new( + "vector-engine", + "https://vector.example/v1/images/generations", + "拼图 UI 背景图生成失败", + "upstream_status", + "上游 429", + ) + .with_status_code(Some(429)) + .with_retryable(true) + .with_latency_ms(Some(1234)) + .with_prompt_chars(Some(88)) + .with_reference_image_count(Some(2)) + .with_image_model(Some("gpt-image-2-all")), + ); + + assert_eq!(draft.event_key, EXTERNAL_API_FAILURE_EVENT_KEY); + assert_eq!(draft.scope_kind, RuntimeTrackingScopeKind::Module); + assert_eq!(draft.scope_id, "vector-engine"); + assert_eq!(draft.module_key, Some(EXTERNAL_API_AUDIT_MODULE_KEY)); + + let metadata = draft.metadata; + assert_eq!(metadata["provider"], "vector-engine"); + assert_eq!(metadata["statusCode"], 429); + assert_eq!(metadata["statusClass"], "4xx"); + assert_eq!(metadata["retryable"], true); + assert_eq!(metadata["latencyMs"], 1234); + assert_eq!(metadata["promptChars"], 88); + assert_eq!(metadata["referenceImageCount"], 2); + assert_eq!(metadata["imageModel"], "gpt-image-2-all"); + assert!(matches!(metadata["occurredAt"], Value::String(_))); + } + + #[test] + fn retryable_classification_keeps_transport_and_overload_failures_actionable() { + assert!(is_retryable_external_api_failure(None, true, false)); + assert!(is_retryable_external_api_failure(None, false, true)); + assert!(is_retryable_external_api_failure(Some(429), false, false)); + assert!(is_retryable_external_api_failure(Some(502), false, false)); + assert!(!is_retryable_external_api_failure(Some(400), false, false)); + } + + #[test] + fn app_error_status_class_can_override_successful_upstream_status() { + let draft = build_external_api_failure_tracking_draft( + &ExternalApiFailureDraft::new( + "vector-engine", + "https://cdn.example/generated.png", + "下载生成图片", + "image_download", + "下载生成图片失败", + ) + .with_status_code(Some(200)) + .with_optional_status_class(Some(app_error_status_class(StatusCode::BAD_GATEWAY))), + ); + + assert_eq!(draft.metadata["statusCode"], 200); + assert_eq!(draft.metadata["statusClass"], "5xx"); + } +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 61bfce45..a422c80c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -39,6 +39,7 @@ mod custom_world_rpg_draft_prompts; mod edutainment_baby_drawing; mod edutainment_baby_object; mod error_middleware; +mod external_api_audit; pub(crate) mod generated_asset_sheets; mod generated_image_assets; mod health; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 789352fd..3a599422 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -109,8 +109,9 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o"; const MATCH3D_ITEM_SIZE_LARGE: &str = "大"; const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中"; const MATCH3D_ITEM_SIZE_SMALL: &str = "小"; -const MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH: &str = - "public/match3d-background-references/pot-fused-reference.png"; +const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = + include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png"); +const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/"; const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关"; const MATCH3D_QUESTION_DIFFICULTY: &str = "如果难度是从1-10,你要创作的关卡是难度几"; diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index e2cdc7b3..74417201 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1156,6 +1156,21 @@ fn match3d_public_reference_image_paths_are_limited_to_known_assets() { ); } +#[test] +fn match3d_container_reference_image_is_embedded_for_api_only_deploy() { + let reference = load_match3d_container_reference_image() + .expect("container reference image should be compiled into api-server"); + + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.file_name, "match3d-container-reference.png"); + assert!( + reference + .bytes + .starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]), + "container reference image should be PNG bytes" + ); +} + #[test] fn match3d_cover_reference_prompt_marks_reference_images() { let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); @@ -1684,44 +1699,44 @@ fn match3d_required_item_images_require_five_views() { assert!(!has_match3d_required_item_images(&assets, 3)); let five_view_assets = (1..=3) - .map(|index| Match3DGeneratedItemAsset { - item_id: format!("match3d-item-{index}"), - item_name: format!("物品{index}"), - item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), - image_src: Some(format!( - "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" - )), - image_object_key: Some(format!( - "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" - )), - image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) - .map(|view_index| Match3DGeneratedItemImageView { - view_id: format!("view-{view_index:02}"), - view_index: view_index as u32, - image_src: Some(format!( - "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" - )), - image_object_key: Some(format!( - "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" - )), - }) - .collect(), - model_src: None, - model_object_key: None, - model_file_name: None, - task_uuid: None, - subscription_key: None, - sound_prompt: None, - background_music_title: None, - background_music_style: None, - background_music_prompt: None, - background_music: None, - click_sound: None, - background_asset: None, - status: "image_ready".to_string(), - error: None, - }) - .collect::>(); + .map(|index| Match3DGeneratedItemAsset { + item_id: format!("match3d-item-{index}"), + item_name: format!("物品{index}"), + item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), + image_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" + )), + image_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" + )), + image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) + .map(|view_index| Match3DGeneratedItemImageView { + view_id: format!("view-{view_index:02}"), + view_index: view_index as u32, + image_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" + )), + image_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" + )), + }) + .collect(), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: None, + status: "image_ready".to_string(), + error: None, + }) + .collect::>(); assert!(has_match3d_required_item_images(&five_view_assets, 3)); } diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 67bbe7eb..7359dbe6 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -386,7 +386,7 @@ pub(super) async fn generate_match3d_background_image( require_match3d_oss_client(state)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; - let reference_image = load_match3d_container_reference_image().await?; + let reference_image = load_match3d_container_reference_image()?; let generated_background = create_openai_image_generation( &http_client, &settings, @@ -486,7 +486,7 @@ pub(super) async fn generate_match3d_container_image( require_match3d_oss_client(state)?; let settings = require_openai_image_settings(state)?; let http_client = build_openai_image_http_client(&settings)?; - let reference_image = load_match3d_container_reference_image().await?; + let reference_image = load_match3d_container_reference_image()?; let container_prompt = build_match3d_container_generation_prompt(config, prompt); let generated_container = create_openai_image_edit( &http_client, @@ -563,15 +563,10 @@ pub(super) fn merge_match3d_container_image_into_background_asset( } } -async fn load_match3d_container_reference_image() -> Result { - let bytes = tokio::fs::read(MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH) - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": MATCH3D_AGENT_PROVIDER, - "message": format!("读取抓大鹅容器参考图失败:{error}"), - })) - })?; +pub(super) fn load_match3d_container_reference_image() -> Result { + // 中文注释:生产 API 单独发布二进制,Web 静态资源可能在另一轮流水线发布。 + // 容器参考图属于后端生图协议输入,必须随 api-server 编译进二进制,不能依赖运行时 cwd 下存在 public/。 + let bytes = MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES.to_vec(); if bytes.is_empty() { return Err( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ @@ -992,7 +987,9 @@ pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Opt ) { return None; } - Some(format!("public/{source}")) + Some(format!( + "{MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX}{source}" + )) } pub(super) fn collect_match3d_cover_reference_image_sources( diff --git a/server-rs/crates/api-server/src/modules/puzzle.rs b/server-rs/crates/api-server/src/modules/puzzle.rs index 55197b0d..fc2e18cb 100644 --- a/server-rs/crates/api-server/src/modules/puzzle.rs +++ b/server-rs/crates/api-server/src/modules/puzzle.rs @@ -1,6 +1,6 @@ use axum::{ Router, - extract::DefaultBodyLimit, + extract::{DefaultBodyLimit, FromRef}, middleware, routing::{get, post}, }; @@ -17,12 +17,13 @@ use crate::{ submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, - state::AppState, + state::{AppState, PuzzleApiState}, }; const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; pub fn router(state: AppState) -> Router { + // 中文注释:拼图 handler 只接收 PuzzleApiState,鉴权层仍使用全局 AppState。 Router::new() .route( "/api/runtime/puzzle/agent/sessions", @@ -181,4 +182,6 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .with_state(PuzzleApiState::from_ref(&state)) + .with_state(state) } 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 55554701..ebf6e8a8 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,21 +1,44 @@ -use std::time::Duration; +use std::{error::Error, time::Duration}; use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use reqwest::header; use serde_json::{Map, Value, json}; -use crate::{http_error::AppError, state::AppState}; +use crate::{ + external_api_audit::{ + ExternalApiFailureDraft, app_error_status_class, is_retryable_external_api_failure, + record_external_api_failure, + }, + http_error::AppError, + state::AppState, +}; pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = "gpt-image-2-all"; const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; -#[derive(Clone, Debug)] +#[derive(Clone)] pub(crate) struct OpenAiImageSettings { pub base_url: String, pub api_key: String, pub request_timeout_ms: u64, + pub external_api_audit_state: Option, +} + +impl std::fmt::Debug for OpenAiImageSettings { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OpenAiImageSettings") + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("request_timeout_ms", &self.request_timeout_ms) + .field( + "external_api_audit_enabled", + &self.external_api_audit_state.is_some(), + ) + .finish() + } } #[derive(Clone, Debug)] @@ -74,6 +97,7 @@ pub(crate) fn require_openai_image_settings( base_url: base_url.to_string(), api_key: api_key.to_string(), request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), + external_api_audit_state: Some(state.clone()), }) } @@ -103,15 +127,18 @@ pub(crate) async fn create_openai_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { + let request_url = vector_engine_images_generation_url(settings); + let normalized_size = normalize_image_size(size); let request_body = build_openai_image_request_body( prompt, negative_prompt, - size, + normalized_size.as_str(), candidate_count, reference_images, ); - let response = http_client - .post(vector_engine_images_generation_url(settings)) + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), @@ -121,16 +148,106 @@ pub(crate) async fn create_openai_image_generation( .json(&request_body) .send() .await - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:创建图片生成任务失败:{error}" - )) - })?; + { + Ok(response) => response, + Err(error) => { + let latency_ms = started_at.elapsed().as_millis() as u64; + let timeout = error.is_timeout(); + let connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{failure_context}:创建图片生成任务失败:{error}"); + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "request_send", + None, + None, + timeout, + connect, + message.as_str(), + source, + None, + Some(latency_ms), + Some(prompt.chars().count()), + Some(reference_images.len()), + ), + ) + .await; + return Err(map_openai_image_reqwest_error( + format!("{failure_context}:创建图片生成任务失败").as_str(), + request_url.as_str(), + error, + )); + } + }; let response_status = response.status(); - let response_text = response.text().await.map_err(|error| { - map_openai_image_request_error(format!("{failure_context}:读取图片生成响应失败:{error}")) - })?; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count = reference_images.len(), + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片生成 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + let latency_ms = started_at.elapsed().as_millis() as u64; + let timeout = error.is_timeout(); + let connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{failure_context}:读取图片生成响应失败:{error}"); + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "response_body", + Some(response_status.as_u16()), + None, + timeout, + connect, + message.as_str(), + source, + None, + Some(latency_ms), + Some(prompt.chars().count()), + Some(reference_images.len()), + ), + ) + .await; + return Err(map_openai_image_reqwest_error( + format!("{failure_context}:读取图片生成响应失败").as_str(), + request_url.as_str(), + error, + )); + } + }; if !response_status.is_success() { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "upstream_status", + Some(response_status.as_u16()), + None, + false, + false, + parse_api_error_message(response_text.as_str(), failure_context).as_str(), + None, + Some(truncate_raw(response_text.as_str())), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_images.len()), + ), + ) + .await; return Err(map_openai_image_upstream_error( response_status.as_u16(), response_text.as_str(), @@ -138,26 +255,114 @@ pub(crate) async fn create_openai_image_generation( )); } - let response_json = parse_json_payload(response_text.as_str(), failure_context)?; + let response_json = match parse_json_payload(response_text.as_str(), failure_context) { + Ok(response_json) => response_json, + Err(error) => { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "response_parse", + Some(response_status.as_u16()), + None, + false, + false, + error.body_text().as_str(), + None, + Some(truncate_raw(response_text.as_str())), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_images.len()), + ), + ) + .await; + return Err(error); + } + }; let generation_id = extract_generation_id(&response_json.payload) .unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros())); let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); let image_urls = extract_image_urls(&response_json.payload); if !image_urls.is_empty() { - let mut generated = - download_images_from_urls(http_client, generation_id, image_urls, candidate_count) - .await?; + let download_started_at = std::time::Instant::now(); + let mut generated = match download_images_from_urls( + http_client, + generation_id, + image_urls, + candidate_count, + ) + .await + { + Ok(generated) => generated, + Err(error) => { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "image_download", + Some(response_status.as_u16()), + Some(app_error_status_class(error.status_code())), + false, + false, + error.body_text().as_str(), + None, + None, + Some(download_started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_images.len()), + ), + ) + .await; + return Err(error); + } + }; generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + elapsed_ms = download_started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片下载完成" + ); return Ok(generated); } let b64_images = extract_b64_images(&response_json.payload); if !b64_images.is_empty() { let mut generated = images_from_base64(generation_id, b64_images, candidate_count); generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + failure_context, + "VectorEngine 图片 base64 解码完成" + ); return Ok(generated); } + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "missing_image", + Some(response_status.as_u16()), + None, + false, + false, + format!("{failure_context}:VectorEngine 未返回图片地址").as_str(), + None, + Some(truncate_raw(response_text.as_str())), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(reference_images.len()), + ), + ) + .await; Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, @@ -176,6 +381,8 @@ pub(crate) async fn create_openai_image_edit( failure_context: &str, ) -> Result { let task_id = format!("vector-engine-edit-{}", current_utc_micros()); + let request_url = vector_engine_images_edit_url(settings); + let normalized_size = normalize_image_size(size); let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) .file_name(reference_image.file_name.clone()) .mime_str(reference_image.mime_type.as_str()) @@ -190,9 +397,10 @@ pub(crate) async fn create_openai_image_edit( build_prompt_with_negative(prompt, negative_prompt), ) .text("n", "1") - .text("size", normalize_image_size(size)); - let response = http_client - .post(vector_engine_images_edit_url(settings).as_str()) + .text("size", normalized_size.clone()); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), @@ -201,16 +409,106 @@ pub(crate) async fn create_openai_image_edit( .multipart(form) .send() .await - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:创建图片编辑任务失败:{error}" - )) - })?; + { + Ok(response) => response, + Err(error) => { + let latency_ms = started_at.elapsed().as_millis() as u64; + let timeout = error.is_timeout(); + let connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{failure_context}:创建图片编辑任务失败:{error}"); + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "request_send", + None, + None, + timeout, + connect, + message.as_str(), + source, + None, + Some(latency_ms), + Some(prompt.chars().count()), + Some(1), + ), + ) + .await; + return Err(map_openai_image_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + error, + )); + } + }; let response_status = response.status(); - let response_text = response.text().await.map_err(|error| { - map_openai_image_request_error(format!("{failure_context}:读取图片编辑响应失败:{error}")) - })?; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count = 1usize, + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片编辑 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + let latency_ms = started_at.elapsed().as_millis() as u64; + let timeout = error.is_timeout(); + let connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{failure_context}:读取图片编辑响应失败:{error}"); + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "response_body", + Some(response_status.as_u16()), + None, + timeout, + connect, + message.as_str(), + source, + None, + Some(latency_ms), + Some(prompt.chars().count()), + Some(1), + ), + ) + .await; + return Err(map_openai_image_reqwest_error( + format!("{failure_context}:读取图片编辑响应失败").as_str(), + request_url.as_str(), + error, + )); + } + }; if !response_status.is_success() { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "upstream_status", + Some(response_status.as_u16()), + None, + false, + false, + parse_api_error_message(response_text.as_str(), failure_context).as_str(), + None, + Some(truncate_raw(response_text.as_str())), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(1), + ), + ) + .await; return Err(map_openai_image_upstream_error( response_status.as_u16(), response_text.as_str(), @@ -218,12 +516,62 @@ pub(crate) async fn create_openai_image_edit( )); } - let response_json = parse_json_payload(response_text.as_str(), failure_context)?; + let response_json = match parse_json_payload(response_text.as_str(), failure_context) { + Ok(response_json) => response_json, + Err(error) => { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "response_parse", + Some(response_status.as_u16()), + None, + false, + false, + error.body_text().as_str(), + None, + Some(truncate_raw(response_text.as_str())), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(1), + ), + ) + .await; + return Err(error); + } + }; let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); let image_urls = extract_image_urls(&response_json.payload); if !image_urls.is_empty() { - let mut generated = download_images_from_urls(http_client, task_id, image_urls, 1).await?; + let download_started_at = std::time::Instant::now(); + let mut generated = + match download_images_from_urls(http_client, task_id, image_urls, 1).await { + Ok(generated) => generated, + Err(error) => { + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "image_download", + Some(response_status.as_u16()), + Some(app_error_status_class(error.status_code())), + false, + false, + error.body_text().as_str(), + None, + None, + Some(download_started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(1), + ), + ) + .await; + return Err(error); + } + }; generated.actual_prompt = actual_prompt; return Ok(generated); } @@ -234,6 +582,25 @@ pub(crate) async fn create_openai_image_edit( return Ok(generated); } + record_openai_image_failure_if_configured( + settings, + build_openai_image_failure_audit_draft( + request_url.as_str(), + failure_context, + "missing_image", + Some(response_status.as_u16()), + None, + false, + false, + format!("{failure_context}:VectorEngine 未返回编辑图片").as_str(), + None, + Some(truncate_raw(response_text.as_str())), + Some(started_at.elapsed().as_millis() as u64), + Some(prompt.chars().count()), + Some(1), + ), + ) + .await; Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, @@ -402,6 +769,44 @@ fn map_openai_image_request_error(message: String) -> AppError { })) } +fn map_openai_image_reqwest_error( + context: &str, + request_url: &str, + error: reqwest::Error, +) -> AppError { + let is_timeout = error.is_timeout(); + let is_connect = error.is_connect(); + let source = error.source().map(ToString::to_string).unwrap_or_default(); + let message = format!("{context}:{error}"); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + timeout = is_timeout, + connect = is_connect, + request = error.is_request(), + body = error.is_body(), + source = %source, + message = %message, + "VectorEngine 图片请求发送失败" + ); + + AppError::from_status(status).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": message, + "endpoint": request_url, + "timeout": is_timeout, + "connect": is_connect, + "request": error.is_request(), + "body": error.is_body(), + "source": source, + })) +} + fn map_openai_image_upstream_error( upstream_status: u16, raw_text: &str, @@ -423,6 +828,53 @@ fn map_openai_image_upstream_error( })) } +async fn record_openai_image_failure_if_configured( + settings: &OpenAiImageSettings, + draft: ExternalApiFailureDraft, +) { + if let Some(state) = settings.external_api_audit_state.as_ref() { + record_external_api_failure(state, draft).await; + } +} + +fn build_openai_image_failure_audit_draft( + request_url: &str, + failure_context: &str, + failure_stage: &'static str, + status_code: Option, + status_class: Option<&'static str>, + timeout: bool, + connect: bool, + error_message: &str, + error_source: Option, + raw_excerpt: Option, + latency_ms: Option, + prompt_chars: Option, + reference_image_count: Option, +) -> ExternalApiFailureDraft { + ExternalApiFailureDraft::new( + VECTOR_ENGINE_PROVIDER, + request_url.to_string(), + failure_context.to_string(), + failure_stage, + error_message.to_string(), + ) + .with_status_code(status_code) + .with_optional_status_class(status_class) + .with_timeout(timeout) + .with_retryable(is_retryable_external_api_failure( + status_code, + timeout, + connect, + )) + .with_error_source(error_source) + .with_raw_excerpt(raw_excerpt) + .with_latency_ms(latency_ms) + .with_prompt_chars(prompt_chars) + .with_reference_image_count(reference_image_count) + .with_image_model(Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)) +} + fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { if raw_text.trim().is_empty() { return fallback_message.to_string(); @@ -629,11 +1081,13 @@ mod tests { base_url: "https://vector.example".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, + external_api_audit_state: None, }; let v1_settings = OpenAiImageSettings { base_url: "https://vector.example/v1".to_string(), api_key: "test-key".to_string(), request_timeout_ms: 1_000_000, + external_api_audit_state: None, }; assert_eq!( @@ -658,4 +1112,41 @@ mod tests { assert_eq!(images.images[0].mime_type, "image/png"); assert_eq!(images.images[0].extension, "png"); } + + #[test] + fn vector_engine_upstream_failure_builds_tracking_ready_audit_event() { + let audit = build_openai_image_failure_audit_draft( + "https://vector.example/v1/images/generations", + "拼图 UI 背景图生成失败", + "upstream_status", + Some(429), + None, + false, + false, + "上游限流", + None, + Some("{\"error\":\"rate limited\"}".to_string()), + Some(321), + Some(42), + Some(1), + ); + let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); + + assert_eq!( + tracking.event_key, + crate::external_api_audit::EXTERNAL_API_FAILURE_EVENT_KEY + ); + assert_eq!(tracking.scope_id, VECTOR_ENGINE_PROVIDER); + assert_eq!(tracking.metadata["provider"], VECTOR_ENGINE_PROVIDER); + assert_eq!(tracking.metadata["statusCode"], 429); + assert_eq!(tracking.metadata["statusClass"], "4xx"); + assert_eq!(tracking.metadata["failureStage"], "upstream_status"); + assert_eq!(tracking.metadata["retryable"], true); + assert_eq!(tracking.metadata["promptChars"], 42); + assert_eq!(tracking.metadata["referenceImageCount"], 1); + assert_eq!( + tracking.metadata["imageModel"], + VECTOR_ENGINE_GPT_IMAGE_2_MODEL + ); + } } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 018c02c7..3c1afb06 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -21,10 +21,8 @@ use module_assets::{ }; use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; -use platform_oss::{ - LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, - OssSignedGetObjectUrlRequest, -}; +use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest}; +use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; use serde_json::{Map, Value, json}; use shared_contracts::{ creation_audio::CreationAudioAsset, @@ -105,12 +103,9 @@ use crate::{ }, puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, - state::AppState, - vector_engine_audio_generation::{ - GeneratedCreationAudioTarget, generate_background_music_asset_for_creation, - }, - work_author::resolve_work_author_by_user_id, - work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, + state::PuzzleApiState, + work_author::resolve_puzzle_work_author_by_user_id, + work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success}, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; @@ -121,8 +116,6 @@ const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview"; const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; -const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music"; -const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music"; #[cfg(test)] const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024"; diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index d301c0b5..55216125 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -24,7 +24,7 @@ pub(crate) fn build_puzzle_form_seed_text_from_parts( } pub(crate) async fn save_puzzle_form_payload_before_compile( - state: &AppState, + state: &PuzzleApiState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, @@ -76,7 +76,7 @@ pub(crate) async fn save_puzzle_form_payload_before_compile( } pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing( - state: &AppState, + state: &PuzzleApiState, request_context: &RequestContext, session_id: &str, owner_user_id: &str, @@ -209,7 +209,7 @@ pub(crate) fn parse_puzzle_level_records_from_module_json( } pub(crate) async fn get_puzzle_session_for_image_generation( - state: &AppState, + state: &PuzzleApiState, session_id: String, owner_user_id: String, payload: &ExecutePuzzleAgentActionRequest, @@ -469,7 +469,7 @@ impl PuzzleLevelNaming { } pub(crate) async fn generate_puzzle_first_level_name( - state: &AppState, + state: &PuzzleApiState, picture_description: &str, ) -> PuzzleLevelNaming { if let Some(llm_client) = state.llm_client() { @@ -511,7 +511,7 @@ pub(crate) async fn generate_puzzle_first_level_name( } pub(crate) async fn generate_puzzle_first_level_name_from_image( - state: &AppState, + state: &PuzzleApiState, picture_description: &str, image: &PuzzleDownloadedImage, ) -> Option { @@ -1033,42 +1033,8 @@ pub(crate) fn attach_puzzle_level_ui_background( levels[index].ui_background_image_object_key = Some(generated.object_key); } -pub(crate) async fn generate_puzzle_background_music_required( - state: &AppState, - owner_user_id: &str, - profile_id: &str, - title: &str, -) -> Result { - let normalized_title = title.trim(); - if normalized_title.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成", - })), - ); - } - generate_background_music_asset_for_creation( - state, - owner_user_id, - String::new(), - normalized_title.to_string(), - Some("轻快, 拼图, 循环, instrumental".to_string()), - None, - GeneratedCreationAudioTarget { - entity_kind: PUZZLE_ENTITY_KIND.to_string(), - entity_id: profile_id.to_string(), - slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(), - asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(), - profile_id: Some(profile_id.to_string()), - storage_prefix: LegacyAssetPrefix::PuzzleAssets, - }, - ) - .await -} - pub(crate) async fn generate_puzzle_initial_ui_background_required( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, draft: &PuzzleResultDraftRecord, @@ -1128,7 +1094,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( } pub(crate) async fn compile_puzzle_draft_with_initial_cover( - state: &AppState, + state: &PuzzleApiState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1398,7 +1364,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover( } pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( - state: &AppState, + state: &PuzzleApiState, session_id: String, owner_user_id: String, prompt_text: Option<&str>, @@ -1417,7 +1383,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( })?; let http_client = reqwest::Client::new(); let uploaded_downloaded_image = - resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src) + resolve_puzzle_reference_image( + state, + &http_client, + uploaded_image_src, + Some(owner_user_id.as_str()), + ) .await .map(PuzzleDownloadedImage::from_resolved_reference_image) .map_err(|error| { @@ -1425,7 +1396,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "field": "referenceImageSrc", - "message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。", + "message": "关闭 AI 重绘时上传图必须是拼图图片 assetObjectId、图片 Data URL 或历史生成图片路径。", })) } else { error diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index 5055d0c3..68079bc5 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -18,7 +18,7 @@ pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppErr } pub(crate) async fn generate_puzzle_image_candidates( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, @@ -58,8 +58,13 @@ pub(crate) async fn generate_puzzle_image_candidates( .filter(|_| should_use_reference_image_edit) { Some(source) => { - let resolved = - resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; + let resolved = resolve_puzzle_reference_image( + state, + &http_client, + source, + Some(owner_user_id), + ) + .await?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), @@ -219,13 +224,13 @@ pub(crate) async fn generate_puzzle_image_candidates( } pub(crate) async fn generate_puzzle_ui_background_image( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, ) -> Result { - let settings = require_openai_image_settings(state)?; + let settings = require_openai_image_settings(state.root_state())?; let http_client = build_openai_image_http_client(&settings)?; let generated = create_openai_image_generation( &http_client, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index a47c00b1..795ae397 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -1,7 +1,7 @@ use super::*; pub async fn create_puzzle_agent_session( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, @@ -46,7 +46,7 @@ pub async fn create_puzzle_agent_session( } pub async fn generate_puzzle_onboarding_work( - State(state): State, + State(state): State, Extension(request_context): Extension, payload: Result, JsonRejection>, ) -> Result, Response> { @@ -161,7 +161,7 @@ pub async fn generate_puzzle_onboarding_work( } pub async fn save_puzzle_onboarding_work( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, @@ -270,7 +270,7 @@ pub async fn save_puzzle_onboarding_work( } pub async fn get_puzzle_agent_session( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -303,7 +303,7 @@ pub async fn get_puzzle_agent_session( } pub async fn submit_puzzle_agent_message( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -359,7 +359,7 @@ pub async fn submit_puzzle_agent_message( llm_client: state.llm_client(), session: &submitted_session, quick_fill_requested: payload.quick_fill_requested.unwrap_or(false), - enable_web_search: state.config.creation_agent_llm_web_search_enabled, + enable_web_search: state.creation_agent_llm_web_search_enabled(), }, |_| {}, ) @@ -401,7 +401,7 @@ pub async fn submit_puzzle_agent_message( } pub async fn stream_puzzle_agent_message( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -464,7 +464,7 @@ pub async fn stream_puzzle_agent_message( llm_client: state.llm_client(), session: &session, quick_fill_requested, - enable_web_search: state.config.creation_agent_llm_web_search_enabled, + enable_web_search: state.creation_agent_llm_web_search_enabled(), }, move |text| { let _ = reply_tx.send(text.to_string()); @@ -554,7 +554,7 @@ pub async fn stream_puzzle_agent_message( } pub async fn execute_puzzle_agent_action( - State(state): State, + State(state): State, AxumPath(session_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -595,6 +595,8 @@ pub async fn execute_puzzle_agent_action( has_reference_image = has_puzzle_reference_images( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), ), "拼图 Agent action 开始执行" ); @@ -604,6 +606,8 @@ pub async fn execute_puzzle_agent_action( let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); let prompt_text = payload @@ -627,7 +631,7 @@ pub async fn execute_puzzle_agent_action( }; let session = if ai_redraw { execute_billable_asset_operation_with_cost( - &state, + state.root_state(), &owner_user_id, "puzzle_initial_image", &billing_asset_id, @@ -652,7 +656,7 @@ pub async fn execute_puzzle_agent_action( compile_session_id.clone(), owner_user_id.clone(), prompt_text, - payload.reference_image_src.as_deref(), + primary_reference_image_src, now, ) .await @@ -737,7 +741,7 @@ pub async fn execute_puzzle_agent_action( })) }); let session = execute_billable_asset_operation_with_cost( - &state, + state.root_state(), &owner_user_id, "puzzle_generated_image", &billing_asset_id, @@ -787,6 +791,8 @@ pub async fn execute_puzzle_agent_action( let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), ); let primary_reference_image_src = reference_image_sources.first().map(String::as_str); @@ -942,7 +948,7 @@ pub async fn execute_puzzle_agent_action( })) }); let session = execute_billable_asset_operation_with_cost( - &state, + state.root_state(), &owner_user_id, "puzzle_ui_background_image", &billing_asset_id, @@ -1147,7 +1153,7 @@ pub async fn execute_puzzle_agent_action( let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( - &state, + state.root_state(), &owner_user_id, "puzzle_publish_work", &work_id, @@ -1235,7 +1241,7 @@ pub async fn execute_puzzle_agent_action( } pub async fn get_puzzle_works( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, Response> { @@ -1263,7 +1269,7 @@ pub async fn get_puzzle_works( } pub async fn get_puzzle_work_detail( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(_authenticated): Extension, @@ -1296,7 +1302,7 @@ pub async fn get_puzzle_work_detail( } pub async fn put_puzzle_work( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1355,7 +1361,7 @@ pub async fn put_puzzle_work( } pub async fn delete_puzzle_work( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1391,7 +1397,7 @@ pub async fn delete_puzzle_work( } pub async fn claim_puzzle_work_point_incentive( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1428,7 +1434,7 @@ pub async fn claim_puzzle_work_point_incentive( } pub async fn list_puzzle_gallery( - State(state): State, + State(state): State, Extension(request_context): Extension, ) -> Result { if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await { @@ -1487,7 +1493,7 @@ pub async fn list_puzzle_gallery( } pub async fn get_puzzle_gallery_detail( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, ) -> Result, Response> { @@ -1519,7 +1525,7 @@ pub async fn get_puzzle_gallery_detail( } pub async fn record_puzzle_gallery_like( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1556,7 +1562,7 @@ pub async fn record_puzzle_gallery_like( } pub async fn remix_puzzle_gallery_work( - State(state): State, + State(state): State, AxumPath(profile_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1599,7 +1605,7 @@ pub async fn remix_puzzle_gallery_work( } pub async fn start_puzzle_run( - State(state): State, + State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, payload: Result, JsonRejection>, @@ -1639,7 +1645,7 @@ pub async fn start_puzzle_run( ) })?; - record_work_play_start_after_success( + record_puzzle_work_play_start_after_success( &state, &request_context, WorkPlayTrackingDraft::new( @@ -1665,7 +1671,7 @@ pub async fn start_puzzle_run( } pub async fn get_puzzle_run( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1693,7 +1699,7 @@ pub async fn get_puzzle_run( } pub async fn swap_puzzle_pieces( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1750,7 +1756,7 @@ pub async fn swap_puzzle_pieces( } pub async fn drag_puzzle_piece_or_group( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1802,7 +1808,7 @@ pub async fn drag_puzzle_piece_or_group( } pub async fn advance_puzzle_next_level( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1854,7 +1860,7 @@ pub async fn advance_puzzle_next_level( } pub async fn update_puzzle_run_pause( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1898,7 +1904,7 @@ pub async fn update_puzzle_run_pause( } pub async fn use_puzzle_runtime_prop( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, @@ -1944,7 +1950,7 @@ pub async fn use_puzzle_runtime_prop( let fallback_run_id = run_id.clone(); let fallback_owner_user_id = owner_user_id.clone(); let run_result = execute_billable_asset_operation( - &state, + state.root_state(), &owner_user_id, billing_asset_kind, billing_asset_id.as_str(), @@ -1996,7 +2002,7 @@ pub async fn use_puzzle_runtime_prop( } pub async fn submit_puzzle_leaderboard( - State(state): State, + State(state): State, AxumPath(run_id): AxumPath, Extension(request_context): Extension, Extension(authenticated): Extension, diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index 6e6c91fd..db33ea10 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -343,11 +343,11 @@ fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool { } pub(super) fn map_puzzle_work_summary_response( - state: &AppState, + state: &PuzzleApiState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkSummaryResponse { let generation_status = resolve_puzzle_work_generation_status(&item); - let author = resolve_work_author_by_user_id( + let author = resolve_puzzle_work_author_by_user_id( state, &item.owner_user_id, Some(&item.author_display_name), @@ -391,10 +391,10 @@ pub(super) fn map_puzzle_work_summary_response( } pub(super) fn map_puzzle_gallery_card_response( - state: &AppState, + state: &PuzzleApiState, item: PuzzleGalleryCardRecord, ) -> PuzzleWorkSummaryResponse { - let author = resolve_work_author_by_user_id( + let author = resolve_puzzle_work_author_by_user_id( state, &item.owner_user_id, Some(&item.author_display_name), @@ -434,7 +434,7 @@ pub(super) fn map_puzzle_gallery_card_response( } pub(super) fn map_puzzle_work_profile_response( - state: &AppState, + state: &PuzzleApiState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkProfileResponse { let mut summary = map_puzzle_work_summary_response(state, item.clone()); @@ -491,7 +491,7 @@ pub(super) fn map_puzzle_recommended_next_work_response( } pub(super) async fn enrich_puzzle_run_author_name( - state: &AppState, + state: &PuzzleApiState, mut run: PuzzleRunRecord, ) -> PuzzleRunRecord { if let Some(level) = run.current_level.as_mut() { @@ -500,7 +500,7 @@ pub(super) async fn enrich_puzzle_run_author_name( .get_puzzle_gallery_detail(level.profile_id.clone()) .await { - level.author_display_name = resolve_work_author_by_user_id( + level.author_display_name = resolve_puzzle_work_author_by_user_id( state, &profile.owner_user_id, Some(&profile.author_display_name), @@ -632,7 +632,7 @@ pub(super) fn map_puzzle_board_response( } pub(super) fn resolve_author_display_name( - state: &AppState, + state: &PuzzleApiState, authenticated: &AuthenticatedAccessToken, ) -> String { state diff --git a/server-rs/crates/api-server/src/puzzle/tags.rs b/server-rs/crates/api-server/src/puzzle/tags.rs index e97f0f44..aee79739 100644 --- a/server-rs/crates/api-server/src/puzzle/tags.rs +++ b/server-rs/crates/api-server/src/puzzle/tags.rs @@ -1,7 +1,7 @@ use super::*; pub(super) async fn generate_puzzle_work_tags( - state: &AppState, + state: &PuzzleApiState, work_title: &str, work_description: &str, ) -> Vec { @@ -143,7 +143,7 @@ pub(super) fn build_fallback_puzzle_tags( } pub(super) async fn save_generated_puzzle_tags_to_session( - state: &AppState, + state: &PuzzleApiState, session_id: &str, owner_user_id: &str, payload: &ExecutePuzzleAgentActionRequest, diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 69425e82..cc5633e2 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -41,6 +41,7 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() { mime_type: "image/png".to_string(), bytes_len: cursor.get_ref().len(), bytes: cursor.into_inner(), + signed_read_url: None, }; let body = build_puzzle_vector_engine_image_request_body( @@ -64,6 +65,33 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() { ); } +#[test] +fn puzzle_vector_engine_generation_prefers_signed_reference_url() { + let reference_image = PuzzleResolvedReferenceImage { + mime_type: "image/png".to_string(), + bytes_len: 4, + bytes: b"test".to_vec(), + signed_read_url: Some( + "https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc" + .to_string(), + ), + }; + + let body = build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + "参考图里的小猫做成拼图主图。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + 1, + Some(&reference_image), + ); + + assert_eq!( + body["image"][0], + "https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc" + ); +} + #[test] fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { let settings = PuzzleVectorEngineSettings { @@ -131,6 +159,8 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() { "data:image/png;base64,e".to_string(), "data:image/png;base64,f".to_string(), ], + None, + &[], ); assert_eq!(sources.len(), 5); @@ -139,6 +169,62 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() { assert!(!sources.contains(&"data:image/png;base64,f".to_string())); } +#[test] +fn puzzle_reference_image_sources_prefer_asset_object_ids() { + let sources = collect_puzzle_reference_image_sources( + Some("data:image/png;base64,legacy"), + &["/generated-puzzle-assets/legacy.png".to_string()], + Some("asset-main-1"), + &[ + "asset-main-1".to_string(), + "asset-prompt-1".to_string(), + "asset-prompt-2".to_string(), + ], + ); + + assert_eq!( + sources, + vec![ + "asset-object:asset-main-1".to_string(), + "asset-object:asset-prompt-1".to_string(), + "asset-object:asset-prompt-2".to_string(), + "data:image/png;base64,legacy".to_string(), + "/generated-puzzle-assets/legacy.png".to_string(), + ] + ); +} + +#[test] +fn puzzle_asset_object_reference_requires_matching_owner() { + let asset_object = module_assets::AssetObjectRecord { + asset_object_id: "assetobj_reference_1".to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-puzzle-assets/reference/image.png".to_string(), + access_policy: module_assets::AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1024, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: Some("user-other".to_string()), + profile_id: None, + entity_id: None, + asset_kind: "puzzle_cover_image".to_string(), + created_at: "2026-05-21T00:00:00Z".to_string(), + updated_at: "2026-05-21T00:00:00Z".to_string(), + }; + + let error = validate_puzzle_reference_asset_object( + &asset_object, + Some("user-current"), + "genarrative-assets", + ) + .expect_err("其他账号的参考图资产应被拒绝"); + + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + assert!(error.body_text().contains("不属于当前账号")); +} + #[test] fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { let error = map_puzzle_vector_engine_request_error( @@ -250,6 +336,8 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { prompt_text: None, reference_image_src: None, reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), @@ -383,6 +471,7 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() { mime_type: "image/png".to_string(), bytes_len: 8, bytes: b"pngbytes".to_vec(), + signed_read_url: None, }; let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved); @@ -410,6 +499,8 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() { prompt_text: None, reference_image_src: None, reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), ai_redraw: None, candidate_count: Some(1), @@ -614,7 +705,9 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() { #[test] fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() { - let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); + let app_state = crate::state::AppState::new(crate::config::AppConfig::default()) + .expect("state should build"); + let state: PuzzleApiState = axum::extract::FromRef::from_ref(&app_state); let level = PuzzleDraftLevelRecord { level_id: "puzzle-level-1".to_string(), level_name: "雨夜猫街".to_string(), diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 40383193..e2ebbad6 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -37,6 +37,7 @@ pub(crate) struct PuzzleResolvedReferenceImage { pub(crate) mime_type: String, pub(crate) bytes_len: usize, pub(crate) bytes: Vec, + pub(crate) signed_read_url: Option, } pub(crate) struct GeneratedPuzzleImageCandidate { @@ -109,13 +110,9 @@ pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageMode } pub(crate) fn require_puzzle_vector_engine_settings( - state: &AppState, + state: &PuzzleApiState, ) -> Result { - let base_url = state - .config - .vector_engine_base_url - .trim() - .trim_end_matches('/'); + let base_url = state.vector_engine_base_url().trim().trim_end_matches('/'); if base_url.is_empty() { return Err( AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ @@ -127,9 +124,7 @@ pub(crate) fn require_puzzle_vector_engine_settings( } let api_key = state - .config - .vector_engine_api_key - .as_deref() + .vector_engine_api_key() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { @@ -147,11 +142,11 @@ pub(crate) fn require_puzzle_vector_engine_settings( } pub(crate) fn build_puzzle_image_http_client( - state: &AppState, + state: &PuzzleApiState, image_model: PuzzleImageModel, ) -> Result { let provider = image_model.provider_name(); - let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms; + let request_timeout_ms = state.vector_engine_image_request_timeout_ms(); reqwest::Client::builder() .timeout(Duration::from_millis(request_timeout_ms.max(1))) @@ -397,11 +392,19 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body( ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), ]); - if let Some(reference_image) = reference_image - && let Some(reference_data_url) = + if let Some(reference_image) = reference_image { + if let Some(signed_read_url) = reference_image + .signed_read_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + body.insert("image".to_string(), json!([signed_read_url])); + } else if let Some(reference_data_url) = build_puzzle_generation_reference_image_data_url(reference_image) - { - body.insert("image".to_string(), json!([reference_data_url])); + { + body.insert("image".to_string(), json!([reference_data_url])); + } } Value::Object(body) @@ -462,6 +465,48 @@ pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> b pub(crate) fn collect_puzzle_reference_image_sources( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], + reference_image_asset_object_id: Option<&str>, + reference_image_asset_object_ids: &[String], +) -> Vec { + let mut sources = Vec::new(); + for source in reference_image_asset_object_id + .into_iter() + .chain(reference_image_asset_object_ids.iter().map(String::as_str)) + .map(|asset_object_id| { + asset_object_id + .trim() + .strip_prefix("asset-object:") + .unwrap_or_else(|| asset_object_id.trim()) + }) + .filter(|asset_object_id| !asset_object_id.is_empty()) + .map(|asset_object_id| format!("asset-object:{asset_object_id}")) + .chain( + legacy_reference_image_src + .into_iter() + .chain(reference_image_srcs.iter().map(String::as_str)) + .map(str::to_string), + ) + { + let normalized = source.trim(); + if normalized.is_empty() { + continue; + } + if !sources + .iter() + .any(|existing: &String| existing == normalized) + { + sources.push(normalized.to_string()); + } + if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { + break; + } + } + sources +} + +pub(crate) fn collect_legacy_puzzle_reference_image_sources( + legacy_reference_image_src: Option<&str>, + reference_image_srcs: &[String], ) -> Vec { let mut sources = Vec::new(); for source in legacy_reference_image_src @@ -488,9 +533,16 @@ pub(crate) fn collect_puzzle_reference_image_sources( pub(crate) fn has_puzzle_reference_images( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], + reference_image_asset_object_id: Option<&str>, + reference_image_asset_object_ids: &[String], ) -> bool { - !collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs) - .is_empty() + !collect_puzzle_reference_image_sources( + legacy_reference_image_src, + reference_image_srcs, + reference_image_asset_object_id, + reference_image_asset_object_ids, + ) + .is_empty() } pub(crate) fn should_use_puzzle_reference_image_edit( @@ -546,10 +598,19 @@ pub(crate) async fn download_puzzle_images_from_urls( Ok(PuzzleGeneratedImages { task_id, images }) } -pub(crate) async fn resolve_puzzle_reference_image_as_data_url( - state: &AppState, +pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> { + source + .trim() + .strip_prefix("asset-object:") + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +pub(crate) async fn resolve_puzzle_reference_image( + state: &PuzzleApiState, http_client: &reqwest::Client, source: &str, + owner_user_id: Option<&str>, ) -> Result { let trimmed = source.trim(); if trimmed.is_empty() { @@ -562,6 +623,16 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( ); } + if let Some(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) { + return resolve_puzzle_reference_asset_object( + state, + http_client, + asset_object_id, + owner_user_id, + ) + .await; + } + if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { let bytes_len = parsed.bytes.len(); if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES { @@ -579,6 +650,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( mime_type: parsed.mime_type, bytes_len, bytes: parsed.bytes, + signed_read_url: None, }); } @@ -587,7 +659,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", - "message": "参考图必须是 Data URL 或 /generated-* 旧路径。", + "message": "参考图必须是 assetObjectId、Data URL 或 /generated-* 旧路径。", })), ); } @@ -598,7 +670,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "puzzle", "field": "referenceImageSrc", - "message": "参考图当前只支持 /generated-* 旧路径。", + "message": "参考图当前只支持 assetObjectId 或 /generated-* 旧路径。", })), ); } @@ -615,8 +687,159 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( expire_seconds: Some(60), }) .map_err(map_puzzle_asset_oss_error)?; + let signed_read_url = signed.signed_url; + download_signed_puzzle_reference_image( + http_client, + signed_read_url, + object_key, + None, + "referenceImageSrc", + ) + .await +} + +pub(crate) async fn resolve_puzzle_reference_image_as_data_url( + state: &PuzzleApiState, + http_client: &reqwest::Client, + source: &str, +) -> Result { + resolve_puzzle_reference_image(state, http_client, source, None).await +} + +async fn resolve_puzzle_reference_asset_object( + state: &PuzzleApiState, + http_client: &reqwest::Client, + asset_object_id: &str, + owner_user_id: Option<&str>, +) -> Result { + let asset_object = state + .spacetime_client() + .get_asset_object(asset_object_id.to_string()) + .await + .map_err(map_puzzle_client_error)? + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object_id, + "message": "参考图资产不存在或当前账号不可见。", + })) + })?; + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + validate_puzzle_reference_asset_object( + &asset_object, + owner_user_id, + oss_client.config_bucket(), + )?; + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: asset_object.object_key.clone(), + expire_seconds: Some(60), + }) + .map_err(map_puzzle_asset_oss_error)?; + let content_type = asset_object.content_type.clone(); + download_signed_puzzle_reference_image( + http_client, + signed.signed_url, + asset_object.object_key.as_str(), + content_type.as_deref(), + "referenceImageAssetObjectId", + ) + .await +} + +pub(crate) fn validate_puzzle_reference_asset_object( + asset_object: &module_assets::AssetObjectRecord, + owner_user_id: Option<&str>, + oss_bucket: &str, +) -> Result<(), AppError> { + if asset_object.bucket.trim() != oss_bucket.trim() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产 bucket 与当前服务 OSS 配置不一致。", + })), + ); + } + if asset_object.asset_kind.trim() != "puzzle_cover_image" { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产类型不属于拼图图片。", + })), + ); + } + let content_type = asset_object + .content_type + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if !content_type.starts_with("image/") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产不是图片类型。", + })), + ); + } + if asset_object.content_length == 0 + || asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64 + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产大小不符合拼图生成要求。", + "maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES, + "actualBytes": asset_object.content_length, + })), + ); + } + if let Some(expected_owner_user_id) = owner_user_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let actual_owner_user_id = asset_object + .owner_user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if actual_owner_user_id != Some(expected_owner_user_id) { + return Err( + AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ + "provider": "asset-object", + "field": "referenceImageAssetObjectId", + "assetObjectId": asset_object.asset_object_id, + "message": "参考图资产不属于当前账号。", + })), + ); + } + } + + Ok(()) +} + +async fn download_signed_puzzle_reference_image( + http_client: &reqwest::Client, + signed_read_url: String, + object_key: &str, + fallback_content_type: Option<&str>, + field: &str, +) -> Result { let response = http_client - .get(signed.signed_url) + .get(signed_read_url.as_str()) .send() .await .map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?; @@ -625,6 +848,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) + .or(fallback_content_type) .unwrap_or("image/png") .to_string(); let body = response.bytes().await.map_err(|error| { @@ -636,6 +860,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( "provider": "aliyun-oss", "message": format!("读取参考图失败,状态码:{status}"), "objectKey": object_key, + "field": field, })), ); } @@ -645,6 +870,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( "provider": "aliyun-oss", "message": "读取参考图失败:对象内容为空", "objectKey": object_key, + "field": field, })), ); } @@ -655,6 +881,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url( mime_type, bytes_len, bytes: body.to_vec(), + signed_read_url: Some(signed_read_url), }) } @@ -693,7 +920,7 @@ pub(crate) async fn download_puzzle_remote_image( } pub(crate) async fn persist_puzzle_generated_asset( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, @@ -805,7 +1032,7 @@ pub(crate) async fn persist_puzzle_generated_asset( } pub(crate) async fn persist_puzzle_ui_background_image( - state: &AppState, + state: &PuzzleApiState, owner_user_id: &str, session_id: &str, level_name: &str, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index a86348b6..6c6d1c60 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -141,6 +141,86 @@ impl FromRef for BackpressureState { } } +#[derive(Clone, Debug)] +pub struct PuzzleApiState { + root_state: AppState, + spacetime_client: SpacetimeClient, + puzzle_gallery_cache: PuzzleGalleryCache, + oss_client: Option, + auth_user_service: AuthUserService, + llm_client: Option, + creative_agent_gpt5_client: Option, + creation_agent_llm_web_search_enabled: bool, + vector_engine_image_request_timeout_ms: u64, +} + +impl PuzzleApiState { + pub fn root_state(&self) -> &AppState { + &self.root_state + } + + pub fn spacetime_client(&self) -> &SpacetimeClient { + &self.spacetime_client + } + + pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache { + &self.puzzle_gallery_cache + } + + pub fn oss_client(&self) -> Option<&OssClient> { + self.oss_client.as_ref() + } + + pub fn auth_user_service(&self) -> &AuthUserService { + &self.auth_user_service + } + + pub fn llm_client(&self) -> Option<&LlmClient> { + self.llm_client.as_ref() + } + + pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> { + self.creative_agent_gpt5_client.as_ref() + } + + pub fn creation_agent_llm_web_search_enabled(&self) -> bool { + self.creation_agent_llm_web_search_enabled + } + + pub fn vector_engine_image_request_timeout_ms(&self) -> u64 { + self.vector_engine_image_request_timeout_ms + } + + pub fn vector_engine_base_url(&self) -> &str { + self.root_state.config.vector_engine_base_url.as_str() + } + + pub fn vector_engine_api_key(&self) -> Option<&str> { + self.root_state.config.vector_engine_api_key.as_deref() + } +} + +impl FromRef for PuzzleApiState { + fn from_ref(state: &AppState) -> Self { + // 中文注释:拼图路由只暴露本能力需要的依赖快照,避免 handler 直接看见完整 AppState。 + Self { + root_state: state.clone(), + spacetime_client: state.spacetime_client.clone(), + puzzle_gallery_cache: state.puzzle_gallery_cache.clone(), + oss_client: state.oss_client.clone(), + auth_user_service: state.auth_user_service.clone(), + llm_client: state.llm_client.clone(), + creative_agent_gpt5_client: state.creative_agent_gpt5_client.clone(), + creation_agent_llm_web_search_enabled: state + .config + .creation_agent_llm_web_search_enabled, + vector_engine_image_request_timeout_ms: state + .config + .vector_engine_image_request_timeout_ms, + } + } +} + // Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。 #[derive(Debug)] pub struct AppStateInner { @@ -1350,4 +1430,23 @@ mod tests { ); assert!(client.config().official_fallback()); } + + #[test] + fn puzzle_api_state_exposes_puzzle_dependency_snapshot() { + let mut config = AppConfig::default(); + config.creation_agent_llm_web_search_enabled = false; + config.vector_engine_image_request_timeout_ms = 987_654; + + let state = AppState::new(config).expect("state should build"); + let puzzle_state: PuzzleApiState = FromRef::from_ref(&state); + + assert!(!puzzle_state.creation_agent_llm_web_search_enabled()); + assert_eq!( + puzzle_state.vector_engine_image_request_timeout_ms(), + 987_654 + ); + assert!(puzzle_state.llm_client().is_none()); + assert!(puzzle_state.creative_agent_gpt5_client().is_none()); + assert!(puzzle_state.oss_client().is_none()); + } } diff --git a/server-rs/crates/api-server/src/telemetry.rs b/server-rs/crates/api-server/src/telemetry.rs index 8c217634..d4a34db4 100644 --- a/server-rs/crates/api-server/src/telemetry.rs +++ b/server-rs/crates/api-server/src/telemetry.rs @@ -172,6 +172,23 @@ pub(crate) fn update_tracking_outbox_pending_files(files: usize) { TRACKING_OUTBOX_PENDING_FILES.store(files.min(i64::MAX as usize) as i64, Ordering::Relaxed); } +pub(crate) fn record_external_api_failure( + provider: &'static str, + failure_stage: &'static str, + status_class: &'static str, + retryable: bool, +) { + external_api_metrics().failures.add( + 1, + &[ + KeyValue::new("provider", provider), + KeyValue::new("failure_stage", failure_stage), + KeyValue::new("status_class", status_class), + KeyValue::new("retryable", retryable), + ], + ); +} + fn track_response_body_in_flight(response: Response) -> Response { response.map(|body| { HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed); @@ -211,6 +228,10 @@ struct TrackingOutboxMetrics { flushed_bytes: Counter, } +struct ExternalApiMetrics { + failures: Counter, +} + struct HttpRequestPermitsAvailableGauges { default: Arc, gallery: Arc, @@ -359,6 +380,21 @@ fn tracking_outbox_metrics() -> &'static TrackingOutboxMetrics { }) } +fn external_api_metrics() -> &'static ExternalApiMetrics { + static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); + METRICS.get_or_init(|| { + let meter = global::meter("genarrative-api"); + ExternalApiMetrics { + failures: meter + .u64_counter("genarrative.external_api.failures") + .with_description( + "External API call failures grouped by provider and failure stage", + ) + .build(), + } + }) +} + fn register_http_request_permits_available_metric() -> HttpRequestPermitsAvailableGauges { let gauges = HttpRequestPermitsAvailableGauges::new(); let meter = global::meter("genarrative-api"); diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index ad3b187c..82670c35 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -584,6 +584,26 @@ async fn record_route_tracking_event_via_outbox_after_success( record_tracking_event_input_after_success(state, request_context, event).await; } +pub(crate) fn build_tracking_event_input( + draft: TrackingEventDraft, +) -> module_runtime::RuntimeTrackingEventInput { + let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; + let event_id = build_tracking_event_id(&draft, occurred_at_micros); + + module_runtime::RuntimeTrackingEventInput { + event_id, + event_key: draft.event_key.to_string(), + scope_kind: draft.scope_kind, + scope_id: draft.scope_id, + user_id: draft.user_id, + owner_user_id: draft.owner_user_id, + profile_id: draft.profile_id, + module_key: draft.module_key.map(str::to_string), + metadata_json: draft.metadata.to_string(), + occurred_at_micros: occurred_at_micros as i64, + } +} + async fn record_tracking_event_input_after_success( state: &AppState, request_context: &RequestContext, @@ -642,26 +662,6 @@ async fn record_tracking_event_input_after_success( } } -fn build_tracking_event_input( - draft: TrackingEventDraft, -) -> module_runtime::RuntimeTrackingEventInput { - let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; - let event_id = build_tracking_event_id(&draft, occurred_at_micros); - - module_runtime::RuntimeTrackingEventInput { - event_id, - event_key: draft.event_key.to_string(), - scope_kind: draft.scope_kind, - scope_id: draft.scope_id, - user_id: draft.user_id, - owner_user_id: draft.owner_user_id, - profile_id: draft.profile_id, - module_key: draft.module_key.map(str::to_string), - metadata_json: draft.metadata.to_string(), - occurred_at_micros: occurred_at_micros as i64, - } -} - fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String { if draft.event_key == "daily_login" && draft.scope_kind == RuntimeTrackingScopeKind::User diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs index e45ebfdd..38b4bea6 100644 --- a/server-rs/crates/api-server/src/work_author.rs +++ b/server-rs/crates/api-server/src/work_author.rs @@ -1,6 +1,6 @@ use module_auth::AuthUser; -use crate::state::AppState; +use crate::state::{AppState, PuzzleApiState}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkAuthorSummary { @@ -14,6 +14,34 @@ pub fn resolve_work_author_by_user_id( owner_user_id: &str, fallback_display_name: Option<&str>, fallback_public_user_code: Option<&str>, +) -> WorkAuthorSummary { + resolve_work_author_by_user_id_with_service( + state.auth_user_service(), + owner_user_id, + fallback_display_name, + fallback_public_user_code, + ) +} + +pub fn resolve_puzzle_work_author_by_user_id( + state: &PuzzleApiState, + owner_user_id: &str, + fallback_display_name: Option<&str>, + fallback_public_user_code: Option<&str>, +) -> WorkAuthorSummary { + resolve_work_author_by_user_id_with_service( + state.auth_user_service(), + owner_user_id, + fallback_display_name, + fallback_public_user_code, + ) +} + +fn resolve_work_author_by_user_id_with_service( + auth_user_service: &module_auth::AuthUserService, + owner_user_id: &str, + fallback_display_name: Option<&str>, + fallback_public_user_code: Option<&str>, ) -> WorkAuthorSummary { let fallback_display_name = normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string()); @@ -26,7 +54,7 @@ pub fn resolve_work_author_by_user_id( }; }; - match state.auth_user_service().get_user_by_id(&owner_user_id) { + 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, diff --git a/server-rs/crates/api-server/src/work_play_tracking.rs b/server-rs/crates/api-server/src/work_play_tracking.rs index cad8eb56..33d722db 100644 --- a/server-rs/crates/api-server/src/work_play_tracking.rs +++ b/server-rs/crates/api-server/src/work_play_tracking.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use crate::{ auth::AuthenticatedAccessToken, request_context::RequestContext, - state::AppState, + state::{AppState, PuzzleApiState}, tracking::{TrackingEventDraft, record_tracking_event_after_success}, }; @@ -68,6 +68,22 @@ pub(crate) async fn record_work_play_start_after_success( state: &AppState, request_context: &RequestContext, draft: WorkPlayTrackingDraft, +) { + record_work_play_start_input_after_success(state, request_context, draft).await; +} + +pub(crate) async fn record_puzzle_work_play_start_after_success( + state: &PuzzleApiState, + request_context: &RequestContext, + draft: WorkPlayTrackingDraft, +) { + record_work_play_start_input_after_success(state.root_state(), request_context, draft).await; +} + +async fn record_work_play_start_input_after_success( + state: &AppState, + request_context: &RequestContext, + draft: WorkPlayTrackingDraft, ) { let mut metadata = json!({ "operation": WORK_PLAY_START_EVENT_KEY, diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index aec6e3e9..7f416ca7 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -18,6 +18,10 @@ pub struct CreatePuzzleAgentSessionRequest { #[serde(default)] pub reference_image_srcs: Vec, #[serde(default)] + pub reference_image_asset_object_id: Option, + #[serde(default)] + pub reference_image_asset_object_ids: Vec, + #[serde(default)] pub image_model: Option, #[serde(default)] pub ai_redraw: Option, @@ -43,6 +47,10 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub reference_image_srcs: Vec, #[serde(default)] + pub reference_image_asset_object_id: Option, + #[serde(default)] + pub reference_image_asset_object_ids: Vec, + #[serde(default)] pub image_model: Option, #[serde(default)] pub ai_redraw: Option, diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index 4cb2c2b7..f8814228 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -46,6 +46,21 @@ impl SpacetimeClient { .await } + pub async fn get_asset_object( + &self, + asset_object_id: String, + ) -> Result, SpacetimeClientError> { + self.read_after_connect("get_asset_object", move |connection| { + Ok(connection + .db() + .asset_object() + .asset_object_id() + .find(&asset_object_id) + .map(map_asset_object_row)) + }) + .await + } + pub async fn bind_asset_object_to_entity( &self, input: module_assets::AssetEntityBindingInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 87faa017..b7706b71 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -595,6 +595,7 @@ impl SpacetimeClient { "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", "SELECT * FROM creation_entry_config", "SELECT * FROM creation_entry_type_config", + "SELECT * FROM asset_object", ] { if let Ok(subscription) = self .subscribe_cached_read_model_query(connection, broken.clone(), query, false) diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 34d50960..5b78095d 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -131,7 +131,9 @@ pub use self::wooden_fish::{ }; pub(crate) use self::ai::map_ai_task_procedure_result; -pub(crate) use self::assets::{map_entity_binding_procedure_result, map_procedure_result}; +pub(crate) use self::assets::{ + map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result, +}; pub(crate) use self::auth::{ map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result, }; diff --git a/server-rs/crates/spacetime-client/src/mapper/assets.rs b/server-rs/crates/spacetime-client/src/mapper/assets.rs index 0e9586f3..8c46ab82 100644 --- a/server-rs/crates/spacetime-client/src/mapper/assets.rs +++ b/server-rs/crates/spacetime-client/src/mapper/assets.rs @@ -115,6 +115,26 @@ pub(crate) fn map_snapshot( } } +pub(crate) fn map_asset_object_row(row: AssetObject) -> AssetObjectRecord { + build_asset_object_record(module_assets::AssetObjectUpsertSnapshot { + asset_object_id: row.asset_object_id, + bucket: row.bucket, + object_key: row.object_key, + access_policy: map_access_policy_back(row.access_policy), + content_type: row.content_type, + content_length: row.content_length, + content_hash: row.content_hash, + version: row.version, + source_job_id: row.source_job_id, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + entity_id: row.entity_id, + asset_kind: row.asset_kind, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + pub(crate) fn map_access_policy( value: AssetObjectAccessPolicy, ) -> crate::module_bindings::AssetObjectAccessPolicy { diff --git a/src/PuzzlePlaygroundApp.tsx b/src/PuzzlePlaygroundApp.tsx index d5794bfd..429b0761 100644 --- a/src/PuzzlePlaygroundApp.tsx +++ b/src/PuzzlePlaygroundApp.tsx @@ -22,7 +22,7 @@ const PLACEHOLDER_PUZZLE_IMAGE = - + diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 02b7c773..005c373a 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -173,7 +173,7 @@ function ImageFrame({ }) { return (
{src ? (
@@ -313,7 +313,7 @@ function OpeningCgPreview({
{isGenerating ? (
-
+
) : null} {openingCg?.status === 'failed' && openingCg.errorMessage ? ( @@ -437,7 +437,7 @@ function CatalogCard({
@@ -453,7 +453,7 @@ function CatalogCard({ onClick={disabled ? undefined : onClick} aria-disabled={disabled} className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${ - isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel' + isSelected ? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]' : 'platform-subpanel' }`} >
@@ -491,7 +491,7 @@ function CatalogCard({ onClick={disabled ? undefined : onClick} aria-disabled={disabled} className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${ - isSelected ? 'border-rose-300/35 bg-rose-500/10' : 'platform-subpanel' + isSelected ? 'border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)]' : 'platform-subpanel' }`} >
@@ -899,7 +899,7 @@ export function CustomWorldEntityCatalog({ ref={scrollContainerRef} className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2 2xl:space-y-5 2xl:pr-3" > -
+
世界档案
@@ -913,7 +913,7 @@ export function CustomWorldEntityCatalog({
-
+
{RESULT_TABS.map((tab) => (
diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 70ff15d7..b540a4f0 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -225,7 +225,7 @@ export function CustomWorldGenerationView({
@@ -283,9 +283,9 @@ export function CustomWorldGenerationView({ )} className={`rounded-2xl border px-4 py-3 transition-colors ${ step.status === 'completed' - ? 'border-emerald-400/16 bg-emerald-500/8' + ? 'border-[var(--platform-success-border)] bg-[var(--platform-success-bg)]' : step.status === 'active' - ? 'border-sky-300/22 bg-sky-500/10' + ? 'border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)]' : 'platform-subpanel' } custom-world-generation-step`} initial={ @@ -317,9 +317,9 @@ export function CustomWorldGenerationView({ {error ? ( -
+
{error}
) : null} @@ -364,7 +364,7 @@ export function CustomWorldGenerationView({ diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 6c346beb..45de37cb 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -260,7 +260,7 @@ function ThemeOptionCard({ onClick={onClick} className={`platform-subpanel w-full rounded-[1.5rem] p-4 text-left transition ${ active - ? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(255,91,132,0.14)]' + ? 'border-[var(--platform-surface-hover-border)] shadow-[0_18px_44px_rgba(112,57,30,0.14)]' : 'hover:border-[var(--platform-surface-hover-border)]' }`} > @@ -534,8 +534,8 @@ export function AccountModal({ onPlatformThemeChange('light')} />
-
+
陶泥儿
视觉叙事 RPG
diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx index fd2345ff..aed59239 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx @@ -127,7 +127,7 @@ export function BarkBattleConfigEditor({ value={title} disabled={isBusy} onChange={(event) => setTitle(event.target.value)} - className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" + className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" maxLength={40} aria-label="作品标题" /> @@ -141,7 +141,7 @@ export function BarkBattleConfigEditor({ value={description} disabled={isBusy} onChange={(event) => setDescription(event.target.value)} - className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" + className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" maxLength={160} placeholder="" aria-label="简介" @@ -157,7 +157,7 @@ export function BarkBattleConfigEditor({ value={themePreset} disabled={isBusy} onChange={(event) => setThemePreset(event.target.value)} - className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" + className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" aria-label="主题背景" > {THEME_OPTIONS.map((option) => ( @@ -180,7 +180,7 @@ export function BarkBattleConfigEditor({ event.target.value as BarkBattleDifficultyPreset, ) } - className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" + className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" aria-label="难度预设" > {DIFFICULTY_OPTIONS.map((option) => ( @@ -201,7 +201,7 @@ export function BarkBattleConfigEditor({ onChange={(event) => setPlayerDogSkinPreset(event.target.value) } - className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" + className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" aria-label="玩家狗狗" > {DOG_SKIN_OPTIONS.map((option) => ( @@ -222,7 +222,7 @@ export function BarkBattleConfigEditor({ onChange={(event) => setOpponentDogSkinPreset(event.target.value) } - className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" + className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" aria-label="对手狗狗" > {DOG_SKIN_OPTIONS.map((option) => ( diff --git a/src/components/common/CreativeImageInputPanel.tsx b/src/components/common/CreativeImageInputPanel.tsx index 8116edb4..db299227 100644 --- a/src/components/common/CreativeImageInputPanel.tsx +++ b/src/components/common/CreativeImageInputPanel.tsx @@ -14,6 +14,7 @@ export type CreativeImageInputReferenceImage = { id: string; label: string; imageSrc: string; + assetObjectId?: string | null; }; export type CreativeImageInputPanelLabels = { @@ -207,7 +208,7 @@ export function CreativeImageInputPanel({ type="button" disabled={disabled} onClick={onHistoryClick} - className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${ + className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] ${ disabled ? 'cursor-not-allowed opacity-55' : '' }`} aria-label={labels.history ?? '选择历史图片'} @@ -232,7 +233,7 @@ export function CreativeImageInputPanel({
{error ? ( -
+
{error}
) : null} diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index e2c7935b..1bfa19e1 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -136,7 +136,7 @@ function CreationAgentOperationBanner({ : 'platform-banner--success'; const progress = normalizeCreationAgentProgress(visibleOperation.progress); const progressFillStyle = isFailed - ? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' } + ? { background: 'linear-gradient(90deg, #c7653d 0%, #a6402f 100%)' } : isRunning ? { background: 'var(--platform-button-primary-fill)' } : { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' }; diff --git a/src/components/creative-agent/CreativeAgentWorkspace.tsx b/src/components/creative-agent/CreativeAgentWorkspace.tsx index 918da8cb..30d527aa 100644 --- a/src/components/creative-agent/CreativeAgentWorkspace.tsx +++ b/src/components/creative-agent/CreativeAgentWorkspace.tsx @@ -171,7 +171,7 @@ export function CreativeAgentWorkspace({ {targetBinding ? (
- +
@@ -203,7 +203,7 @@ export function CreativeAgentWorkspace({ key={message.id} className={`max-w-[86%] rounded-[1.15rem] px-4 py-3 text-sm leading-6 ${ message.role === 'user' - ? 'ml-auto bg-[var(--platform-button-primary-fill)] text-[var(--platform-button-primary-text)]' + ? 'ml-auto bg-[var(--platform-button-primary-solid)] text-[var(--platform-button-primary-text)]' : 'platform-subpanel text-[var(--platform-text-base)]' }`} > diff --git a/src/components/jump-hop-result/JumpHopResultView.tsx b/src/components/jump-hop-result/JumpHopResultView.tsx index acef8e4f..d2f6cd78 100644 --- a/src/components/jump-hop-result/JumpHopResultView.tsx +++ b/src/components/jump-hop-result/JumpHopResultView.tsx @@ -60,13 +60,13 @@ const difficultyToneByValue: Record< { accent: string; soft: string; label: string } > = { advanced: { - accent: '#f97316', + accent: '#df7f40', soft: 'rgba(249, 115, 22, 0.16)', label: '进阶', }, challenge: { - accent: '#e11d48', - soft: 'rgba(225, 29, 72, 0.16)', + accent: '#b64a35', + soft: 'rgba(182, 98, 63, 0.16)', label: '挑战', }, easy: { diff --git a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx index 08859ffd..7d99810c 100644 --- a/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx +++ b/src/components/jump-hop-runtime/JumpHopRuntimeShell.tsx @@ -290,9 +290,9 @@ export function JumpHopRuntimeShell({ }; return ( -
+
-
+
diff --git a/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx b/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx index 003ddf6a..48893bcf 100644 --- a/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx +++ b/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx @@ -142,10 +142,10 @@ function VisualNovelStyleButton({ aria-pressed={active} aria-label={label} onClick={onClick} - className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:h-[5.45rem] sm:w-[6.4rem] ${ + className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-warm-border)] sm:h-[5.45rem] sm:w-[6.4rem] ${ active - ? 'border-rose-300 bg-white shadow-[0_8px_18px_rgba(190,18,60,0.10)] ring-2 ring-rose-100' - : 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95' + ? 'border-[var(--platform-surface-hover-border)] bg-white shadow-[0_8px_18px_rgba(112,57,30,0.10)] ring-2 ring-[var(--platform-warm-border)]' + : 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-[var(--platform-surface-hover-border)] hover:bg-white/95' } ${disabled ? 'cursor-not-allowed opacity-55' : ''}`} > {imageSrc ? ( @@ -160,12 +160,12 @@ function VisualNovelStyleButton({ )} {active ? ( - + ) : null} @@ -254,7 +254,7 @@ export function VisualNovelAgentWorkspace({

{title}

- + BETA
@@ -280,7 +280,7 @@ export function VisualNovelAgentWorkspace({ ideaText: event.target.value, })) } - className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100 sm:min-h-[9rem] lg:min-h-[14rem]" + className="h-full min-h-[7.75rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)] sm:min-h-[9rem] lg:min-h-[14rem]" aria-label="一句话创作" /> diff --git a/src/components/visual-novel-runtime/VisualNovelAttributePanel.tsx b/src/components/visual-novel-runtime/VisualNovelAttributePanel.tsx index c94da2d7..a2a7576f 100644 --- a/src/components/visual-novel-runtime/VisualNovelAttributePanel.tsx +++ b/src/components/visual-novel-runtime/VisualNovelAttributePanel.tsx @@ -25,7 +25,7 @@ export function VisualNovelAttributePanel({ run }: VisualNovelAttributePanelProp
diff --git a/src/components/visual-novel-runtime/VisualNovelSavePanel.tsx b/src/components/visual-novel-runtime/VisualNovelSavePanel.tsx index 5debd432..96b9d967 100644 --- a/src/components/visual-novel-runtime/VisualNovelSavePanel.tsx +++ b/src/components/visual-novel-runtime/VisualNovelSavePanel.tsx @@ -42,7 +42,7 @@ export function VisualNovelSavePanel({ type="button" disabled={!onSaveRun || isSaving} onClick={onSaveRun} - className="flex min-h-12 w-full items-center justify-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-[var(--platform-button-primary-fill)] px-4 text-sm font-black text-white transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55" + className="flex min-h-12 w-full items-center justify-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-[var(--platform-button-primary-solid)] px-4 text-sm font-black text-white transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55" > {isSaving ? ( diff --git a/src/games/bark-battle/ui/BarkBattleHud.css b/src/games/bark-battle/ui/BarkBattleHud.css index 65aded4d..f9b7a6ec 100644 --- a/src/games/bark-battle/ui/BarkBattleHud.css +++ b/src/games/bark-battle/ui/BarkBattleHud.css @@ -111,7 +111,7 @@ } .bark-battle-primary-button { - background: linear-gradient(135deg, #facc15, #fb7185); + background: linear-gradient(135deg, #facc15, #c7653d); } .bark-battle-status-card, diff --git a/src/index.css b/src/index.css index a1691f57..7434dda0 100644 --- a/src/index.css +++ b/src/index.css @@ -35,7 +35,7 @@ --platform-bottom-nav-label-tracking: 0.18em; --platform-bottom-nav-content-gap: 0.22rem; --platform-bottom-nav-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 8px 18px rgba(255, 91, 132, 0.1); + 0 8px 18px rgba(182, 98, 63, 0.1); } html, @@ -485,272 +485,277 @@ body { .platform-theme--light { color-scheme: light; + --platform-accent: #c7653d; --platform-body-fill: radial-gradient( - circle at top left, - rgba(255, 196, 214, 0.14), - transparent 24% - ), - radial-gradient( - circle at 88% 4%, - rgba(255, 222, 196, 0.12), - transparent 20% - ), - radial-gradient( - circle at bottom, - rgba(255, 214, 225, 0.08), + circle at 8% 0%, + rgba(240, 203, 169, 0.24), transparent 26% ), - linear-gradient(180deg, #fffdfd 0%, #fffefe 50%, #fff8fa 100%); - --platform-panel-shadow: 0 22px 60px rgba(215, 87, 134, 0.12), - 0 8px 20px rgba(255, 255, 255, 0.82); + radial-gradient( + circle at 92% 8%, + rgba(226, 171, 134, 0.2), + transparent 22% + ), + radial-gradient( + circle at 54% 100%, + rgba(204, 117, 76, 0.08), + transparent 30% + ), + linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%); + --platform-panel-shadow: 0 22px 60px rgba(112, 57, 30, 0.1), + 0 8px 22px rgba(255, 255, 255, 0.82); --platform-panel-fill: linear-gradient( 180deg, rgba(255, 255, 255, 0.985), - rgba(255, 250, 251, 0.96) + rgba(253, 248, 243, 0.96) ); --platform-panel-fill-soft: linear-gradient( 180deg, - rgba(255, 255, 255, 0.95), - rgba(255, 248, 250, 0.88) + rgba(255, 254, 252, 0.96), + rgba(250, 243, 236, 0.9) ); --platform-hero-fill: linear-gradient( 135deg, - rgba(255, 139, 162, 0.9), - rgba(255, 184, 153, 0.88) + rgba(244, 226, 211, 0.96), + rgba(238, 208, 183, 0.9) ); - --platform-hero-glow-a: rgba(255, 255, 255, 0.22); - --platform-hero-glow-b: rgba(255, 228, 211, 0.2); + --platform-hero-glow-a: rgba(255, 255, 255, 0.42); + --platform-hero-glow-b: rgba(228, 149, 91, 0.18); --platform-hero-overlay-strong: linear-gradient( 135deg, - rgba(255, 146, 170, 0.78), - rgba(255, 201, 171, 0.72) + rgba(238, 208, 183, 0.66), + rgba(204, 117, 76, 0.18) ); --platform-hero-overlay-soft: linear-gradient( 180deg, - rgba(255, 255, 255, 0.1), - rgba(255, 246, 249, 0.26) + rgba(255, 255, 255, 0.34), + rgba(248, 233, 219, 0.22) ); - --platform-surface-border: rgba(239, 221, 228, 0.9); - --platform-surface-hover-border: rgba(255, 154, 188, 0.58); - --platform-shell-glow-1: rgba(255, 255, 255, 0.2); - --platform-shell-glow-2: rgba(255, 220, 229, 0.18); - --platform-shell-glow-3: rgba(255, 221, 194, 0.14); - --platform-surface-glow-a: rgba(255, 213, 225, 0.14); - --platform-surface-glow-b: rgba(255, 224, 201, 0.12); - --platform-text-strong: #28151d; - --platform-text-base: #5c4650; - --platform-text-soft: #886f79; - --platform-brand-logo-title: #3b1a24; - --platform-brand-logo-subtitle: #d93570; - --platform-brand-logo-shadow: #8f5870; - --platform-line-soft: rgba(236, 214, 221, 0.72); + --platform-surface-border: rgba(226, 203, 184, 0.88); + --platform-surface-hover-border: rgba(204, 117, 76, 0.42); + --platform-shell-glow-1: rgba(255, 255, 255, 0.34); + --platform-shell-glow-2: rgba(238, 208, 183, 0.22); + --platform-shell-glow-3: rgba(210, 132, 93, 0.12); + --platform-surface-glow-a: rgba(240, 203, 169, 0.18); + --platform-surface-glow-b: rgba(204, 117, 76, 0.1); + --platform-text-strong: #3d1f10; + --platform-text-base: #6f5848; + --platform-text-soft: #988476; + --platform-text-muted: #a38f80; + --platform-brand-logo-title: #4a220f; + --platform-brand-logo-subtitle: #c7653d; + --platform-brand-logo-shadow: #caa48b; + --platform-line-soft: rgba(226, 203, 184, 0.72); --platform-subpanel-fill: linear-gradient( 180deg, - rgba(255, 255, 255, 0.94), - rgba(255, 250, 251, 0.9) + rgba(255, 254, 252, 0.94), + rgba(250, 243, 236, 0.9) ); - --platform-subpanel-border: rgba(233, 217, 223, 0.82); - --platform-warm-border: rgba(255, 140, 116, 0.28); - --platform-warm-bg: rgba(255, 140, 116, 0.14); - --platform-warm-text: #cf4f4e; - --platform-cool-border: rgba(255, 83, 142, 0.24); - --platform-cool-bg: rgba(255, 83, 142, 0.14); - --platform-cool-text: #d93570; - --platform-neutral-border: rgba(232, 191, 205, 0.44); - --platform-neutral-bg: rgba(255, 255, 255, 0.68); - --platform-neutral-text: #715662; - --platform-button-primary-border: rgba(255, 101, 147, 0.3); - --platform-button-primary-fill: linear-gradient(135deg, #ff4f8b, #ff8a73); - --platform-button-primary-text: #fff7fb; - --platform-button-secondary-fill: rgba(255, 255, 255, 0.72); - --platform-button-secondary-text: #4b3340; - --platform-button-ghost-fill: rgba(255, 255, 255, 0.52); - --platform-button-ghost-text: #6e5460; - --platform-button-danger-border: rgba(251, 113, 133, 0.22); - --platform-button-danger-fill: rgba(255, 228, 233, 0.94); - --platform-button-danger-text: #c2415d; - --platform-success-border: rgba(52, 211, 153, 0.24); - --platform-success-bg: rgba(236, 253, 245, 0.92); - --platform-success-text: #0f8a61; - --platform-icon-fill: rgba(255, 255, 255, 0.62); - --platform-icon-border: rgba(232, 191, 205, 0.46); - --platform-icon-text: #7a5d67; + --platform-subpanel-border: rgba(225, 204, 187, 0.82); + --platform-warm-border: rgba(204, 117, 76, 0.28); + --platform-warm-bg: rgba(234, 204, 179, 0.32); + --platform-warm-text: #b6623f; + --platform-cool-border: rgba(199, 117, 76, 0.24); + --platform-cool-bg: rgba(238, 208, 183, 0.26); + --platform-cool-text: #b76038; + --platform-neutral-border: rgba(226, 203, 184, 0.44); + --platform-neutral-bg: rgba(255, 253, 250, 0.7); + --platform-neutral-text: #7b6150; + --platform-button-primary-border: rgba(182, 98, 63, 0.32); + --platform-button-primary-solid: #c7653d; + --platform-button-primary-fill: linear-gradient(135deg, #df7f40, #b95d3a); + --platform-button-primary-text: #fffaf5; + --platform-button-secondary-fill: rgba(255, 253, 250, 0.78); + --platform-button-secondary-text: #4b2412; + --platform-button-ghost-fill: rgba(255, 253, 250, 0.56); + --platform-button-ghost-text: #755a49; + --platform-button-danger-border: rgba(185, 75, 58, 0.22); + --platform-button-danger-fill: rgba(255, 237, 229, 0.94); + --platform-button-danger-text: #a6402f; + --platform-success-border: rgba(73, 144, 96, 0.24); + --platform-success-bg: rgba(237, 248, 239, 0.92); + --platform-success-text: #2f7b46; + --platform-icon-fill: rgba(255, 253, 250, 0.68); + --platform-icon-border: rgba(226, 203, 184, 0.5); + --platform-icon-text: #7b5c49; --platform-nav-fill: linear-gradient( 180deg, - rgba(255, 255, 255, 0.96), - rgba(255, 249, 250, 0.92) + rgba(255, 254, 252, 0.96), + rgba(250, 243, 236, 0.92) ); --platform-nav-active-fill: linear-gradient( 180deg, - rgba(255, 91, 132, 0.16), - rgba(255, 151, 116, 0.16) + rgba(238, 208, 183, 0.48), + rgba(204, 117, 76, 0.16) ); - --platform-nav-active-border: rgba(255, 126, 154, 0.32); - --platform-nav-active-shadow: 0 10px 22px rgba(255, 91, 132, 0.12); + --platform-nav-active-border: rgba(204, 117, 76, 0.34); + --platform-nav-active-shadow: 0 10px 24px rgba(182, 98, 63, 0.12); --platform-modal-fill: linear-gradient( 180deg, - rgba(255, 255, 255, 0.96), - rgba(255, 245, 248, 0.95) + rgba(255, 254, 252, 0.97), + rgba(250, 243, 236, 0.95) ); - --platform-modal-border: rgba(255, 255, 255, 0.52); + --platform-modal-border: rgba(255, 255, 255, 0.58); --platform-desktop-shell-fill: linear-gradient( 180deg, rgba(255, 255, 255, 0.99), - rgba(255, 251, 252, 0.985) + rgba(253, 249, 245, 0.985) ); - --platform-desktop-shell-border: rgba(240, 228, 232, 0.94); - --platform-desktop-shell-inner-border: rgba(241, 230, 234, 0.92); + --platform-desktop-shell-border: rgba(230, 213, 199, 0.94); + --platform-desktop-shell-inner-border: rgba(233, 218, 205, 0.92); --platform-desktop-topbar-fill: linear-gradient( 180deg, rgba(255, 255, 255, 0.95), - rgba(255, 251, 252, 0.92) + rgba(253, 249, 245, 0.92) ); --platform-desktop-panel-fill: linear-gradient( 180deg, - rgba(255, 255, 255, 0.95), - rgba(255, 250, 251, 0.91) + rgba(255, 254, 252, 0.95), + rgba(250, 243, 236, 0.91) ); - --platform-desktop-panel-border: rgba(238, 223, 228, 0.88); - --platform-desktop-hover-shadow: 0 16px 28px rgba(222, 82, 124, 0.12); + --platform-desktop-panel-border: rgba(226, 203, 184, 0.88); + --platform-desktop-hover-shadow: 0 16px 30px rgba(112, 57, 30, 0.12); --platform-overlay-fill: linear-gradient( 180deg, - rgba(255, 184, 204, 0.14), - rgba(255, 255, 255, 0.56) + rgba(240, 203, 169, 0.14), + rgba(255, 255, 255, 0.58) ); - --platform-track-border: rgba(234, 193, 208, 0.46); - --platform-track-fill: rgba(255, 255, 255, 0.88); + --platform-track-border: rgba(226, 203, 184, 0.48); + --platform-track-fill: rgba(255, 253, 250, 0.9); --platform-page-fill: linear-gradient( 180deg, rgba(255, 255, 255, 0.9), - rgba(255, 250, 251, 0.8) + rgba(250, 243, 236, 0.8) ); - --platform-page-border: rgba(241, 230, 234, 0.88); - --platform-input-fill: rgba(255, 255, 255, 0.94); - --platform-input-fill-focus: rgba(255, 255, 255, 0.96); + --platform-page-border: rgba(233, 218, 205, 0.88); + --platform-input-fill: rgba(255, 253, 250, 0.94); + --platform-input-fill-focus: rgba(255, 254, 252, 0.96); --platform-input-highlight: rgba(255, 255, 255, 0.9); - --platform-input-focus-ring: rgba(255, 91, 132, 0.14); - --platform-nav-item-text: #7c6770; - --platform-nav-item-text-active: #2d1820; - --platform-nav-item-hover-fill: rgba(255, 244, 247, 0.92); - --platform-nav-item-icon-fill: rgba(248, 244, 246, 1); - --platform-nav-item-icon-text: #7a5d67; - --platform-nav-item-icon-active-fill: rgba(255, 255, 255, 0.98); - --platform-nav-item-icon-active-text: #d93570; - --platform-nav-icon-active-shadow: 0 12px 24px rgba(255, 91, 132, 0.16); + --platform-input-focus-ring: rgba(204, 117, 76, 0.15); + --platform-nav-item-text: #80695a; + --platform-nav-item-text-active: #3d1f10; + --platform-nav-item-hover-fill: rgba(253, 248, 243, 0.94); + --platform-nav-item-icon-fill: rgba(250, 243, 236, 1); + --platform-nav-item-icon-text: #7b5c49; + --platform-nav-item-icon-active-fill: rgba(255, 254, 252, 0.98); + --platform-nav-item-icon-active-text: #c7653d; + --platform-nav-icon-active-shadow: 0 12px 24px rgba(182, 98, 63, 0.16); --platform-profile-hero-fill: linear-gradient( 180deg, - rgba(255, 255, 255, 0.96), - rgba(255, 245, 248, 0.9) + rgba(255, 254, 252, 0.96), + rgba(250, 239, 229, 0.9) ); - --platform-profile-hero-border: rgba(255, 255, 255, 0.52); - --platform-profile-hero-shadow: 0 20px 56px rgba(216, 74, 124, 0.18); + --platform-profile-hero-border: rgba(255, 255, 255, 0.56); + --platform-profile-hero-shadow: 0 20px 56px rgba(112, 57, 30, 0.16); --platform-profile-avatar-fill: linear-gradient( 135deg, - rgba(255, 79, 139, 0.96), - rgba(255, 140, 110, 0.9) + rgba(223, 127, 64, 0.96), + rgba(181, 91, 56, 0.92) ); - --platform-profile-avatar-shadow: 0 14px 30px rgba(255, 79, 139, 0.24); - --platform-profile-chip-fill: rgba(255, 255, 255, 0.88); - --platform-profile-chip-hover-fill: rgba(255, 255, 255, 0.96); - --platform-profile-chip-text: #6a505b; - --platform-profile-action-fill: linear-gradient(135deg, #ff4f8b, #ff8a73); - --platform-profile-action-text: #fff7fb; - --platform-profile-action-shadow: 0 14px 30px rgba(255, 79, 139, 0.24); + --platform-profile-avatar-shadow: 0 14px 30px rgba(182, 98, 63, 0.22); + --platform-profile-chip-fill: rgba(255, 253, 250, 0.88); + --platform-profile-chip-hover-fill: rgba(255, 254, 252, 0.96); + --platform-profile-chip-text: #755a49; + --platform-profile-action-fill: linear-gradient(135deg, #df7f40, #b95d3a); + --platform-profile-action-solid: #c7653d; + --platform-profile-action-text: #fffaf5; + --platform-profile-action-shadow: 0 14px 30px rgba(182, 98, 63, 0.22); --platform-card-overlay-soft: linear-gradient( 180deg, rgba(255, 255, 255, 0.08), - rgba(255, 247, 249, 0.82) + rgba(250, 243, 236, 0.82) ); --platform-card-overlay-strong: linear-gradient( 180deg, rgba(255, 255, 255, 0.16), - rgba(255, 243, 247, 0.92) + rgba(248, 239, 231, 0.92) ); --platform-card-overlay-deep: radial-gradient( circle at top left, rgba(255, 255, 255, 0.2), transparent 30% ), - radial-gradient(circle at right, rgba(255, 205, 178, 0.14), transparent 28%), - linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 241, 246, 0.9)); + radial-gradient(circle at right, rgba(228, 149, 91, 0.14), transparent 28%), + linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(248, 239, 231, 0.9)); --platform-recommend-runtime-fill: var(--platform-panel-fill); - --platform-recommend-runtime-border: rgba(232, 191, 205, 0.42); - --platform-recommend-runtime-shadow: 0 18px 44px rgba(215, 87, 134, 0.13), + --platform-recommend-runtime-border: rgba(226, 203, 184, 0.42); + --platform-recommend-runtime-shadow: 0 18px 44px rgba(112, 57, 30, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.58); --platform-recommend-runtime-state-fill: radial-gradient( circle at 50% 18%, - rgba(255, 91, 132, 0.12), + rgba(204, 117, 76, 0.12), transparent 34% ), linear-gradient( 180deg, rgba(255, 255, 255, 0.98), - rgba(255, 246, 249, 0.94) + rgba(250, 243, 236, 0.94) ); --platform-recommend-runtime-state-text: var(--platform-text-strong); --puzzle-runtime-shell-fill: var(--platform-body-fill); --puzzle-runtime-stage-fill: radial-gradient( circle at 50% 18%, - rgba(255, 91, 132, 0.13), + rgba(204, 117, 76, 0.13), transparent 30% ), radial-gradient( circle at 18% 82%, - rgba(255, 138, 115, 0.13), + rgba(240, 203, 169, 0.18), transparent 28% ), - linear-gradient(180deg, #fffefe 0%, #fff7fa 58%, #fff1f5 100%); - --puzzle-runtime-grid-line: rgba(130, 75, 95, 0.06); + linear-gradient(180deg, #fffdf9 0%, #fbf5ed 58%, #f4e5d7 100%); + --puzzle-runtime-grid-line: rgba(112, 57, 30, 0.06); --puzzle-runtime-text-strong: var(--platform-text-strong); --puzzle-runtime-text-base: var(--platform-text-base); --puzzle-runtime-text-soft: var(--platform-text-soft); - --puzzle-runtime-surface-fill: rgba(255, 255, 255, 0.76); - --puzzle-runtime-surface-fill-strong: rgba(255, 255, 255, 0.9); - --puzzle-runtime-surface-border: rgba(232, 191, 205, 0.48); - --puzzle-runtime-board-fill: rgba(255, 255, 255, 0.68); - --puzzle-runtime-board-border: rgba(255, 126, 154, 0.28); - --puzzle-runtime-board-shadow: 0 30px 80px rgba(215, 87, 134, 0.14); - --puzzle-runtime-piece-fill: rgba(255, 255, 255, 0.74); + --puzzle-runtime-surface-fill: rgba(255, 253, 250, 0.76); + --puzzle-runtime-surface-fill-strong: rgba(255, 253, 250, 0.9); + --puzzle-runtime-surface-border: rgba(226, 203, 184, 0.48); + --puzzle-runtime-board-fill: rgba(255, 253, 250, 0.68); + --puzzle-runtime-board-border: rgba(204, 117, 76, 0.28); + --puzzle-runtime-board-shadow: 0 30px 80px rgba(112, 57, 30, 0.14); + --puzzle-runtime-piece-fill: rgba(255, 253, 250, 0.74); --puzzle-runtime-piece-border: transparent; - --puzzle-runtime-piece-empty-fill: rgba(255, 228, 236, 0.34); - --puzzle-runtime-piece-empty-text: rgba(92, 70, 80, 0.38); + --puzzle-runtime-piece-empty-fill: rgba(238, 208, 183, 0.34); + --puzzle-runtime-piece-empty-text: rgba(111, 88, 72, 0.4); --puzzle-runtime-piece-selected-fill: linear-gradient( 135deg, - #ff4f8b, - #ff8a73 + #df7f40, + #b95d3a ); - --puzzle-runtime-piece-selected-text: #fff7fb; + --puzzle-runtime-piece-selected-text: #fffaf5; --puzzle-runtime-piece-selected-border: transparent; - --puzzle-runtime-next-card-overlay: rgba(61, 24, 38, 0.06); - --puzzle-runtime-control-fill: rgba(255, 255, 255, 0.72); - --puzzle-runtime-control-hover-fill: rgba(255, 91, 132, 0.1); + --puzzle-runtime-next-card-overlay: rgba(74, 34, 15, 0.06); + --puzzle-runtime-control-fill: rgba(255, 253, 250, 0.72); + --puzzle-runtime-control-hover-fill: rgba(204, 117, 76, 0.1); --puzzle-runtime-primary-fill: var(--platform-button-primary-fill); --puzzle-runtime-primary-text: var(--platform-button-primary-text); --puzzle-runtime-primary-shadow: var(--platform-profile-action-shadow); --puzzle-runtime-accent-text: var(--platform-cool-text); - --puzzle-runtime-cool-text: #0f8fa9; - --puzzle-runtime-danger-fill: rgba(255, 228, 233, 0.9); - --puzzle-runtime-danger-text: #c2415d; - --puzzle-runtime-backdrop-fill: rgba(43, 20, 32, 0.34); + --puzzle-runtime-cool-text: #6e8d42; + --puzzle-runtime-danger-fill: rgba(255, 237, 229, 0.9); + --puzzle-runtime-danger-text: #a6402f; + --puzzle-runtime-backdrop-fill: rgba(61, 31, 16, 0.34); --puzzle-runtime-dialog-fill: radial-gradient( circle at 12% 0%, - rgba(255, 91, 132, 0.18), + rgba(204, 117, 76, 0.18), transparent 36% ), linear-gradient( 180deg, - rgba(255, 255, 255, 0.98), - rgba(255, 246, 249, 0.95) + rgba(255, 254, 252, 0.98), + rgba(250, 243, 236, 0.95) ); - --puzzle-runtime-dialog-border: rgba(255, 126, 154, 0.3); - --puzzle-runtime-table-fill: rgba(255, 255, 255, 0.62); - --puzzle-runtime-table-row-fill: rgba(255, 91, 132, 0.12); - --puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.66); - --puzzle-runtime-next-card-hover-fill: rgba(255, 91, 132, 0.1); + --puzzle-runtime-dialog-border: rgba(204, 117, 76, 0.3); + --puzzle-runtime-table-fill: rgba(255, 253, 250, 0.62); + --puzzle-runtime-table-row-fill: rgba(204, 117, 76, 0.12); + --puzzle-runtime-next-card-fill: rgba(255, 253, 250, 0.66); + --puzzle-runtime-next-card-hover-fill: rgba(204, 117, 76, 0.1); } .platform-theme--dark { color-scheme: dark; + --platform-accent: #5b6cff; --platform-body-fill: radial-gradient( circle at top, rgba(129, 140, 248, 0.2), @@ -797,6 +802,7 @@ body { --platform-text-strong: #ffffff; --platform-text-base: rgb(228 228 231); --platform-text-soft: rgb(161 161 170); + --platform-text-muted: rgb(161 161 170); --platform-brand-logo-title: #fff7dc; --platform-brand-logo-subtitle: #9fe7ff; --platform-brand-logo-shadow: #040814; @@ -813,6 +819,7 @@ body { --platform-neutral-bg: rgba(255, 255, 255, 0.05); --platform-neutral-text: rgb(228 228 231); --platform-button-primary-border: rgba(129, 140, 248, 0.3); + --platform-button-primary-solid: #5b6cff; --platform-button-primary-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); --platform-button-primary-text: rgb(238 248 255); --platform-button-secondary-fill: rgba(255, 255, 255, 0.05); @@ -907,6 +914,7 @@ body { --platform-profile-chip-hover-fill: rgba(255, 255, 255, 0.14); --platform-profile-chip-text: rgb(228 228 231); --platform-profile-action-fill: linear-gradient(135deg, #5b6cff, #3dd9ff); + --platform-profile-action-solid: #5b6cff; --platform-profile-action-text: rgb(238 248 255); --platform-profile-action-shadow: 0 14px 32px rgba(91, 108, 255, 0.22); --platform-card-overlay-soft: linear-gradient( @@ -1748,9 +1756,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .platform-pill--rose { - border-color: rgba(251, 113, 133, 0.2); - background: rgba(244, 63, 94, 0.1); - color: rgb(255 228 230); + border-color: var(--platform-button-danger-border); + background: var(--platform-button-danger-fill); + color: var(--platform-button-danger-text); } .platform-pill--success { @@ -1872,7 +1880,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .creation-work-card__swipe-button--danger { - background: linear-gradient(180deg, #fb7185, #e11d48); + background: linear-gradient(180deg, #c7653d, #8f3f27); } .creation-work-card__swipe-button:disabled { @@ -2087,7 +2095,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { width: 0.6rem; height: 0.6rem; border-radius: 9999px; - background: #ef4444; + background: #b64a35; box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.26), 0 0 12px rgba(239, 68, 68, 0.68); @@ -2129,7 +2137,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .creation-work-card-stat--like { - --creation-work-stat-accent: #ff6b6b; + --creation-work-stat-accent: #b64a35; } .creation-work-card-stat__label { @@ -2177,7 +2185,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { display: inline-flex; align-items: center; gap: 0.08rem; - color: #ef233c; + color: #b64a35; font-size: 0.54rem; font-weight: 900; line-height: 1; @@ -2334,7 +2342,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border-color: var(--platform-button-primary-border); background: var(--platform-button-primary-fill); color: var(--platform-button-primary-text); - box-shadow: 0 16px 34px rgba(255, 91, 132, 0.18); + box-shadow: 0 16px 34px rgba(182, 98, 63, 0.18); } .platform-button--secondary { @@ -2409,7 +2417,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border-color: var(--platform-button-primary-border); background: var(--platform-button-primary-fill); color: var(--platform-button-primary-text); - box-shadow: 0 12px 26px rgba(255, 91, 132, 0.16); + box-shadow: 0 12px 26px rgba(182, 98, 63, 0.16); } .platform-close-confirm-dialog__button--secondary { @@ -2567,7 +2575,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { width: 0.48rem; height: 0.48rem; border-radius: 9999px; - background: #ef4444; + background: #b64a35; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.28), 0 0 12px rgba(239, 68, 68, 0.72); @@ -5440,9 +5448,9 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .platform-banner--danger { - border-color: rgba(251, 113, 133, 0.2); - background: rgba(244, 63, 94, 0.1); - color: #c2415d; + border-color: var(--platform-button-danger-border); + background: var(--platform-button-danger-fill); + color: #a6402f; } .platform-progress-track { @@ -5492,7 +5500,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { rgba(255, 138, 115, 0.22), transparent 34% ), - linear-gradient(135deg, #fff9fb 0%, #ffe8f0 48%, #ffdacf 100%); + linear-gradient(135deg, #fffdf9 0%, #f4e5d7 48%, #eaccb3 100%); } .platform-theme--light @@ -6060,14 +6068,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border-color: var(--platform-surface-border) !important; background: radial-gradient( circle at top left, - rgba(255, 204, 219, 0.34), + rgba(240, 203, 169, 0.34), transparent 34% ), - linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(255, 248, 250, 0.9)) !important; + linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(250, 243, 236, 0.9)) !important; color: var(--platform-text-strong) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84), - 0 18px 42px rgba(222, 82, 124, 0.1); + 0 18px 42px rgba(112, 57, 30, 0.1); } .platform-theme--light @@ -6106,18 +6114,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border-color: var(--platform-button-primary-border) !important; background: var(--platform-button-primary-fill) !important; color: var(--platform-button-primary-text) !important; - box-shadow: 0 12px 26px rgba(255, 91, 132, 0.16); + box-shadow: 0 12px 26px rgba(182, 98, 63, 0.16); } .platform-theme--light .map-modal-overlay { - background: rgba(73, 45, 56, 0.24) !important; + background: rgba(74, 34, 15, 0.24) !important; } .platform-theme--light .map-modal-shell { background: var(--platform-modal-fill); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78), - 0 24px 70px rgba(131, 77, 98, 0.2) !important; + 0 24px 70px rgba(112, 57, 30, 0.2) !important; } .platform-theme--light .map-modal-backdrop { @@ -6129,7 +6137,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { background: linear-gradient( 180deg, rgba(255, 255, 255, 0.7), - rgba(255, 247, 250, 0.88) + rgba(250, 243, 236, 0.88) ) !important; } @@ -6146,7 +6154,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { } .platform-theme--light .map-modal-shell svg line { - stroke: rgba(217, 53, 112, 0.32); + stroke: rgba(199, 101, 61, 0.32); } button { @@ -7045,23 +7053,23 @@ button { border: 1px solid color-mix( in srgb, - var(--platform-work-like-accent, #ff6b6b) 24%, + var(--platform-work-like-accent, #c7653d) 24%, transparent ); border-radius: 1rem; background: color-mix( in srgb, - var(--platform-work-like-accent, #ff6b6b) 10%, + var(--platform-work-like-accent, #c7653d) 10%, var(--platform-panel-fill) 90% ); - color: var(--platform-work-like-accent, #ff6b6b); + color: var(--platform-work-like-accent, #c7653d); padding: 0.6rem 0.75rem; font-size: 0.8125rem; font-weight: 900; box-shadow: 0 0.55rem 1.2rem color-mix( in srgb, - var(--platform-work-like-accent, #ff6b6b) 10%, + var(--platform-work-like-accent, #c7653d) 10%, transparent ); } @@ -7080,7 +7088,7 @@ button { } .platform-work-detail__stat { - --platform-work-stat-accent: #ff4f8b; + --platform-work-stat-accent: #c7653d; position: relative; min-width: 0; overflow: hidden; @@ -7103,7 +7111,7 @@ button { } .platform-work-detail__stat--like { - --platform-work-stat-accent: #ff6b6b; + --platform-work-stat-accent: #b64a35; } .platform-work-detail__stat--time { diff --git a/src/services/puzzle-works/puzzleAssetClient.ts b/src/services/puzzle-works/puzzleAssetClient.ts index 4b5d0e9a..30c90ddf 100644 --- a/src/services/puzzle-works/puzzleAssetClient.ts +++ b/src/services/puzzle-works/puzzleAssetClient.ts @@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = { updatedAt: string; }; +export type PuzzleReferenceAsset = PuzzleHistoryAsset & { + objectKey: string; +}; + +type DirectUploadTicketResponse = { + upload: { + bucket: string; + host: string; + objectKey: string; + legacyPublicPath: string; + formFields: Record; + }; +}; + +type ConfirmAssetObjectResponse = { + assetObject: { + assetObjectId: string; + objectKey: string; + assetKind: 'puzzle_cover_image'; + ownerUserId?: string | null; + profileId?: string | null; + entityId?: string | null; + createdAt?: string; + updatedAt?: string; + }; +}; + +const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024; + +const MIME_BY_EXTENSION: Record = { + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', +}; + +function resolvePuzzleImageContentType(file: File) { + if (file.type.trim()) { + return file.type.trim(); + } + + const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? ''; + return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream'; +} + +function validatePuzzleReferenceImageFile(file: File) { + const contentType = resolvePuzzleImageContentType(file); + if (file.size <= 0) { + throw new Error('参考图文件为空,请重新选择。'); + } + if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) { + throw new Error('参考图过大,请压缩后再上传。'); + } + if (!contentType.startsWith('image/')) { + throw new Error('参考图必须是图片文件。'); + } +} + +async function postDirectUploadFile( + upload: DirectUploadTicketResponse['upload'], + file: File, +) { + const formData = new FormData(); + Object.entries(upload.formFields).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + formData.append(key, value); + } + }); + formData.append('file', file); + + const response = await fetch(upload.host, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('上传拼图参考图失败。'); + } +} + +export async function uploadPuzzleReferenceImage(payload: { + file: File; +}): Promise { + validatePuzzleReferenceImageFile(payload.file); + const contentType = resolvePuzzleImageContentType(payload.file); + const uploadedAt = Date.now(); + const ticket = await requestJson( + '/api/assets/direct-upload-tickets', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + legacyPrefix: 'generated-puzzle-assets', + pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`], + fileName: payload.file.name, + contentType, + access: 'private', + maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + metadata: { + asset_kind: 'puzzle_cover_image', + puzzle_slot: 'reference_image', + }, + }), + }, + '创建拼图参考图上传凭证失败', + ); + + await postDirectUploadFile(ticket.upload, payload.file); + + const confirmed = await requestJson( + '/api/assets/objects/confirm', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + bucket: ticket.upload.bucket, + objectKey: ticket.upload.objectKey, + contentType, + contentLength: payload.file.size, + assetKind: 'puzzle_cover_image', + accessPolicy: 'private', + }), + }, + '确认拼图参考图失败', + ); + + return { + assetObjectId: confirmed.assetObject.assetObjectId, + assetKind: confirmed.assetObject.assetKind, + objectKey: confirmed.assetObject.objectKey, + imageSrc: ticket.upload.legacyPublicPath, + ownerUserId: confirmed.assetObject.ownerUserId, + ownerLabel: confirmed.assetObject.ownerUserId + ? `账号 ${confirmed.assetObject.ownerUserId}` + : '当前账号', + profileId: confirmed.assetObject.profileId, + entityId: confirmed.assetObject.entityId, + createdAt: confirmed.assetObject.createdAt ?? '', + updatedAt: confirmed.assetObject.updatedAt ?? '', + }; +} + +export const puzzleReferenceAssetTestUtils = { + maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES, + validateFile: validatePuzzleReferenceImageFile, +}; + /** * 读取历史拼图图片素材。结果页只把它们作为参考图来源, * 不直接替换当前正式图,正式图仍由后端单图生成链路写回。 @@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) { export const puzzleAssetClient = { listHistoryAssets: listPuzzleHistoryAssets, + uploadReferenceImage: uploadPuzzleReferenceImage, }; diff --git a/src/services/puzzleReferenceImage.ts b/src/services/puzzleReferenceImage.ts index 4cb66ca4..1eac5862 100644 --- a/src/services/puzzleReferenceImage.ts +++ b/src/services/puzzleReferenceImage.ts @@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({ ), ); } + +export function puzzleReferenceImageDataUrlToFile( + dataUrl: string, + fileName = 'puzzle-reference.jpg', +) { + const [metadata = '', encoded = ''] = dataUrl.split(',', 2); + const mimeType = + metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg'; + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], fileName, { type: mimeType }); +}