master #25
@@ -1 +0,0 @@
|
|||||||
C:/proj/Genarrative/.hermes/skills/behavior-driven-development
|
|
||||||
1
.codex/skills/behavior-driven-development
Symbolic link
1
.codex/skills/behavior-driven-development
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.hermes/skills/behavior-driven-development/
|
||||||
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
.git
|
||||||
|
.codex-temp
|
||||||
|
.codex-logs
|
||||||
|
.codex-runlogs
|
||||||
|
.idea
|
||||||
|
.vite
|
||||||
|
node_modules
|
||||||
|
target
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
logs
|
||||||
|
tmp
|
||||||
|
*.log
|
||||||
|
/*.png
|
||||||
|
/*.jpg
|
||||||
|
/*.jpeg
|
||||||
|
/*.webp
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.secrets.local
|
||||||
|
.env.secrets.*
|
||||||
|
spacetime.local.json
|
||||||
|
deploy/container/api-server.env
|
||||||
|
|
||||||
|
server-rs/target
|
||||||
|
server-rs/target-*
|
||||||
|
server-rs/.data
|
||||||
|
server-rs/.spacetimedb
|
||||||
|
|
||||||
|
public/generated-*
|
||||||
|
|
||||||
|
scripts/loadtest/data/*.local.json
|
||||||
|
scripts/loadtest/data/k6-*.log
|
||||||
|
scripts/loadtest/data/k6-*summary*.md
|
||||||
|
scripts/loadtest/data/latest-*-prefix.txt
|
||||||
23
.env.local
23
.env.local
@@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d"
|
|||||||
|
|
||||||
SMS_AUTH_ENABLED="true"
|
SMS_AUTH_ENABLED="true"
|
||||||
SMS_AUTH_PROVIDER="aliyun"
|
SMS_AUTH_PROVIDER="aliyun"
|
||||||
ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9"
|
ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com"
|
||||||
ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD"
|
ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技"
|
||||||
ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com"
|
ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486"
|
||||||
ALIYUN_SMS_SIGN_NAME="速通互联验证码"
|
|
||||||
ALIYUN_SMS_TEMPLATE_CODE="100001"
|
|
||||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code"
|
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code"
|
||||||
ALIYUN_SMS_COUNTRY_CODE="86"
|
|
||||||
ALIYUN_SMS_SCHEME_NAME=""
|
|
||||||
ALIYUN_SMS_CODE_LENGTH="6"
|
|
||||||
ALIYUN_SMS_CODE_TYPE="1"
|
|
||||||
ALIYUN_SMS_VALID_TIME_SECONDS="300"
|
|
||||||
ALIYUN_SMS_INTERVAL_SECONDS="60"
|
|
||||||
ALIYUN_SMS_DUPLICATE_POLICY="1"
|
|
||||||
ALIYUN_SMS_CASE_AUTH_POLICY="1"
|
|
||||||
ALIYUN_SMS_RETURN_VERIFY_CODE="false"
|
|
||||||
|
|
||||||
VITE_AUTH_ALLOW_DEV_GUEST="false"
|
VITE_AUTH_ALLOW_DEV_GUEST="false"
|
||||||
|
|
||||||
@@ -70,3 +59,9 @@ GENARRATIVE_SPACETIME_TOKEN=""
|
|||||||
GENARRATIVE_ADMIN_USERNAME=admin
|
GENARRATIVE_ADMIN_USERNAME=admin
|
||||||
GENARRATIVE_ADMIN_PASSWORD=123456
|
GENARRATIVE_ADMIN_PASSWORD=123456
|
||||||
ADMIN_API_TARGET=http://127.0.0.1:3100
|
ADMIN_API_TARGET=http://127.0.0.1:3100
|
||||||
|
|
||||||
|
# OTLP
|
||||||
|
GENARRATIVE_OTEL_ENABLED=true
|
||||||
|
OTEL_SERVICE_NAME=genarrative-api
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,service.namespace=genarrative
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ temp*build*/
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
.env.secrets.local
|
.env.secrets.local
|
||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
|
deploy/container/api-server.env
|
||||||
|
|
||||||
# Local load-test data extracted from private migration files
|
# Local load-test data extracted from private migration files
|
||||||
scripts/loadtest/data/*.local.json
|
scripts/loadtest/data/*.local.json
|
||||||
|
|||||||
@@ -16,6 +16,71 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
|
||||||
|
|
||||||
|
- 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。
|
||||||
|
- 决策:手写 Rust 模块统一使用同名入口文件,例如 `puzzle.rs`、`match3d.rs`、`gameplay.rs`,子模块继续放在同名目录下;不要再为手写模块新增 `mod.rs`。SpacetimeDB CLI 生成的 bindings 也由生成脚本同步为 `module_bindings.rs` 加 `module_bindings/` 子目录,避免仓库里继续出现 `mod.rs`。
|
||||||
|
- 边界:本决策只规范文件布局,不改变 module path、HTTP route、DTO、SpacetimeDB schema、生成绑定内容或运行时行为。
|
||||||
|
- 影响范围:`server-rs/crates/api-server/src/`、`server-rs/crates/spacetime-module/src/`、`server-rs/crates/spacetime-client/src/module_bindings.rs`、`scripts/generate-spacetime-bindings.mjs`。
|
||||||
|
- 验证方式:执行 `Get-ChildItem server-rs -Recurse -Filter mod.rs` 应无结果;再执行对应 `cargo check` / 定向测试 / 编码检查。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-05-18 大文件拆分继续按聚合入口加领域子模块推进
|
||||||
|
|
||||||
|
- 背景:完成拼图 `api-server` 拆分后,`match3d.rs`、`spacetime-client/src/mapper.rs` 与 `PlatformEntryFlowShellImpl.tsx` 仍是后续迭代和评审的高噪音大文件。
|
||||||
|
- 决策:抓大鹅 Match3D 的 `api-server` 单文件改为同名入口 `src/match3d.rs` 加 `src/match3d/` 子模块目录,`handlers.rs`、`draft.rs`、`works.rs`、`item_assets.rs`、`runtime.rs`、`vector_engine_gemini.rs`、`mappers.rs`、`tags.rs`、`tests.rs` 分担原实现;`spacetime-client/src/mapper.rs` 改为聚合入口,具体 mapper 按领域落到 `src/mapper/*.rs`;平台入口继续以 `PlatformEntryFlowShellImpl.tsx` 为编排壳,独立 UI 片段优先拆到 `PlatformEntryFlowShellImpl/` 子目录,本次已抽出 `PuzzleOnboardingView.tsx`。
|
||||||
|
- 边界:这些拆分只改变文件组织,不改变 HTTP route、DTO、error envelope、SpacetimeDB schema、生成绑定、procedure result、入口配置事实源、前端行为、VectorEngine / OSS 副作用或计费语义。后续要下沉领域规则时另行讨论并更新设计。
|
||||||
|
- 影响范围:`server-rs/crates/api-server/src/match3d/`、`server-rs/crates/spacetime-client/src/mapper/`、`src/components/platform-entry/PlatformEntryFlowShellImpl/`、后端架构文档和玩法链路文档。
|
||||||
|
- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml --no-run`、`cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml`、前端 typecheck 或定向 tsc、`git diff --check` 与 `npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-05-18 api-server 拼图能力按 HTTP/BFF 子模块拆分
|
||||||
|
|
||||||
|
- 背景:`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 单测。
|
||||||
|
- 边界:本次只改变 `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 引用、后端架构文档。
|
||||||
|
- 验证方式:执行 `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`。
|
||||||
|
|
||||||
|
## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动
|
||||||
|
|
||||||
|
- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。
|
||||||
|
- 决策:Windows Jenkins 上凡是需要执行 PowerShell 逻辑的流水线,优先通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...`,不要再依赖 Jenkins `powershell` step 的隐式启动器。
|
||||||
|
- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换,避免在 Windows workspace 里二次清理触发权限拒绝。
|
||||||
|
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。
|
||||||
|
- 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。
|
||||||
|
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||||
|
|
||||||
|
## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径
|
||||||
|
|
||||||
|
- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
|
||||||
|
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
|
||||||
|
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
|
||||||
|
- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。
|
||||||
|
- 验证方式:执行 `npm run container:config` 展开 compose 配置;需要真实运行时再执行 `npm run container:build`、`npm run container:up`、`npm run container:k6`,并结合容器 Nginx log 与 OTLP debug exporter 判断瓶颈。
|
||||||
|
- 关联文档:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-05-16 公开作品列表短期由 BFF 订阅读模型缓存
|
||||||
|
|
||||||
|
- 背景:作品列表压测和实时性讨论中,曾考虑让浏览器前端直接订阅公开作品列表,减少 HTTP 拉取和 BFF 压力。
|
||||||
|
- 决策:本轮不直接把作品列表整体交给前端订阅。短期继续由 `api-server` / BFF 通过 `spacetime-client` 长期订阅 SpacetimeDB 公开 read model 并读取本地 cache,维持首屏、排序、字段归一、权限降级和 HTTP fallback。中期可以新增或统一稳定的专用公开作品列表 read model,例如 `public_work_gallery_entry`,作为前端可选直连订阅对象。
|
||||||
|
- 边界:未来前端直订阅只允许面向稳定、低基数、公开的专用 read model。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得在前端自行 join、聚合或执行公开权限逻辑;这些逻辑必须先沉到后端投影 / read model。
|
||||||
|
- 后续准入:若要落地前端直订阅,必须先完成并验收权限边界、字段契约、排序 / 分页、埋点和 BFF 回退策略;缺任一项时继续走 `api-server` / BFF 订阅缓存方案。
|
||||||
|
- 影响范围:发现页、推荐流、各玩法公开广场、`api-server` 公开列表缓存、SpacetimeDB public view / public 读模型设计。
|
||||||
|
- 验证方式:新增公开作品列表订阅能力时,检查前端只消费专用 public read model 或 BFF HTTP DTO;检查源表 row shape、权限判断和跨玩法聚合没有下沉到前端页面。
|
||||||
|
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-05-16 api-server OpenTelemetry 统一补齐 traces metrics logs
|
||||||
|
|
||||||
|
- 背景:压测与运行观测需要把 HTTP、SpacetimeDB 调用和应用日志串起来,同时保留本地 `journalctl` / 文件日志做故障排障。
|
||||||
|
- 决策:`api-server` 通过 OTLP HTTP base endpoint 发送 traces、metrics 和 logs;Collector 统一用 `otelcol-contrib`,`npm run otel:debug` 负责 debug 采集,`npm run otel:rider` 负责转发到 Rider;Rider 只是接收与可视化端,不直接替代 Collector。
|
||||||
|
- 日志口径:Rider Logs 面板只展示 log event 自身字段,请求完成日志需要直接携带 `request_id`、HTTP method、规范化 route、scheme、path、status、status_class、latency 和 slow_request;更完整的 request attributes 仍以 trace/span 为准。
|
||||||
|
- 影响范围:`server-rs/crates/shared-logging`、`server-rs/crates/api-server`、`scripts/run-otelcol.mjs`、压测与运维文档。
|
||||||
|
- 验证方式:`cargo test -p shared-logging --manifest-path server-rs/Cargo.toml generic_otlp_http_endpoint_expands_to_signal_paths`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml observability_route_keeps_metrics_labels_low_cardinality`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml resolve_request_scheme_uses_forwarded_proto_first_value`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。
|
||||||
|
|
||||||
## 2026-05-14 创作页图像输入统一封装为图像组件
|
## 2026-05-14 创作页图像输入统一封装为图像组件
|
||||||
|
|
||||||
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
|
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
|
||||||
@@ -133,7 +198,8 @@
|
|||||||
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
|
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
|
||||||
|
|
||||||
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
|
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
|
||||||
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
|
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
|
||||||
|
- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。
|
||||||
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。
|
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。
|
||||||
- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。
|
- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。
|
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。
|
||||||
@@ -172,7 +238,7 @@
|
|||||||
## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包
|
## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包
|
||||||
|
|
||||||
- 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图,运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。
|
- 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图,运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。
|
||||||
- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。视觉包包含 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。
|
- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`。为降低调用成本,新链路只生成一张 `1024x1024` 的 `2x2` 素材 sheet 和一张 `1536x1024` 场景背景图;`2x2` sheet 固定左上物品 A、右上物品 B、左下篮子、右下礼物盒,服务端按格切图并把物品、篮子和礼物盒转透明 PNG。视觉包必需资源为 `background`、`gift-box`、`basket`;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配。左右手位置指示器是运行态默认静态素材,使用项目内置第一人称半抓握手,不再随每次创作生成。运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,中央物品 UI 与篮子物品图标使用固定正方形槽位并等比 `contain` 缩放,礼物盒打开烟雾特效由 CSS 兜底;历史草稿中的 `ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 仅兼容读取或忽略。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。
|
||||||
- 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、宝贝识物 PRD 与技术方案。
|
- 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、宝贝识物 PRD 与技术方案。
|
||||||
- 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。
|
- 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`。
|
||||||
- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
@@ -197,7 +263,7 @@
|
|||||||
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
|
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
|
||||||
|
|
||||||
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。
|
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。
|
||||||
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
|
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v3.png`、`picture-book-character-outline-v4.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png` 和 `picture-book-ui-button-v2.png`。其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
|
||||||
- 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
|
- 影响范围:`src/index.css`、`src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
|
||||||
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only <asset-id>` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。
|
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live` 或 `--live --only <asset-id>` 应能写出对应 PNG,并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。
|
||||||
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。
|
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`、`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`。
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ npm run check:server-rs-ddd
|
|||||||
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
|
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
|
||||||
- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`
|
- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`
|
||||||
|
|
||||||
|
## 生产压测与观测默认口径
|
||||||
|
|
||||||
|
- 作品列表 50 HTTP req/s 压测使用 `scripts/loadtest/README.md` 中的 K6 命令;当前脚本一次 iteration 请求两个公开列表接口,因此目标 50 HTTP req/s 对应 `PEAK_RPS=25`。
|
||||||
|
- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、systemd 限制、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
|
||||||
|
- OpenTelemetry 现阶段可选发送 traces / metrics / logs,但不会取代本地 `journalctl -u genarrative-api.service`、`logs/api-server/` 与 `/var/log/nginx/genarrative.*.log`。
|
||||||
|
- 指标 label 不写 raw URI、userId、profileId 或 request_id;request_id 只用于 trace/log 串联。
|
||||||
|
|
||||||
## 前端相关默认验证
|
## 前端相关默认验证
|
||||||
|
|
||||||
前端修改后,应根据修改范围选择:
|
前端修改后,应根据修改范围选择:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
- 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。
|
- 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。
|
||||||
- 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets`、`clearCount` 和 `difficulty`。
|
- 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets`、`clearCount` 和 `difficulty`。
|
||||||
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。
|
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。
|
||||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||||
|
|
||||||
## OSS V4 签名时间和 bucket/object_key 兼容
|
## OSS V4 签名时间和 bucket/object_key 兼容
|
||||||
|
|
||||||
@@ -83,6 +83,54 @@
|
|||||||
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
|
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
|
||||||
- 关联:`AGENTS.md`、`npm run check:encoding`。
|
- 关联:`AGENTS.md`、`npm run check:encoding`。
|
||||||
|
|
||||||
|
## SpacetimeDB 运行态查询不要绕过已有索引或用 procedure JSON 回传
|
||||||
|
|
||||||
|
- 现象:运行态接口看起来只查当前用户、作品或任务,却在 `spacetime-module` 中使用 `ctx.db.<table>().iter().filter(...)` 整表遍历;或者 procedure result 返回 `items_json/run_json/work_json` 等 JSON 字符串,`spacetime-client` mapper 再反序列化成旧兼容结构。
|
||||||
|
- 原因:新增索引或 typed snapshot 后,没有同步清理旧 mapper / 测试兼容层,也没有用静态检查拦截回退写法。
|
||||||
|
- 处理:表上已有主键、unique 或 `#[index]` 覆盖查询前缀时,先用对应 accessor `.find(...)` / `.filter(...)`,只对索引无法覆盖的条件做内存残余过滤;procedure result 返回 typed snapshot / typed value,不再跨层传 `*_json: Option<String>` 作为 payload。
|
||||||
|
- 验证:执行 `npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`,涉及绑定变化时先执行 `npm run spacetime:generate` 和 `npm run check:spacetime-schema`。
|
||||||
|
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`scripts/check-spacetime-runtime-access.mjs`、`server-rs/crates/spacetime-module/src/*`、`server-rs/crates/spacetime-client/src/mapper.rs`。
|
||||||
|
|
||||||
|
## 拼图广场列表不要每次 HTTP 请求调用 SpacetimeDB procedure
|
||||||
|
|
||||||
|
- 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure,导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。
|
||||||
|
- 原因:`api-server` 的服务器端 `spacetime-client` 没有订阅可公开读取的 gallery 投影,虽然 SDK 支持 client cache,但请求路径仍把列表读取当作 procedure 调用。
|
||||||
|
- 处理:`spacetime-module` 中用 public view `puzzle_gallery_card_view` 暴露已发布拼图作品的列表卡片字段,不携带 `levels` / `anchor_pack` 等详情级载荷;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_card_view` 和 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`。HTTP gallery 通过 `PuzzleGalleryCache` 缓存最终 `PuzzleGalleryResponse` DTO:`items` 返回前 10 个完整卡片,`previewRefs` 返回后 10 个作品号引用,cache miss / TTL 过期时单飞重建,后台 cleanup task 周期清理旧响应。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。
|
||||||
|
- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;搜索 `server-rs/crates/spacetime-client/src/lib.rs` 应订阅 `puzzle_gallery_card_view`;执行 `npm run spacetime:generate`、`cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client`、`cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。
|
||||||
|
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.rs`、`/api/runtime/puzzle/gallery`。
|
||||||
|
|
||||||
|
## Windows 本地直连高 VU 压测不要误判成业务内存泄漏
|
||||||
|
|
||||||
|
- 现象:本地 Windows release `api-server` 直连 K6 压测时,250 RPS、`PREALLOCATED_VUS=300` 能把进程 private memory 瞬时推到约 7GB;同样配置打 `/healthz` 小响应也能复现,压测结束后回落到 100MB 级。
|
||||||
|
- 原因:高水位主要来自本机直连的 K6 VU / 长连接 / Hyper 发送链路和 Windows 连接缓冲,不是 SpacetimeDB procedure、拼图 JSON 缓存或 OTEL exporter。降低到接近真实并发的 VU 后,同样 250 RPS 拼图广场 p95 约 9ms,峰值约 600MB。
|
||||||
|
- 处理:本地容量判断时让 `PREALLOCATED_VUS` / `MAX_VUS` 接近真实并发,不要把过高 VU 预分配当作默认吞吐测试;同时观察 `process.memory.*`、`process.windows.handle.count`、`genarrative.http.server.response_bodies.in_flight`、`genarrative.http.server.request_permits.available`、`genarrative.puzzle_gallery.cache.*` 和 `genarrative.spacetime.read.*`。如果内存高但 body in-flight、背压 permit、cache rebuild 和 SpacetimeDB read 都不显示积压,优先按连接 / 发送链路高水位处理。
|
||||||
|
- 验证:对照打 `/api/runtime/puzzle/gallery` 与 `/healthz`;对比 `PREALLOCATED_VUS=300 MAX_VUS=800` 和 `PREALLOCATED_VUS=20 MAX_VUS=40`;压测结束后继续采样 10 秒确认 private memory 回落。
|
||||||
|
- 关联:`scripts/loadtest/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/api-server/src/process_metrics.rs`、`server-rs/crates/api-server/src/telemetry.rs`。
|
||||||
|
|
||||||
|
## 多玩法公开广场列表优先订阅 public view / read model
|
||||||
|
|
||||||
|
- 现象:抓大鹅、方洞挑战、视觉小说、大鱼吃小鱼等公开列表如果沿用 `list_*_works` procedure,即使只读已发布作品,也会在每个 HTTP 请求里回到 SpacetimeDB WASM 侧扫描、反序列化配置并组装列表,50RPS 以上容易变成热点。
|
||||||
|
- 原因:个人作品列表和公开广场列表复用了同一套 procedure 输入,导致公开列表为了通过 owner 校验传固定占位 owner,并把可长期同步的公开读模型当成请求期查询。
|
||||||
|
- 处理:每个公开广场新增或复用专用 public view / public read model:`match_3_d_gallery_view`、`square_hole_gallery_view`、`visual_novel_gallery_view`、`big_fish_gallery_view`。`spacetime-client` 建连接后订阅这些 view 和对应 `public_work_play_daily_stat` source_type 桶,HTTP gallery 只读本地 cache。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍走原有 procedure / reducer。
|
||||||
|
- 验证:搜索 `server-rs/crates/spacetime-client/src/{match3d,square_hole,visual_novel,big_fish}.rs`,公开 gallery 主路径应读取 `connection.db().*_gallery_view()`,不应调用 `list_*_works_with_input`;执行 `npm run spacetime:generate`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。
|
||||||
|
- 关联:`server-rs/crates/spacetime-module/src/match3d.rs`、`server-rs/crates/spacetime-module/src/square_hole.rs`、`server-rs/crates/spacetime-module/src/visual_novel.rs`、`server-rs/crates/spacetime-module/src/big_fish/session.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 自定义世界广场和创作入口配置不要每次 HTTP 请求调用只读 procedure
|
||||||
|
|
||||||
|
- 现象:`/api/runtime/custom-world-gallery` 每次请求调用 `list_custom_world_gallery_entries` procedure;入口熔断中间件每个玩法请求调用 `get_creation_entry_config` procedure,50RPS 以上会把 SpacetimeDB procedure 调用变成热点。
|
||||||
|
- 原因:`custom_world_gallery_entry`、`creation_entry_config` 和 `creation_entry_type_config` 已经是可订阅读模型或配置表,但 HTTP 路径仍按“请求到来再查 procedure”处理。
|
||||||
|
- 处理:`spacetime-client` 长连接订阅 `custom_world_gallery_entry`、`public_work_play_daily_stat` 的 `custom-world` 桶、`creation_entry_config` 和 `creation_entry_type_config`;custom-world gallery 从本地 cache 排序并聚合 7 日播放数;入口配置优先读订阅 cache,cache 缺失时用最近一次成功内存快照,再兜底调用 `get_creation_entry_config` 完成旧库兼容。旧 `list_custom_world_gallery_entries` procedure 只允许作为旧库缺少 gallery 行时的一次性同步兜底。
|
||||||
|
- 验证:搜索 `server-rs/crates/spacetime-client/src/custom_world.rs`,gallery 主路径应是 `read_after_connect` 读取 `custom_world_gallery_entry()`;搜索 `server-rs/crates/spacetime-client/src/runtime.rs`,`get_creation_entry_config` 应优先读取 `creation_entry_config()` 和 `creation_entry_type_config()`。执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||||
|
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/custom_world.rs`、`server-rs/crates/spacetime-client/src/runtime.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
|
||||||
|
|
||||||
|
- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
|
||||||
|
- 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。
|
||||||
|
- 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt,先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。
|
||||||
|
- 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs`、`npm run check:encoding`、`git diff --check`。
|
||||||
|
- 关联:`scripts/generate-taonier-logo-concepts.mjs`、`docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md`。
|
||||||
|
|
||||||
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
|
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
|
||||||
|
|
||||||
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
|
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
|
||||||
@@ -144,10 +192,10 @@
|
|||||||
## 宝贝识物选篮误触发先查多套判定和残余轨迹
|
## 宝贝识物选篮误触发先查多套判定和残余轨迹
|
||||||
|
|
||||||
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
|
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
|
||||||
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。
|
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名、连续横向轨迹和左右手固定篮子规则,或在 `correct` / `wrong` 反馈阶段继续累计手部状态,会把反馈期间残留移动或未知侧别手部误算成下一次选篮。
|
||||||
- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;反馈阶段清空轨迹,不在非 `active` 阶段累计路径。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
|
- 处理:宝贝识物当前选篮只允许“手先触碰中央物品 UI,物品绑定到该手,随后拖入左侧或右侧篮子区域”这一套路径;侧别为 `unknown` 的手部不参与抓取或选篮;反馈阶段清空持有状态,不在非 `active` 阶段累计输入。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
|
||||||
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮。
|
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物仍需换算为用户身体视角以展示左右手:`rightHand` 坐标代表玩家左手,`leftHand` 坐标代表玩家右手。换算不再决定只能选择哪侧篮子;任意一只手都可以拖物品到任意篮子。键鼠调试保持鼠标左键=左手位置、右键=右手位置,也必须先触碰中央物品再拖入篮子。
|
||||||
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。
|
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试、触碰前不能选篮和任意手拖入任意篮子用例通过。
|
||||||
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
## 宝贝爱画左右手反了先查 mocap 摄像头视角换算
|
## 宝贝爱画左右手反了先查 mocap 摄像头视角换算
|
||||||
@@ -161,11 +209,27 @@
|
|||||||
## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求
|
## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求
|
||||||
|
|
||||||
- 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。
|
- 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。
|
||||||
- 原因:宝贝识物一次创作会生成 2 张物品图和 `background`、`ui-frame`、`gift-box`、`basket`、`smoke-puff` 5 张视觉包装图。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。
|
- 原因:宝贝识物创作属于长耗时 image-2 链路。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort,再重复发起第二次生成;上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。2026-05-14 后,新链路已从“2 张物品图 + 5 张视觉包装图”收敛为“1 张 `2x2` 素材 sheet + 1 张场景背景图”,左右手位置指示器改为运行态默认静态素材,不再每次创作生成,但仍需要按长耗时链路排查。
|
||||||
- 处理:`babyObjectMatchClient` 对 `/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动物品图和视觉主题包生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。
|
- 处理:`babyObjectMatchClient` 对 `/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动 `2x2` 素材 sheet 和场景背景生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。`2x2` sheet 固定包含物品 A、物品 B、篮子和礼物盒,服务端按格切图并转透明 PNG;`ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 不再作为新生成必需资源。
|
||||||
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误` 的 `upstreamStatus/raw_excerpt`。
|
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts`、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成`、`宝贝识物 image-2 场景资源生成完成` 和整体 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误` 的 `upstreamStatus/raw_excerpt`。
|
||||||
- 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
- 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts`、`src/services/miniGameDraftGenerationProgress.ts`、`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
|
## 宝贝识物篮子手柄白底先查 sheet 切图后处理
|
||||||
|
|
||||||
|
- 现象:`宝贝识物` 新生成的主题篮子在左右手柄、篮口镂空或边缘处仍出现白底块或白色毛边,尤其是 2x2 sheet 背景被抠透明后,封闭镂空区域可能没有被通用边缘连通抠图清理掉。
|
||||||
|
- 原因:宝贝识物为了降低 image-2 成本,把物品 A、物品 B、篮子和礼物盒放在同一张 `2x2` sheet。通用背景透明处理主要从单格边缘连通背景开始,封闭在篮子手柄内部的近白区域不一定与边缘连通,因此会残留;如果把强力近白清理应用到物品格,又可能误伤白色物品主体。
|
||||||
|
- 处理:后端 `slice_baby_object_match_sheet` 只在 `BabyObjectMatchSheetSlot::Basket` 编码前执行近白、低饱和 matte 清理;物品格和礼物盒格继续只走通用背景透明处理。sheet prompt 同步要求篮子手柄和篮口镂空处不要留下白底描边或毛边。运行态左右篮子的物品图标和名称 UI 以篮子中心线对齐,避免素材放大后看起来偏移。
|
||||||
|
- 验证:运行 `cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 与 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx`;真实联调需要重新生成宝贝识物资源,旧草稿中已保存的 base64 篮子图不会自动被新后处理改写。
|
||||||
|
- 关联:`server-rs/crates/api-server/src/edutainment_baby_object.rs`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`src/index.css`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
|
## 宝贝识物物品框被长条素材拉伸先查固定槽位
|
||||||
|
|
||||||
|
- 现象:用户用手机、筷子等长条关键词生成素材后,中央物品 UI 或篮子上方物品图标看起来被拉成长框,圆形 UI 失去固定比例。
|
||||||
|
- 原因:运行态如果让图片固有宽高或外层自适应内容,就会把长条透明 PNG 的主体比例传导到 UI 容器。
|
||||||
|
- 处理:中央物品 UI 和篮子物品图标都必须使用固定正方形槽位,外层尺寸由 CSS 变量控制;生成素材图片只在槽位内 `object-fit: contain` 等比缩放,不改变外层圆形 UI 框尺寸。
|
||||||
|
- 验证:用长条物品草稿进入宝贝识物运行态,中央物品框和篮子图标框仍为正圆,长条主体在框内缩小显示。
|
||||||
|
- 关联:`src/index.css`、`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx`、`docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`。
|
||||||
|
|
||||||
## 寓教于乐作品和宝贝识物模板同时消失先查入口种子
|
## 寓教于乐作品和宝贝识物模板同时消失先查入口种子
|
||||||
|
|
||||||
- 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`。
|
- 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`。
|
||||||
@@ -186,10 +250,18 @@
|
|||||||
|
|
||||||
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
|
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
|
||||||
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板,就会出现变形和层叠观感。
|
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板,就会出现变形和层叠观感。
|
||||||
- 处理:使用 v2 用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v2.png`、`picture-book-character-outline-v2.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png`、`picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。若只需修透明裁切或品红边,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
|
- 处理:使用用途专属资源:`picture-book-foreground-grass-v2.png`、`picture-book-ground-ring-v3.png`、`picture-book-character-outline-v4.png`、`picture-book-hud-strip-v2.png`、`picture-book-calibration-strip-v2.png`、`picture-book-start-panel-v2.png`、`picture-book-ui-button-v2.png`;CSS 按资源比例等比缩放,底部草坪只覆盖下沿,HUD / 状态条 / 开始托盘分别引用各自资源。角色指示器使用 v4 更细白色描边资源,内部透明且显示尺寸相对上一版放大 50%;若只需修透明裁切、品红边或纯描边后处理,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
|
||||||
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`。
|
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`。
|
||||||
- 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`public/child-motion-demo/`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
- 关联:`scripts/generate-child-motion-demo-assets.mjs`、`src/index.css`、`public/child-motion-demo/`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||||
|
|
||||||
|
## 儿童动作 Demo 猫咪挥手拆件错位先查动画父级和肩部挂点
|
||||||
|
|
||||||
|
- 现象:`/child-motion-demo` 打个招呼阶段的猫咪图和风格正确,但挥手时左右手臂像漂浮在身体旁边,视频里能看到肢体没有稳定接在肩膀上。
|
||||||
|
- 原因:猫咪身体和手臂如果分别做上下浮动,或手臂使用透明方形画布的默认中心/底部旋转轴,就会在摆动极值时放大肩点偏差;镜像左臂还需要把资源内部连接点换算到镜像后的坐标。
|
||||||
|
- 处理:`.child-motion-gesture-guide__wave-cat` 父级统一承接 bob 动画,身体层保持静态贴底且层级低于手臂;左右手臂作为同一父级下的兄弟层,只做旋转动画并显示在身体前方。身体使用去掉左右小圆点的 `picture-book-wave-cat-body-guide-v7.png`;手臂 v7 资源当前按身体外缘摆放,圆猫爪掌面朝向玩家;左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴,动画周期为 `0.47s`,左右手臂不设置错峰延迟;不要把 `scaleX(...)` 和 rotate 放在同一个手臂 wrapper 上。
|
||||||
|
- 验证:用用户录屏关键帧或离线合成预览检查摆动两端的手臂根部仍贴住肩点;再运行儿童动作 Demo 定向组件测试、ESLint 和 `npm run check:encoding`。
|
||||||
|
- 关联:`src/index.css`、`public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png`、`public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png`、`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`。
|
||||||
|
|
||||||
## GPT-image-2 不再读 APIMart 图片配置
|
## GPT-image-2 不再读 APIMart 图片配置
|
||||||
|
|
||||||
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。
|
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`。
|
||||||
@@ -358,6 +430,14 @@
|
|||||||
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
||||||
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
||||||
|
|
||||||
|
## `npm run build` 因 Vite warning 被 build-gate 判失败
|
||||||
|
|
||||||
|
- 现象:主站或后台 Vite 已经输出 `built in ...`,但根命令最后仍失败并打印 `Build gate failed because warnings were emitted`。
|
||||||
|
- 原因:`scripts/build-gate.mjs` 会收集 stdout / stderr 中的 warning 行并作为硬失败;常见触发是产物 chunk 超过 `vite.config.ts` 或 `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`。
|
||||||
|
- 处理:先看 warning 原文确认来源。若是合理的入口级 chunk 体积增长,调整对应 Vite 配置阈值或做真实拆包;不要把这类失败按 Rust / SpacetimeDB 编译错误排查。
|
||||||
|
- 验证:重新执行 `npm run build`,主站与后台均构建完成且没有 build-gate warning 汇总。
|
||||||
|
- 关联:`scripts/build-gate.mjs`、`vite.config.ts`、`apps/admin-web/vite.config.ts`。
|
||||||
|
|
||||||
## 反馈页清空 file input 前必须先拷贝 FileList
|
## 反馈页清空 file input 前必须先拷贝 FileList
|
||||||
|
|
||||||
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
|
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
|
||||||
@@ -378,8 +458,8 @@
|
|||||||
|
|
||||||
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
|
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
|
||||||
- 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
|
- 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
|
||||||
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。
|
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。本地认证开关例外:`SMS_AUTH_ENABLED`、`SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`。
|
||||||
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。
|
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级;`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })` 在 `.env.local` 写 `SMS_AUTH_ENABLED=true` 时应返回 true。
|
||||||
- 关联:`scripts/dev-utils.mjs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。
|
- 关联:`scripts/dev-utils.mjs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。
|
||||||
|
|
||||||
## OSS 密钥键名不要把字母 O 写成数字 0
|
## OSS 密钥键名不要把字母 O 写成数字 0
|
||||||
@@ -408,28 +488,28 @@
|
|||||||
## 本地短信登录页签突然消失
|
## 本地短信登录页签突然消失
|
||||||
|
|
||||||
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
|
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
|
||||||
- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类:
|
- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。
|
||||||
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
|
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
|
||||||
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
|
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
|
||||||
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
|
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
|
||||||
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
|
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
|
||||||
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
|
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
|
||||||
- 处理:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。
|
- 处理:当前口径是登录弹窗永远展示 `短信登录` 与 `密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,确认 `.env.local` 覆盖 `.env`、`RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标。
|
||||||
- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。
|
- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。
|
||||||
- 关联:`scripts/dev-utils.mjs`、`scripts/dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。
|
- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。
|
||||||
|
|
||||||
## 本地短信收不到验证码先查 provider
|
## 本地短信收不到验证码先查 provider
|
||||||
|
|
||||||
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
|
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
|
||||||
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。
|
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程,Rust bool 解析失败后保持默认 false。
|
||||||
- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。
|
- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_ENABLED=true`、`SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`,cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。
|
||||||
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。
|
- 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]`;`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。
|
||||||
- 关联:`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。
|
- 关联:`server-rs/crates/api-server/src/config.rs`、`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。
|
||||||
|
|
||||||
## 手机验证码登录 500 先查短信 provider 语义
|
## 手机验证码登录 500 先查短信 provider 语义
|
||||||
|
|
||||||
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。
|
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。
|
||||||
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。
|
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`。
|
||||||
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`。
|
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`。
|
||||||
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
||||||
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
||||||
@@ -766,7 +846,7 @@
|
|||||||
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
|
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
|
||||||
- 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21;历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
- 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21;历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
||||||
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。
|
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。
|
||||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||||
|
|
||||||
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
|
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
|
||||||
|
|
||||||
@@ -863,3 +943,27 @@
|
|||||||
- 处理:导入 / 导出流水线在调用迁移脚本前先 `source scripts/jenkins-prepare-toolchain-env.sh`;该脚本会把 `GENARRATIVE_JENKINS_TOOL_PATHS`、`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin`、`/var/lib/jenkins/.cargo/bin`、`/var/lib/jenkins/.local/bin` 和系统 PATH 前缀统一补齐,并在缺少 `node` 时尽早报错。
|
- 处理:导入 / 导出流水线在调用迁移脚本前先 `source scripts/jenkins-prepare-toolchain-env.sh`;该脚本会把 `GENARRATIVE_JENKINS_TOOL_PATHS`、`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin`、`/var/lib/jenkins/.cargo/bin`、`/var/lib/jenkins/.local/bin` 和系统 PATH 前缀统一补齐,并在缺少 `node` 时尽早报错。
|
||||||
- 验证:重新跑 `Genarrative-Database-Import` 或 `Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain` 的 `node=...` 解析结果,而不是在迁移中途报 `node: command not found`。
|
- 验证:重新跑 `Genarrative-Database-Import` 或 `Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain` 的 `node=...` 解析结果,而不是在迁移中途报 `node: command not found`。
|
||||||
- 关联:`scripts/jenkins-prepare-toolchain-env.sh`、`jenkins/Jenkinsfile.production-database-import`、`jenkins/Jenkinsfile.production-database-export`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
- 关联:`scripts/jenkins-prepare-toolchain-env.sh`、`jenkins/Jenkinsfile.production-database-import`、`jenkins/Jenkinsfile.production-database-export`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## Windows Jenkins `powershell` step 在 Stdb module 构建里曾触发 CreateProcess error=5
|
||||||
|
|
||||||
|
- 现象:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 节点上报 `java.io.IOException: Cannot run program "powershell" (in directory "C:\\Users\\DSK\\.jenkins-local\\workspace\\Genarrative-Stdb-Module-Build"): CreateProcess error=5, 拒绝访问。`;日志里能看到 `durable-task` 已写出 `powershellWrapper.ps1`,但在真正启动裸 `powershell` 子进程时失败。
|
||||||
|
- 原因:Jenkins durable-task 的 `powershell` step 依赖一个隐式命令解析/启动路径,在这台 Windows 本地 Jenkins 环境里会被拒绝。`powershell.exe` 本体和 workspace ACL 都是正常的,问题出在 Jenkins step 的启动方式,而不是 PowerShell 脚本内容。修复后若日志能打印 `[jenkins-powershell] exe:`,但随后仅报 `拒绝访问` / `script returned exit code 5`,通常已经不是 PowerShell 启动失败,而是 Checkout 脚本内部命令在 Windows workspace 里触发权限拒绝。若 `.jenkins-*.ps1` 里中文 `throw '[stdb-build] ...'` 报 `MissingArrayIndexExpression`,则是 Windows PowerShell 5.1 用 `-File` 解析无 BOM UTF-8 脚本时按本地 ANSI 误解码。
|
||||||
|
- 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build` 的 `Checkout` 和 `Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper,先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM,最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时,PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。
|
||||||
|
- 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout,日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds/<n>/log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。
|
||||||
|
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||||
|
|
||||||
|
## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底
|
||||||
|
|
||||||
|
- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。
|
||||||
|
- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。
|
||||||
|
- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)` 给 `ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。
|
||||||
|
- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 生成中草稿刷新后不要只恢复作品架遮罩
|
||||||
|
|
||||||
|
- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。
|
||||||
|
- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。
|
||||||
|
- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。
|
||||||
|
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。
|
||||||
|
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|||||||
27
.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md
Normal file
27
.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 前端直订阅公开作品列表准入待办
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
未来可以考虑让前端直接订阅公开作品列表,以减少列表读取链路中的 HTTP 往返,并复用 SpacetimeDB 的实时同步能力。
|
||||||
|
|
||||||
|
## 当前结论
|
||||||
|
|
||||||
|
短期仍由 `api-server` / BFF 订阅 SpacetimeDB public read model,并从本地 cache 读取后对外提供 HTTP 列表接口。前端不直接订阅作品源表,也不把正式列表排序、分页、权限裁剪逻辑下放到 UI。
|
||||||
|
|
||||||
|
## 落地前置条件
|
||||||
|
|
||||||
|
- 建立专用、稳定、低基数的 public read model,例如 `public_work_gallery_entry`。
|
||||||
|
- 明确权限边界,只暴露公开列表所需字段,不泄露作者私有信息、审核内部状态或运营字段。
|
||||||
|
- 固化字段契约,明确字段含义、默认值、兼容策略和生成绑定更新流程。
|
||||||
|
- 明确排序与分页语义,避免依赖自增 ID 顺序,优先使用时间戳或显式排序字段。
|
||||||
|
- 补齐埋点方案,能区分直订阅首屏、增量更新、分页加载和 fallback 命中。
|
||||||
|
- 保留 BFF HTTP fallback,用于低版本客户端、订阅失败、权限策略调整和灰度回滚。
|
||||||
|
- 禁止前端订阅 `puzzle_work_profile`、`custom_world_profile` 等作品源表。
|
||||||
|
|
||||||
|
## 建议验收
|
||||||
|
|
||||||
|
- 文档确认直订阅只面向专用 public read model,不绕过 BFF 读取源表。
|
||||||
|
- schema、绑定、字段契约、排序分页和权限说明同步更新。
|
||||||
|
- 前端具备订阅失败后的 BFF HTTP fallback。
|
||||||
|
- 自动测试覆盖公开字段裁剪、排序分页稳定性和 fallback 路径。
|
||||||
|
- 监控可观察直订阅成功率、首屏耗时、增量更新延迟和 fallback 比例。
|
||||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
# 默认忽略的文件
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# 基于编辑器的 HTTP 客户端请求
|
|
||||||
/httpRequests/
|
|
||||||
# 已忽略包含查询文件的默认文件夹
|
|
||||||
/queries/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
||||||
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
|||||||
mod.rs
|
|
||||||
59
.idea/codeStyles/Project.xml
generated
59
.idea/codeStyles/Project.xml
generated
@@ -1,59 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<code_scheme name="Project" version="173">
|
|
||||||
<HTMLCodeStyleSettings>
|
|
||||||
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
|
||||||
</HTMLCodeStyleSettings>
|
|
||||||
<JSCodeStyleSettings version="0">
|
|
||||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
|
||||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
|
||||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
|
||||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
|
||||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
|
||||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
|
||||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
|
||||||
</JSCodeStyleSettings>
|
|
||||||
<TypeScriptCodeStyleSettings version="0">
|
|
||||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
|
||||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
|
||||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
|
||||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
|
||||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
|
||||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
|
||||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
|
||||||
</TypeScriptCodeStyleSettings>
|
|
||||||
<VueCodeStyleSettings>
|
|
||||||
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
|
||||||
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
|
||||||
</VueCodeStyleSettings>
|
|
||||||
<codeStyleSettings language="HTML">
|
|
||||||
<option name="SOFT_MARGINS" value="80" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="INDENT_SIZE" value="2" />
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
|
||||||
<option name="TAB_SIZE" value="2" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="JavaScript">
|
|
||||||
<option name="SOFT_MARGINS" value="80" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="INDENT_SIZE" value="2" />
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
|
||||||
<option name="TAB_SIZE" value="2" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="TypeScript">
|
|
||||||
<option name="SOFT_MARGINS" value="80" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="INDENT_SIZE" value="2" />
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
|
||||||
<option name="TAB_SIZE" value="2" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="Vue">
|
|
||||||
<option name="SOFT_MARGINS" value="80" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
|
||||||
</component>
|
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
248
.idea/editor.xml
generated
248
.idea/editor.xml
generated
@@ -1,248 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="BackendCodeEditorSettings">
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CDeclarationWithImplicitIntType/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConstevalIfIsAlwaysConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractClassWithoutSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractVirtualFunctionCallInCtor/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAccessSpecifierWithNoDeclarations/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAwaiterTypeIsNotClass/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBooleanIncrementExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatBadCode/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatLegacyCode/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatMixedArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooFewArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCompileTimeConstantCanBeReplacedWithBooleanConstant/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConceptNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConditionalExpressionCanBeSimplified/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstValueFunctionReturnType/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCoroutineCallResolveError/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantConditions/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFADeletedPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInvalidatedMemory/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesScope/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALoopConditionNotUpdated/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAMemoryLeak/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANotInitializedField/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANullDereference/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesLocal/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesUncapturedLocal/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationSpecifierWithoutDeclarators/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorDisambiguatedAsFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorUsedBeforeInitialization/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultCaseNotHandledInSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedEntity/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedOverridenMethod/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedRegisterStorageClassSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDereferenceOperatorLimitExceeded/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDiscardedPostfixOperatorResult/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenSyntaxError/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUndocumentedParameter/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUnresolvedReference/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEmptyDeclaration/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersOrder/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersPlacement/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceDoStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceForStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceFunctionDeclarationStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceIfStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceNestedNamespacesStyle/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingDestructorStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingFunctionStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceTypeAliasCodeStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceWhileStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityAssignedButNoRead/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityUsedOnlyInUnevaluatedContext/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnumeratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEqualOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEvaluationFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExplicitSpecializationInNonNamespaceScope/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExpressionWithoutSideEffects/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalFunctionInFinalClass/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalNonOverridingVirtualFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForLoopCanBeReplacedWithWhile/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForwardEnumDeclarationWithoutUnderlyingType/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionResultShouldBeUsed/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionalStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHeaderHasBeenAlreadyIncluded/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHiddenFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHidingFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIdenticalOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIfCanBeReplacedByConstexprIf/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberInitializersOrder/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMismatchedClassTags/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingIncludeGuard/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingKeywordThrow/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppModulePartitionWithSeveralPartitionUnits/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtAddressOfClassRValue/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtBindingRValueToLvalueReference/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtCopyElisionInCopyInitDeclarator/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtDoubleUserConversionInCopyInit/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtNotInitializedStaticConstLocalVar/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtReinterpretCastFromNullptr/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterWideLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMustBePublicVirtualToImplementInterface/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMutableSpecifierOnReferenceMember/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNoDiscardExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNodiscardFunctionWithoutReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExceptionSafeResourceAcquisition/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConversionOperator/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConvertingConstructor/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineFunctionDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineVariableDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNotAllPathsReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppObjectMemberMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOutParameterMustBeWritten/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConstPtrOrRef/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNamesMismatch/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPassValueParameterByConstReference/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerConversionDropsQualifiers/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerToIntegralConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPolymorphicClassWithNonVirtualPublicDestructor/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyErroneousEmptyStatements/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUnintendedObjectSlicing/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderIsNotIncluded/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderNotFound/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfBadFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfExtraArg/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfMissedArg/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfRiskyFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrivateSpecialMemberFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRangeBasedForIncompatibleReference/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedefinitionOfDefaultArgumentInOverrideFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBooleanExpressionArgument/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantCastExpression/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantComplexityInComparison/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConditionalExpression/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConstSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantControlFlowJump/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantDereferencingAndTakingAddress/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElaboratedTypeSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeyword/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeywordInsideCompoundStatement/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyDeclaration/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyStatement/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantExportKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantFwdClassOrEnumSpecifier/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantInlineSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantLambdaParameterList/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantMemberInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantNamespaceDefinition/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantParentheses/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifier/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifierADL/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnMemberAllocationFunction/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnThreadLocalLocalVariable/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateArguments/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantVoidArgumentList/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantZeroInitializerInAggregateInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReinterpretCastFromVoidPtr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRemoveRedundantBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceMemsetWithZeroInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceTieWithStructuredBinding/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReturnNoValueInNonVoidFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSmartPointerVsMakeFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSomeObjectMembersMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSpecialFunctionWithoutNoexceptSpecification/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticAssertFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppThrowExpressionCanBeReplacedWithRethrow/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScope/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScopeInitStatement/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTypeAliasNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedDependentBaseClass/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedNonStaticDataMember/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnionMemberOfReferenceType/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAssociativeContains/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAuto/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAutoForNumeric/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseElementsView/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseEraseAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseFamiliarTemplateSyntaxForGenericLambdas/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseRangeAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStdSize/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStructuredBinding/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseTypeTraitAlias/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUserDefinedLiteralSuffixDoesNotStartWithUnderscore/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUsingResultOfAssignmentAsCondition/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVariableCanBeMadeConstexpr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionCallInsideCtor/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionInFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVolatileParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWarningDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongIncludesOrder/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongSlashesInIncludeDirective/@EntryIndexedValue" value="HINT" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroConstantCanBeReplacedWithNullptr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroValuedExpressionUsedAsNullPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
|
||||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Genarrative.iml" filepath="$PROJECT_DIR$/.idea/Genarrative.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/prettier.xml
generated
6
.idea/prettier.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="PrettierConfiguration">
|
|
||||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
132
deploy/container/README.md
Normal file
132
deploy/container/README.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Genarrative 容器化压测与隔离部署方案
|
||||||
|
|
||||||
|
本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。
|
||||||
|
|
||||||
|
## 拓扑
|
||||||
|
|
||||||
|
```text
|
||||||
|
Docker Compose
|
||||||
|
├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
|
||||||
|
├─ api-server :8082,Linux release 构建,连接外部 SpacetimeDB
|
||||||
|
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
||||||
|
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
默认 host 端口:
|
||||||
|
|
||||||
|
- `http://127.0.0.1:18080`:容器 Nginx。
|
||||||
|
- `127.0.0.1:4317` / `127.0.0.1:4318`:容器 Collector OTLP gRPC / HTTP。
|
||||||
|
|
||||||
|
如端口冲突,可设置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GENARRATIVE_CONTAINER_HTTP_PORT="18081"
|
||||||
|
$env:GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="14318"
|
||||||
|
$env:GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="14317"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:init
|
||||||
|
```
|
||||||
|
|
||||||
|
该命令会从 `deploy/container/api-server.env.example` 生成本地 `deploy/container/api-server.env`。真实 token、库名和外部服务密钥只写本地 env 文件,不提交 Git。
|
||||||
|
|
||||||
|
Docker Desktop 下默认通过 `host.docker.internal:3101` 连接宿主机上 `npm run dev` 启动的 SpacetimeDB:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GENARRATIVE_SPACETIME_SERVER_URL=http://host.docker.internal:3101
|
||||||
|
GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest
|
||||||
|
GENARRATIVE_SPACETIME_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux Docker Engine 如果不能解析 `host.docker.internal`,Compose 已配置 `host-gateway`;仍不通时把 `GENARRATIVE_SPACETIME_SERVER_URL` 改成宿主机网关 IP 或同网络内的 SpacetimeDB 地址。
|
||||||
|
|
||||||
|
## 启动与验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:config
|
||||||
|
npm run container:build
|
||||||
|
npm run container:up
|
||||||
|
npm run container:ps
|
||||||
|
curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:logs -- nginx
|
||||||
|
npm run container:logs -- api-server
|
||||||
|
npm run container:logs -- otelcol
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run container:config` 默认只校验配置,不打印完整 env。排查 compose 展开结果时可临时使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:config -- --print
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
||||||
|
|
||||||
|
停止:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:down
|
||||||
|
```
|
||||||
|
|
||||||
|
如需同时清理容器卷:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:down -- -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 压测
|
||||||
|
|
||||||
|
k6 在 compose 网络内访问 `http://nginx`,避免 Windows 本机直连连接模型干扰 Linux 容器结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:k6
|
||||||
|
```
|
||||||
|
|
||||||
|
作品列表脚本一次 iteration 默认请求两个公开列表接口,因此目标 500 HTTP req/s 对应 `PEAK_RPS=250`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SCENARIO="spike"
|
||||||
|
$env:START_RPS="25"
|
||||||
|
$env:PEAK_RPS="250"
|
||||||
|
$env:HOLD="60s"
|
||||||
|
$env:END_RPS="25"
|
||||||
|
$env:PREALLOCATED_VUS="100"
|
||||||
|
$env:MAX_VUS="500"
|
||||||
|
$env:DETAIL_RATIO="0"
|
||||||
|
npm run container:k6
|
||||||
|
```
|
||||||
|
|
||||||
|
如果要压 1000 HTTP req/s,把 `PEAK_RPS` 调到 `500`;如果要压 5000 HTTP req/s,把 `PEAK_RPS` 调到 `2500`,并同时提高 `PREALLOCATED_VUS` / `MAX_VUS`,观察是否先被带宽、Nginx `limit_conn` 或 api-server 背压限制。
|
||||||
|
|
||||||
|
## OTLP
|
||||||
|
|
||||||
|
容器内 `otelcol` 默认使用 debug exporter。开启 api-server OTEL:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GENARRATIVE_OTEL_ENABLED=true
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
|
||||||
|
```
|
||||||
|
|
||||||
|
然后重建或重启容器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:up
|
||||||
|
npm run container:logs -- otelcol
|
||||||
|
```
|
||||||
|
|
||||||
|
Collector 日志会输出 traces / metrics / logs。接 Rider、Jaeger、Tempo、Prometheus、Grafana 或托管平台时,另建独立 Collector 配置,不直接改生产 systemd 或 Nginx 模板。
|
||||||
|
|
||||||
|
## 隔离边界
|
||||||
|
|
||||||
|
- 不改生产 systemd 单元。
|
||||||
|
- 不改 Jenkins 发布主流程。
|
||||||
|
- 不要求真实 HTTPS 证书。
|
||||||
|
- 不把真实 `.env`、`.env.local`、`.env.secrets.local` 或 `deploy/container/api-server.env` 放入 Docker build context。
|
||||||
|
- 不在容器镜像里内置 SpacetimeDB 数据或 token。
|
||||||
49
deploy/container/api-server.Dockerfile
Normal file
49
deploy/container/api-server.Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
FROM rust:1.88-bookworm AS rust-builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY server-rs ./server-rs
|
||||||
|
RUN cargo build --release -p api-server --manifest-path server-rs/Cargo.toml && \
|
||||||
|
cp server-rs/target/release/api-server /tmp/api-server
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim AS api-runtime
|
||||||
|
WORKDIR /srv/genarrative
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
useradd --system --create-home --home-dir /srv/genarrative --shell /usr/sbin/nologin genarrative
|
||||||
|
|
||||||
|
COPY --from=rust-builder /tmp/api-server /usr/local/bin/api-server
|
||||||
|
|
||||||
|
RUN mkdir -p /var/lib/genarrative/auth && \
|
||||||
|
chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative
|
||||||
|
|
||||||
|
USER genarrative
|
||||||
|
EXPOSE 8082
|
||||||
|
|
||||||
|
ENV GENARRATIVE_ENV=container \
|
||||||
|
GENARRATIVE_API_HOST=0.0.0.0 \
|
||||||
|
GENARRATIVE_API_PORT=8082 \
|
||||||
|
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
|
||||||
|
|
||||||
|
CMD ["api-server"]
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS web-builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/admin-web/package.json ./apps/admin-web/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY index.html metadata.json tsconfig.json vite.config.ts ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
COPY media ./media
|
||||||
|
COPY packages ./packages
|
||||||
|
COPY apps/admin-web ./apps/admin-web
|
||||||
|
RUN npm run build:raw && npm run admin-web:build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS nginx-runtime
|
||||||
|
COPY --from=web-builder /workspace/dist /srv/genarrative/web
|
||||||
|
COPY --from=web-builder /workspace/apps/admin-web/dist /srv/genarrative/web/admin
|
||||||
|
COPY deploy/container/nginx.conf /etc/nginx/nginx.conf
|
||||||
35
deploy/container/api-server.env.example
Normal file
35
deploy/container/api-server.env.example
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 复制为 deploy/container/api-server.env 后填入本机或预发值。
|
||||||
|
# 该文件只用于容器隔离方案,不参与 systemd/Jenkins 生产部署。
|
||||||
|
# 不要在这里写真实 token 后提交 Git。
|
||||||
|
|
||||||
|
GENARRATIVE_ENV=container
|
||||||
|
GENARRATIVE_API_HOST=0.0.0.0
|
||||||
|
GENARRATIVE_API_PORT=8082
|
||||||
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
|
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||||
|
GENARRATIVE_API_WORKER_THREADS=4
|
||||||
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||||
|
|
||||||
|
GENARRATIVE_OTEL_ENABLED=false
|
||||||
|
OTEL_SERVICE_NAME=genarrative-api
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=container,service.namespace=genarrative
|
||||||
|
|
||||||
|
GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_CONTAINER
|
||||||
|
GENARRATIVE_JWT_ISSUER=genarrative-container
|
||||||
|
GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER
|
||||||
|
AUTH_REFRESH_COOKIE_SECURE=false
|
||||||
|
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
|
||||||
|
|
||||||
|
# Docker Desktop 下连接宿主机 npm run dev 启动的 SpacetimeDB。
|
||||||
|
# Linux Docker Engine 可改成宿主机网关 IP,或在 compose 里接入同一网络内的 SpacetimeDB。
|
||||||
|
GENARRATIVE_SPACETIME_SERVER_URL=http://host.docker.internal:3101
|
||||||
|
GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest
|
||||||
|
GENARRATIVE_SPACETIME_TOKEN=
|
||||||
|
GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||||
|
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||||
|
|
||||||
|
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||||
|
GENARRATIVE_LLM_BASE_URL=
|
||||||
|
GENARRATIVE_LLM_API_KEY=
|
||||||
|
GENARRATIVE_LLM_MODEL=
|
||||||
85
deploy/container/docker-compose.loadtest.yml
Normal file
85
deploy/container/docker-compose.loadtest.yml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: genarrative-container-loadtest
|
||||||
|
|
||||||
|
services:
|
||||||
|
api-server:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/container/api-server.Dockerfile
|
||||||
|
target: api-runtime
|
||||||
|
env_file:
|
||||||
|
- ./api-server.env
|
||||||
|
environment:
|
||||||
|
GENARRATIVE_API_HOST: 0.0.0.0
|
||||||
|
GENARRATIVE_API_PORT: 8082
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4318
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- api-auth-store:/var/lib/genarrative/auth
|
||||||
|
depends_on:
|
||||||
|
otelcol:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8082/healthz"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: deploy/container/api-server.Dockerfile
|
||||||
|
target: nginx-runtime
|
||||||
|
depends_on:
|
||||||
|
api-server:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${GENARRATIVE_CONTAINER_HTTP_PORT:-18080}:80"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- nginx-logs:/var/log/nginx
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/api/runtime/puzzle/gallery"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
otelcol:
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.125.0
|
||||||
|
command: ["--config=/etc/otelcol/config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./otelcol.yaml:/etc/otelcol/config.yaml:ro
|
||||||
|
ports:
|
||||||
|
- "${GENARRATIVE_CONTAINER_OTLP_GRPC_PORT:-4317}:4317"
|
||||||
|
- "${GENARRATIVE_CONTAINER_OTLP_HTTP_PORT:-4318}:4318"
|
||||||
|
|
||||||
|
k6:
|
||||||
|
image: grafana/k6:0.52.0
|
||||||
|
profiles: ["loadtest"]
|
||||||
|
depends_on:
|
||||||
|
nginx:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
BASE_URL: http://nginx
|
||||||
|
WORKS_DATA: data/works-list.sample.json
|
||||||
|
SCENARIO: ${SCENARIO:-spike}
|
||||||
|
START_RPS: ${START_RPS:-5}
|
||||||
|
PEAK_RPS: ${PEAK_RPS:-250}
|
||||||
|
HOLD: ${HOLD:-60s}
|
||||||
|
END_RPS: ${END_RPS:-5}
|
||||||
|
PREALLOCATED_VUS: ${PREALLOCATED_VUS:-100}
|
||||||
|
MAX_VUS: ${MAX_VUS:-500}
|
||||||
|
DETAIL_RATIO: ${DETAIL_RATIO:-0}
|
||||||
|
SLEEP_MIN_SECONDS: ${SLEEP_MIN_SECONDS:-0}
|
||||||
|
SLEEP_MAX_SECONDS: ${SLEEP_MAX_SECONDS:-0}
|
||||||
|
volumes:
|
||||||
|
- ../../scripts/loadtest:/scripts/loadtest:ro
|
||||||
|
working_dir: /scripts/loadtest
|
||||||
|
command: ["run", "k6-works-list.js"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-auth-store:
|
||||||
|
nginx-logs:
|
||||||
133
deploy/container/nginx.conf
Normal file
133
deploy/container/nginx.conf
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format genarrative_upstream
|
||||||
|
'$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
|
||||||
|
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
|
||||||
|
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
|
||||||
|
'upstream_status=$upstream_status request_id=$request_id';
|
||||||
|
|
||||||
|
upstream genarrative_api {
|
||||||
|
server api-server:8082;
|
||||||
|
keepalive 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
application/xml+rss
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||||
|
error_log /var/log/nginx/genarrative.error.log warn;
|
||||||
|
limit_conn_status 429;
|
||||||
|
limit_conn_log_level warn;
|
||||||
|
|
||||||
|
root /srv/genarrative/web;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location ^~ /admin/api/ {
|
||||||
|
default_type application/json;
|
||||||
|
limit_conn genarrative_api_conn 64;
|
||||||
|
|
||||||
|
proxy_pass http://genarrative_api/admin/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Request-Id $request_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /admin {
|
||||||
|
return 301 /admin/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /admin/ {
|
||||||
|
try_files $uri $uri/ /admin/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /assets/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/api(?:/|$) {
|
||||||
|
default_type application/json;
|
||||||
|
limit_conn genarrative_api_conn 64;
|
||||||
|
|
||||||
|
proxy_pass http://genarrative_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
add_header X-Accel-Buffering no always;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Request-Id $request_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(generated-|healthz) {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/v1/database/[^/]+/subscribe$ {
|
||||||
|
proxy_pass http://host.docker.internal:3101;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /v1/identity {
|
||||||
|
proxy_pass http://host.docker.internal:3101;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /v1/ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
deploy/container/otelcol.yaml
Normal file
23
deploy/container/otelcol.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
debug:
|
||||||
|
verbosity: detailed
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
|
logs:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
13
deploy/env/api-server.env.example
vendored
13
deploy/env/api-server.env.example
vendored
@@ -5,6 +5,13 @@ GENARRATIVE_ENV=production
|
|||||||
GENARRATIVE_API_HOST=127.0.0.1
|
GENARRATIVE_API_HOST=127.0.0.1
|
||||||
GENARRATIVE_API_PORT=8082
|
GENARRATIVE_API_PORT=8082
|
||||||
GENARRATIVE_API_LOG=info,tower_http=info
|
GENARRATIVE_API_LOG=info,tower_http=info
|
||||||
|
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||||
|
GENARRATIVE_API_WORKER_THREADS=4
|
||||||
|
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||||
|
GENARRATIVE_OTEL_ENABLED=false
|
||||||
|
OTEL_SERVICE_NAME=genarrative-api
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=genarrative
|
||||||
|
|
||||||
GENARRATIVE_ADMIN_USERNAME=
|
GENARRATIVE_ADMIN_USERNAME=
|
||||||
GENARRATIVE_ADMIN_PASSWORD=
|
GENARRATIVE_ADMIN_PASSWORD=
|
||||||
@@ -79,9 +86,9 @@ SMS_AUTH_ENABLED=false
|
|||||||
SMS_AUTH_PROVIDER=aliyun
|
SMS_AUTH_PROVIDER=aliyun
|
||||||
ALIYUN_SMS_ACCESS_KEY_ID=
|
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||||
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||||
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
|
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||||
ALIYUN_SMS_SIGN_NAME=
|
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
|
||||||
ALIYUN_SMS_TEMPLATE_CODE=
|
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
|
||||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||||
ALIYUN_SMS_COUNTRY_CODE=86
|
ALIYUN_SMS_COUNTRY_CODE=86
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
# 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。
|
# 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。
|
||||||
# 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。
|
# 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。
|
||||||
# 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。
|
# 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。
|
||||||
|
log_format genarrative_upstream
|
||||||
|
'$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
|
||||||
|
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
|
||||||
|
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
|
||||||
|
'upstream_status=$upstream_status request_id=$request_id';
|
||||||
|
|
||||||
|
upstream genarrative_api {
|
||||||
|
server 127.0.0.1:8082;
|
||||||
|
keepalive 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name genarrative.example.com;
|
server_name genarrative.example.com;
|
||||||
|
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||||
|
error_log /var/log/nginx/genarrative.error.log warn;
|
||||||
|
limit_conn_status 429;
|
||||||
|
limit_conn_log_level warn;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -29,13 +47,15 @@ server {
|
|||||||
|
|
||||||
location ^~ /admin/api/ {
|
location ^~ /admin/api/ {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
|
limit_conn genarrative_api_conn 64;
|
||||||
|
|
||||||
if ($genarrative_maintenance) {
|
if ($genarrative_maintenance) {
|
||||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:8082/admin/api/;
|
proxy_pass http://genarrative_api/admin/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -68,17 +88,19 @@ server {
|
|||||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||||
location ~ ^/api(?:/|$) {
|
location ~ ^/api(?:/|$) {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
|
limit_conn genarrative_api_conn 64;
|
||||||
|
|
||||||
if ($genarrative_maintenance) {
|
if ($genarrative_maintenance) {
|
||||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:8082;
|
proxy_pass http://genarrative_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 3600s;
|
||||||
add_header X-Accel-Buffering no always;
|
add_header X-Accel-Buffering no always;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
# 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。
|
# 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。
|
||||||
|
log_format genarrative_upstream
|
||||||
|
'$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
|
||||||
|
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
|
||||||
|
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
|
||||||
|
'upstream_status=$upstream_status request_id=$request_id';
|
||||||
|
|
||||||
|
upstream genarrative_api {
|
||||||
|
server 127.0.0.1:8082;
|
||||||
|
keepalive 64;
|
||||||
|
}
|
||||||
|
|
||||||
|
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name genarrative.example.com;
|
server_name genarrative.example.com;
|
||||||
|
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||||
|
error_log /var/log/nginx/genarrative.error.log warn;
|
||||||
|
limit_conn_status 429;
|
||||||
|
limit_conn_log_level warn;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
root /var/www/html;
|
root /var/www/html;
|
||||||
@@ -15,6 +33,8 @@ server {
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name genarrative.example.com;
|
server_name genarrative.example.com;
|
||||||
|
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||||
|
error_log /var/log/nginx/genarrative.error.log warn;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -43,13 +63,15 @@ server {
|
|||||||
|
|
||||||
location ^~ /admin/api/ {
|
location ^~ /admin/api/ {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
|
limit_conn genarrative_api_conn 64;
|
||||||
|
|
||||||
if ($genarrative_maintenance) {
|
if ($genarrative_maintenance) {
|
||||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:8082/admin/api/;
|
proxy_pass http://genarrative_api/admin/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -82,17 +104,19 @@ server {
|
|||||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||||
location ~ ^/api(?:/|$) {
|
location ~ ^/api(?:/|$) {
|
||||||
default_type application/json;
|
default_type application/json;
|
||||||
|
limit_conn genarrative_api_conn 64;
|
||||||
|
|
||||||
if ($genarrative_maintenance) {
|
if ($genarrative_maintenance) {
|
||||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:8082;
|
proxy_pass http://genarrative_api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 3600s;
|
||||||
add_header X-Accel-Buffering no always;
|
add_header X-Accel-Buffering no always;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ Restart=always
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
KillSignal=SIGINT
|
KillSignal=SIGINT
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
|
LimitNOFILE=65535
|
||||||
|
TasksMax=2048
|
||||||
|
|
||||||
# api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。
|
# api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|||||||
505
docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md
Normal file
505
docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
# 儿童动作识别互动玩法 Demo 热身关开发文档
|
||||||
|
|
||||||
|
> 日期:2026-05-09
|
||||||
|
> 适用范围:儿童动作识别互动玩法 Demo 的固定启动热身关
|
||||||
|
> 文档性质:玩法 Demo 开发设计文档
|
||||||
|
> 说明:本文整理当前已确认的热身关内容、体验、流程和热身数据记录要求。
|
||||||
|
|
||||||
|
## 1. 热身关定位
|
||||||
|
|
||||||
|
热身关是 Demo 启动后的固定流程,用于在正式进入后续趣味学习关前完成以下事项:
|
||||||
|
|
||||||
|
- 调用摄像头;
|
||||||
|
- 识别用户和环境;
|
||||||
|
- 引导用户来到建议互动位置;
|
||||||
|
- 教学基础交互方式;
|
||||||
|
- 确认用户可在互动空间内完成左右移动和挥手;
|
||||||
|
- 记录用户左右移动距离和挥动手臂空间,作为后续关卡的空间边界与行为坐标;
|
||||||
|
- 完成后进入关卡选择。
|
||||||
|
|
||||||
|
热身关不接入创作模块,不作为可配置玩法模板提供给创作者。
|
||||||
|
|
||||||
|
## 2. 屏幕与设备适配
|
||||||
|
|
||||||
|
本产品适用于电视屏幕、电脑屏幕等环境。
|
||||||
|
|
||||||
|
热身关制作表达使用横屏比例。
|
||||||
|
|
||||||
|
## 3. 画面基础表现
|
||||||
|
|
||||||
|
用户进入热身关后,摄像头被调用,并开始识别用户和环境。
|
||||||
|
|
||||||
|
画面基础表现如下:
|
||||||
|
|
||||||
|
1. 在屏幕中央位置的地面生成预设的绿色圆环,作为建议位置的指引。
|
||||||
|
2. 将用户的实际位置生成为更细的白色描边小人指示器,作为用户在画面中的标识。
|
||||||
|
3. 只对摄像头背景做虚化处理,用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。
|
||||||
|
|
||||||
|
## 4. 通用检测与引导规则
|
||||||
|
|
||||||
|
### 4.1 不允许跳过
|
||||||
|
|
||||||
|
热身关每个步骤都必须由用户完成,不允许跳过,也不允许系统自动进入下一步。
|
||||||
|
|
||||||
|
### 4.2 引导动画播放规则
|
||||||
|
|
||||||
|
每个动作等待 3 秒后可以播放引导动画。
|
||||||
|
|
||||||
|
当前不设置最长等待时间。
|
||||||
|
|
||||||
|
### 4.3 绿色圆环完成规则
|
||||||
|
|
||||||
|
用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。
|
||||||
|
|
||||||
|
用户需要在绿色圆环内保持停留 2 秒,才算完成该圆环位置检测。
|
||||||
|
|
||||||
|
### 4.4 左右距离映射规则
|
||||||
|
|
||||||
|
“约半米”的左右移动距离,技术上以角色剪影移动距离为准。
|
||||||
|
|
||||||
|
该距离后续会根据实际体验继续调校。
|
||||||
|
|
||||||
|
### 4.5 手势区分规则
|
||||||
|
|
||||||
|
招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。
|
||||||
|
|
||||||
|
手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||||
|
|
||||||
|
### 4.6 手势引导规则
|
||||||
|
|
||||||
|
挥动哪只手,就使用对应手的引导。
|
||||||
|
|
||||||
|
## 5. 热身关完整流程
|
||||||
|
|
||||||
|
### 5.1 进入热身关
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
- 摄像头被调用。
|
||||||
|
- 系统识别用户和环境。
|
||||||
|
- 屏幕中央位置的地面出现预设绿色圆环。
|
||||||
|
- 用户实际位置以更细的白色描边小人指示器形式显示。
|
||||||
|
- 只对摄像头背景做虚化处理,保留空间感。
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
屏幕中上方浮现文字,同时语音播报:
|
||||||
|
|
||||||
|
```text
|
||||||
|
欢迎你,小朋友,见到你真开心
|
||||||
|
```
|
||||||
|
|
||||||
|
随后继续播报:
|
||||||
|
|
||||||
|
```text
|
||||||
|
来圆圈这里和我打个招呼吧
|
||||||
|
```
|
||||||
|
|
||||||
|
首句展示完成后停顿 2 秒,再展示第二句。该步骤不展示“来到圆圈这里”大标题。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否到达屏幕中央绿色圆环位置。
|
||||||
|
|
||||||
|
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成中央圆环位置检测后:
|
||||||
|
|
||||||
|
- 播放圆圈消失特效;
|
||||||
|
- 进入招手手势教学步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 招手教学
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
播放招手的手势引导,引导猫咪整体位于上半屏幕、字幕 UI 下方。
|
||||||
|
|
||||||
|
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否完成招手 / 摆手手势。
|
||||||
|
|
||||||
|
该动作与后续挥动左手、挥动右手需要有动作区分,但仅对肢体进行区分,不对手部细节进行区分。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成招手 / 摆手手势后,进入下一步。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 热身说明
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||||
|
```
|
||||||
|
|
||||||
|
播放完成后进入左右移动热身步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 向左一步
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
向左一步
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
屏幕中心向左一个身位,约半米的地面位置,出现新的绿色圆圈。
|
||||||
|
|
||||||
|
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否到达该绿色圆圈位置。
|
||||||
|
|
||||||
|
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成后播放鼓励语:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
同时记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。
|
||||||
|
|
||||||
|
完成后进入“回到中间来”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 回到中间来(一)
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
回到中间来
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
场地中心位置出现绿色圆圈。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否到达场地中心绿色圆圈位置。
|
||||||
|
|
||||||
|
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成后播放鼓励语:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
完成后进入“向右一步”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 向右一步
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
向右一步
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
屏幕中心向右一个身位,约半米的地面位置,出现新的绿色圆圈。
|
||||||
|
|
||||||
|
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否到达该绿色圆圈位置。
|
||||||
|
|
||||||
|
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成后播放鼓励语:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
同时记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。
|
||||||
|
|
||||||
|
完成后进入“回到中间来”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 回到中间来(二)
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
回到中间来
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
场地中心位置出现绿色圆圈。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否到达场地中心绿色圆圈位置。
|
||||||
|
|
||||||
|
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成后播放鼓励语:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
完成后进入左手挥动教学。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.8 挥动左手
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
挥动左手
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
播放伸展手臂挥动左手的手势引导。
|
||||||
|
|
||||||
|
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否完成挥动左手手势。
|
||||||
|
|
||||||
|
该手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成后播放鼓励语:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
同时记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
|
完成后进入右手挥动教学。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.9 挥动右手
|
||||||
|
|
||||||
|
#### 屏幕文字与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
挥动右手
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 画面表现
|
||||||
|
|
||||||
|
播放伸展手臂挥动右手的手势引导。
|
||||||
|
|
||||||
|
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||||
|
|
||||||
|
#### 检测逻辑
|
||||||
|
|
||||||
|
系统检测用户是否完成挥动右手手势。
|
||||||
|
|
||||||
|
该手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
用户完成后播放鼓励语:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
同时记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
|
完成后进入热身结束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.10 热身结束
|
||||||
|
|
||||||
|
#### 进入条件
|
||||||
|
|
||||||
|
用户完成挥动右手后,直接进入热身结束阶段。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
播放热身结束特效、上浮字幕和语音:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真厉害,你是我见过最聪明的小朋友
|
||||||
|
```
|
||||||
|
|
||||||
|
随后继续播放:
|
||||||
|
|
||||||
|
```text
|
||||||
|
别走开,现在开始我们的游戏吧
|
||||||
|
```
|
||||||
|
|
||||||
|
热身关结束,进入关卡选择。
|
||||||
|
|
||||||
|
## 6. 流程状态表
|
||||||
|
|
||||||
|
| 顺序 | 步骤 | 屏幕文字 / 语音 | 画面表现 | 检测目标 | 完成后反馈 |
|
||||||
|
|---:|---|---|---|---|---|
|
||||||
|
| 1 | 进入热身关 | 欢迎你,小朋友,见到你真开心;来圆圈这里和我打个招呼吧 | 中央地面绿色圆环;用户更细白色描边小人指示器;摄像头背景虚化 | 用户到达中央圆环并保持 2 秒 | 圆圈消失特效 |
|
||||||
|
| 2 | 招手教学 | 同上流程延续 | 招手手势引导;等待 3 秒可播放引导动画 | 招手 / 摆手 | 进入下一步 |
|
||||||
|
| 3 | 热身说明 | 你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 | 保持热身引导状态 | 无新增动作检测 | 进入移动热身 |
|
||||||
|
| 4 | 向左一步 | 向左一步 | 左侧约半米处绿色圆圈 | 用户到达左侧圆环并保持 2 秒 | 真棒;记录左侧空间边界 |
|
||||||
|
| 5 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
|
||||||
|
| 6 | 向右一步 | 向右一步 | 右侧约半米处绿色圆圈 | 用户到达右侧圆环并保持 2 秒 | 真棒;记录右侧空间边界 |
|
||||||
|
| 7 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
|
||||||
|
| 8 | 挥动左手 | 挥动左手 | 伸展手臂挥动左手手势引导;等待 3 秒可播放引导动画 | 挥动左手 | 真棒;记录左手挥动空间 |
|
||||||
|
| 9 | 挥动右手 | 挥动右手 | 伸展手臂挥动右手手势引导;等待 3 秒可播放引导动画 | 挥动右手 | 真棒;记录右手挥动空间;进入热身结束 |
|
||||||
|
| 10 | 热身结束 | 真厉害,你是我见过最聪明的小朋友;别走开,现在开始我们的游戏吧 | 热身结束特效 | 无新增动作检测 | 进入关卡选择 |
|
||||||
|
|
||||||
|
## 7. 固定文案与语音清单
|
||||||
|
|
||||||
|
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
|
||||||
|
|
||||||
|
```text
|
||||||
|
欢迎你,小朋友,见到你真开心
|
||||||
|
来圆圈这里和我打个招呼吧
|
||||||
|
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||||
|
向左一步
|
||||||
|
真棒
|
||||||
|
回到中间来
|
||||||
|
真棒
|
||||||
|
向右一步
|
||||||
|
真棒
|
||||||
|
回到中间来
|
||||||
|
真棒
|
||||||
|
挥动左手
|
||||||
|
真棒
|
||||||
|
挥动右手
|
||||||
|
真厉害,你是我见过最聪明的小朋友
|
||||||
|
别走开,现在开始我们的游戏吧
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 需要开发支持的识别能力
|
||||||
|
|
||||||
|
热身关当前流程需要支持以下识别能力:
|
||||||
|
|
||||||
|
1. 摄像头调用;
|
||||||
|
2. 用户识别;
|
||||||
|
3. 环境识别;
|
||||||
|
4. 用户实际位置识别;
|
||||||
|
5. 用户是否到达中央绿色圆环位置;
|
||||||
|
6. 用户是否在绿色圆环内持续保持 2 秒;
|
||||||
|
7. 用户是否到达左侧约半米绿色圆环位置;
|
||||||
|
8. 用户是否到达右侧约半米绿色圆环位置;
|
||||||
|
9. 招手 / 摆手手势识别;
|
||||||
|
10. 挥动左手识别;
|
||||||
|
11. 挥动右手识别;
|
||||||
|
12. 用户左右移动距离记录;
|
||||||
|
13. 用户挥动手臂空间记录。
|
||||||
|
|
||||||
|
## 9. 需要开发支持的表现能力
|
||||||
|
|
||||||
|
热身关当前流程需要支持以下表现能力:
|
||||||
|
|
||||||
|
1. 横屏比例显示;
|
||||||
|
2. 摄像头背景虚化;
|
||||||
|
3. 用户位置生成更细的白色描边小人指示器;
|
||||||
|
4. 屏幕中央地面绿色圆环;
|
||||||
|
5. 左侧约半米地面绿色圆环;
|
||||||
|
6. 右侧约半米地面绿色圆环;
|
||||||
|
7. 绿色圆环 2 秒选中状态;
|
||||||
|
8. 圆圈消失特效;
|
||||||
|
9. 招手手势引导;
|
||||||
|
10. 伸展手臂挥动左手手势引导;
|
||||||
|
11. 伸展手臂挥动右手手势引导;
|
||||||
|
12. 热身结束特效;
|
||||||
|
13. 上浮字幕;
|
||||||
|
14. 语音播报。
|
||||||
|
|
||||||
|
## 10. 热身数据记录要求
|
||||||
|
|
||||||
|
热身关需要记录以下数据,用于后续关卡的空间边界和行为坐标判断。
|
||||||
|
|
||||||
|
### 10.1 左右空间边界
|
||||||
|
|
||||||
|
用户完成向左一步后,记录该移动距离,作为后续关卡中的左侧空间边界。
|
||||||
|
|
||||||
|
用户完成向右一步后,记录该移动距离,作为后续关卡中的右侧空间边界。
|
||||||
|
|
||||||
|
后续关卡中,当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||||
|
|
||||||
|
后续关卡中,当用户身体主体超出安全边界线时:
|
||||||
|
|
||||||
|
1. 关卡内容暂停;
|
||||||
|
2. 屏幕虚化;
|
||||||
|
3. 屏幕中央地面出现绿色圆圈;
|
||||||
|
4. 屏幕提示文案:
|
||||||
|
|
||||||
|
```text
|
||||||
|
小朋友,要注意安全哦
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。
|
||||||
|
|
||||||
|
### 10.2 手臂挥动空间
|
||||||
|
|
||||||
|
用户完成挥动左手后,记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
|
用户完成挥动右手后,记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
|
## 11. 热身关完成条件
|
||||||
|
|
||||||
|
热身关完成条件为用户按顺序完成以下流程:
|
||||||
|
|
||||||
|
1. 到达中央圆环位置并保持 2 秒;
|
||||||
|
2. 完成招手 / 摆手手势;
|
||||||
|
3. 到达左侧约半米圆环位置并保持 2 秒;
|
||||||
|
4. 记录左侧空间边界;
|
||||||
|
5. 回到中央圆环位置并保持 2 秒;
|
||||||
|
6. 到达右侧约半米圆环位置并保持 2 秒;
|
||||||
|
7. 记录右侧空间边界;
|
||||||
|
8. 回到中央圆环位置并保持 2 秒;
|
||||||
|
9. 完成挥动左手;
|
||||||
|
10. 记录左手挥动空间;
|
||||||
|
11. 完成挥动右手;
|
||||||
|
12. 记录右手挥动空间;
|
||||||
|
13. 播放热身结束特效和结束语音;
|
||||||
|
14. 进入关卡选择。
|
||||||
|
|
||||||
|
## 12. 数据保存方式
|
||||||
|
|
||||||
|
左右空间边界和手臂挥动空间仅在当前 Demo 体验会话内保存。
|
||||||
|
|
||||||
|
这里的“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。
|
||||||
|
|
||||||
|
采用仅当前 Demo 体验会话内保存的原因:
|
||||||
|
|
||||||
|
1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。
|
||||||
|
2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。
|
||||||
|
3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。
|
||||||
|
4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。
|
||||||
|
|
||||||
|
## 13. 后续待确认事项
|
||||||
|
|
||||||
|
当前暂无待确认事项。
|
||||||
1680
docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md
Normal file
1680
docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
|||||||
|
# 宝贝识物寓教于乐模板 PRD 2026-05-11
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
新增寓教于乐内容线的创作模板:
|
||||||
|
|
||||||
|
```text
|
||||||
|
宝贝识物
|
||||||
|
```
|
||||||
|
|
||||||
|
创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。
|
||||||
|
|
||||||
|
本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。
|
||||||
|
|
||||||
|
## 2. 创作输入
|
||||||
|
|
||||||
|
创作者必须填写两个物品名称:
|
||||||
|
|
||||||
|
1. 物品 A 名称;
|
||||||
|
2. 物品 B 名称。
|
||||||
|
|
||||||
|
两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。
|
||||||
|
|
||||||
|
## 3. 生成规则
|
||||||
|
|
||||||
|
提交后生成一份宝贝识物草稿,草稿包含:
|
||||||
|
|
||||||
|
1. 模板 ID:`baby-object-match`;
|
||||||
|
2. 模板名称:`宝贝识物`;
|
||||||
|
3. 两个物品;
|
||||||
|
4. 两个物品图;
|
||||||
|
5. 游戏视觉主题包;
|
||||||
|
6. 作品标签。
|
||||||
|
|
||||||
|
素材使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`。
|
||||||
|
|
||||||
|
为降低生成成本,创作提交后只生成两张原始图片:一张 `2x2` 素材 sheet 和一张单独场景背景图。`2x2` 素材 sheet 固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒。服务端必须按固定格切图,并把物品、篮子和礼物盒转成透明 PNG。只有透明抠图后的两个物品素材才允许写入草稿 `itemAssets` 并进入游戏运行态。左右手位置指示器属于运行态默认规则,使用项目内置静态素材,不在每次创作时生成。
|
||||||
|
|
||||||
|
同一次创作还必须生成游戏视觉主题包,必需资源为背景环境、礼物盒、篮子。主题包必须继续保持寓教于乐插画风,并根据用户填写的两个物品关键词匹配主题:例如关键词偏动漫角色或玩具时,背景环境和元素可使用动漫、玩具主题;关键词偏水果时,背景环境和元素可匹配果园、自然主题;其它关键词按其语义匹配合适主题。主题包不得改变关卡玩法规则,不新增文字说明、额外按钮或额外判定规则。
|
||||||
|
|
||||||
|
视觉主题包的资源边界:
|
||||||
|
|
||||||
|
1. 背景环境图不做透明抠图,但必须保证屏幕中间、中下方和底部左右篮子区域清爽,不遮挡放大后的物品、礼物盒和篮子;
|
||||||
|
2. 礼物盒资源从 `2x2` 素材 sheet 右下格切出,输出为透明 PNG,运行态按当前礼盒视觉的 2 倍尺寸展示,素材主体必须饱满清晰;
|
||||||
|
3. 篮子资源从 `2x2` 素材 sheet 左下格切出,输出为透明 PNG,运行态按当前篮子视觉的 1.5 倍尺寸展示,左右篮子仍固定为两个物品对应选项,篮子造型资源可以复用同一张主题篮子图;篮子切图不得保留手柄、篮口或边缘处的白底描边和抠图毛边;
|
||||||
|
4. 运行态左右手位置指示器使用内置默认静态素材,姿势为用户第一人称看到的半抓握手,不随创作关键词重新生成;
|
||||||
|
5. 礼物盒打开时的烟雾弹出特效由运行态 CSS 动效兜底;历史草稿如果已有 `smoke-puff` 资源可继续兼容读取,但新生成链路不再单独生成该资源。
|
||||||
|
|
||||||
|
当前本地 Demo 阶段已接入真实 image-2 资源链路。创作提交必须成功获得 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品透明 PNG、背景环境图、礼物盒和篮子后,才能进入结果页、试玩或发布;若后端接口、登录态、VectorEngine 配置或上游生成失败,前端必须停留在生成失败状态并展示错误,不得静默回退为占位图。历史草稿中若仍存在 `generationProvider = "placeholder"` 的占位资源,结果页必须提示重新生成,试玩和发布前必须先补齐 image-2 资源。
|
||||||
|
|
||||||
|
## 4. 标签规则
|
||||||
|
|
||||||
|
发布作品必须携带精确标签:
|
||||||
|
|
||||||
|
```text
|
||||||
|
寓教于乐
|
||||||
|
```
|
||||||
|
|
||||||
|
标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育`、`动作教育`、`寓教于乐 ` 等近似标签。
|
||||||
|
|
||||||
|
宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`。
|
||||||
|
|
||||||
|
## 5. 结果页能力
|
||||||
|
|
||||||
|
结果页展示:
|
||||||
|
|
||||||
|
1. 作品名称;
|
||||||
|
2. 两个物品名称;
|
||||||
|
3. 两个物品图;
|
||||||
|
4. 标签;
|
||||||
|
5. 保存草稿;
|
||||||
|
6. 发布;
|
||||||
|
7. 试玩。
|
||||||
|
|
||||||
|
结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。
|
||||||
|
|
||||||
|
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
|
||||||
|
|
||||||
|
若草稿包含视觉主题包,运行态还必须消费该主题包中的背景环境、礼物盒和篮子资源;左右手位置指示器始终使用内置默认静态素材。旧草稿或接口失败时允许回退到当前 CSS 绘本风兜底。历史草稿中若已有 UI 装饰、左右手或烟雾弹出特效资源,运行态仅做兼容读取或忽略,不作为新链路必需资源。
|
||||||
|
|
||||||
|
## 6. 发布后体验
|
||||||
|
|
||||||
|
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
|
||||||
|
|
||||||
|
入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。
|
||||||
|
|
||||||
|
## 7. 与运行时线程的边界
|
||||||
|
|
||||||
|
本 PRD 同步约束首关运行态,已确认规则包括:
|
||||||
|
|
||||||
|
1. 进入关卡后先展示两个目标物品:物品 A 居中展示 2 秒,名称 UI 与字体约为默认大小的 2 倍,随后物品和名称飞入左侧篮子预设位置,并在飞行过程中恢复为默认大小;左侧就绪后等待 1 秒,再展示物品 B 并飞入右侧篮子预设位置;全部就绪后等待 1 秒再进入礼物盒入场。
|
||||||
|
2. 目标展示完成后,首次礼物盒自动打开并弹出首个随机物品;后续每次正确反馈完全结束后重新进入礼物盒入场。
|
||||||
|
3. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
|
||||||
|
4. 下一关按钮当前占位;
|
||||||
|
5. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
|
||||||
|
6. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
|
||||||
|
7. 礼物盒位于屏幕中下方并按当前视觉放大一倍,首次进入关卡和每次正确反馈结束后的新轮次都从上方落下后自动打开。
|
||||||
|
8. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
|
||||||
|
9. 左右篮子按当前视觉放大 50%,物品图标与篮子中心尽量对齐,物品图标下方展示对应物品名称 UI。
|
||||||
|
10. 礼物盒打开时播放烟雾特效,中央物品从烟雾特效中弹出;物品弹出后礼物盒从舞台移除。
|
||||||
|
11. 中央物品 UI 和左右篮子上方物品图标都使用固定正方形槽位,生成素材只在槽位内等比缩放;长条形物品不得拉伸外层 UI 框。
|
||||||
|
12. 运行态实时展示用户左右手位置;任意一只手先接触中央物品 UI 后,中央物品绑定并跟随该手移动,手带物品进入左侧或右侧篮子区域时代表选择对应篮子;选篮不使用动作名判定,也不再使用左手固定选左篮、右手固定选右篮的规则。
|
||||||
|
13. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
|
||||||
|
14. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
|
||||||
|
15. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
|
||||||
|
|
||||||
|
## 8. 验收
|
||||||
|
|
||||||
|
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
|
||||||
|
2. 未填写任一物品名称时不能生成草稿。
|
||||||
|
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
|
||||||
|
4. 生成草稿后包含视觉主题包,主题包含背景环境、礼物盒、篮子三类必需资源。
|
||||||
|
5. 草稿标签中始终包含精确 `寓教于乐`。
|
||||||
|
6. 发布 payload 始终包含精确 `寓教于乐`。
|
||||||
|
7. 发布完成后出现分享弹窗或发布完成状态。
|
||||||
|
8. 前端不读取或暴露 VectorEngine 密钥。
|
||||||
|
9. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
|
||||||
|
10. 运行态通过鼠标左键映射左手位置、鼠标右键映射右手位置;调试输入也必须先触碰中央物品,再拖入任一篮子完成选择。
|
||||||
|
11. 成功 20 次后出现“再来一次”和“下一关”按钮。
|
||||||
|
12. 使用长条形物品素材时,中央物品 UI 和篮子物品图标仍保持固定正方形槽位,只缩放物品本体。
|
||||||
|
13. 运行态开局先完成两个目标物品的居中展示和飞入篮子动画,之后才出现礼物盒并进入首轮随机物品。
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# 宝贝识物创作发布实现方案 2026-05-11
|
||||||
|
|
||||||
|
## 1. 范围
|
||||||
|
|
||||||
|
本方案对应第 2 线程:创作发布线程。
|
||||||
|
|
||||||
|
本线程落地:
|
||||||
|
|
||||||
|
1. 创作入口配置;
|
||||||
|
2. 模板表单;
|
||||||
|
3. 本地草稿生成 service;
|
||||||
|
4. 结果页;
|
||||||
|
5. 发布 payload 约束;
|
||||||
|
6. 本地 Demo 运行态;
|
||||||
|
7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。
|
||||||
|
|
||||||
|
本阶段运行态先做浏览器本地 Demo,并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。
|
||||||
|
|
||||||
|
## 2. 前端接入点
|
||||||
|
|
||||||
|
新增玩法 ID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baby-object-match
|
||||||
|
```
|
||||||
|
|
||||||
|
用户展示名:
|
||||||
|
|
||||||
|
```text
|
||||||
|
宝贝识物
|
||||||
|
```
|
||||||
|
|
||||||
|
工程接入文件:
|
||||||
|
|
||||||
|
1. `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
|
||||||
|
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
|
||||||
|
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
|
||||||
|
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
|
||||||
|
|
||||||
|
`src/config/newWorkEntryConfig.ts` 已迁移删除,不再作为入口事实源。`baby-object-match` 必须存在于 SpacetimeDB `creation_entry_type_config` 默认种子中,默认展示名为 `宝贝识物`、`visible=true`、`open=true`、`sortOrder=90`;前端只通过 `GET /api/creation-entry/config` 读取后端配置并在 `platformEntryCreationTypes.ts` 做展示派生。
|
||||||
|
|
||||||
|
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
|
||||||
|
|
||||||
|
新增阶段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
baby-object-match-workspace
|
||||||
|
baby-object-match-generating
|
||||||
|
baby-object-match-result
|
||||||
|
baby-object-match-runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 契约
|
||||||
|
|
||||||
|
前端共享契约放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/shared/src/contracts/edutainmentBabyObject.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
核心字段:
|
||||||
|
|
||||||
|
1. `BabyObjectMatchDraft.templateId = "baby-object-match"`;
|
||||||
|
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`;
|
||||||
|
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`;
|
||||||
|
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2` 或 `placeholder`;
|
||||||
|
5. `BabyObjectMatchDraft.visualPackage` 可选承载背景环境、礼物盒和篮子三类必需视觉资源;历史草稿中的 `ui-frame`、`smoke-puff`、`left-hand` 与 `right-hand` 仅保留运行态兼容读取或忽略;
|
||||||
|
6. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`。
|
||||||
|
|
||||||
|
## 4. Service 边界
|
||||||
|
|
||||||
|
前端 service 放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/services/edutainment-baby-object/babyObjectMatchClient.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
首版提供:
|
||||||
|
|
||||||
|
1. `createBabyObjectMatchDraft(payload)`;
|
||||||
|
2. `saveBabyObjectMatchDraft(draft)`;
|
||||||
|
3. `publishBabyObjectMatchWork(payload)`;
|
||||||
|
4. `deleteLocalBabyObjectMatchDraft(profileId)`;
|
||||||
|
5. `regenerateBabyObjectMatchDraftAssets(draft)`;
|
||||||
|
6. `hasBabyObjectMatchPlaceholderAssets(draft)`。
|
||||||
|
|
||||||
|
当前后端正式作品持久化接口未在本线程扩表落地,因此 service 仍使用本地 Demo 存储草稿和发布状态。由于 image-2 会返回多张 base64 PNG 大图,本地 Demo 草稿必须优先写入 IndexedDB `genarrative-edutainment-baby-object-drafts/drafts`,不得把完整草稿 JSON 写入 `localStorage`;`localStorage` 仅作为旧版小草稿迁移读取来源,读取后迁移到 IndexedDB 并清理旧 key,避免触发浏览器 `Storage` 配额错误。
|
||||||
|
|
||||||
|
物品图片生成已接入后端 image-2 接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/creation/edutainment/baby-object-match/assets
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"itemNames": ["苹果", "香蕉"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"itemId": "baby-object-item-1",
|
||||||
|
"itemName": "苹果",
|
||||||
|
"imageSrc": "data:image/png;base64,...",
|
||||||
|
"assetObjectId": null,
|
||||||
|
"generationProvider": "vector-engine-gpt-image-2",
|
||||||
|
"prompt": "..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visualPackage": {
|
||||||
|
"themePrompt": "...",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"assetId": "baby-object-visual-background",
|
||||||
|
"assetKind": "background",
|
||||||
|
"imageSrc": "data:image/png;base64,...",
|
||||||
|
"assetObjectId": null,
|
||||||
|
"generationProvider": "vector-engine-gpt-image-2",
|
||||||
|
"prompt": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
该接口返回从同一张 `2x2` 素材 sheet 切出的两个物品透明 PNG、礼物盒透明 PNG、篮子透明 PNG,以及单独生成的一张背景环境图。本地 Demo 阶段暂不写入 OSS 或 SpacetimeDB `asset_object`。当前创作链路必须真实拿到 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品图和必需视觉主题包后才允许进入结果页;若本地未配置 VectorEngine、登录态失效、接口返回 401/5xx、上游生成失败或响应缺少任一必需资源,前端 service 必须抛出错误并停留在生成失败状态,不得静默回退到占位图。左右手位置指示器是运行态默认静态素材,不在该接口中生成。
|
||||||
|
|
||||||
|
为了降低 image-2 调用成本,一次创作只发起两次图片生成:一次 `1024x1024` 的 `2x2` 素材 sheet,固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒;一次 `1536x1024` 的场景背景图。前端 `babyObjectMatchClient` 对该 POST 使用 10 分钟请求超时,且不做自动重试,避免第一次生成仍在后端执行时又发起第二次重复生成。后端并发启动两张图生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,避免某张图 3 分钟附近仍在生成时被后端提前断开。后端日志记录资源开始、完成和耗时,排查时优先按同一次 HTTP 请求查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成`、`宝贝识物 image-2 场景资源生成完成` 与 `VectorEngine 图片生成上游错误`。
|
||||||
|
|
||||||
|
历史本地草稿中若已保存 `generationProvider = "placeholder"` 的旧占位资源,结果页必须提示“重新生成 image-2 资源”,并禁用试玩和发布。用户点击重新生成、发布或试玩前,前端统一调用 `regenerateBabyObjectMatchDraftAssets(draft)` 补齐资源;补齐失败时保留在结果页并展示错误。
|
||||||
|
|
||||||
|
后续正式作品持久化接入时,应补齐:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/creation/edutainment/baby-object-match/drafts
|
||||||
|
PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
|
||||||
|
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
|
||||||
|
```
|
||||||
|
|
||||||
|
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
|
||||||
|
|
||||||
|
后端 `2x2` 素材 sheet prompt 约束:
|
||||||
|
|
||||||
|
1. 锁定寓教于乐板块统一的明亮卡通绘本插画风;
|
||||||
|
2. 固定四格布局:左上格物品 A,右上格物品 B,左下格篮子,右下格礼物盒;
|
||||||
|
3. 两个物品格只能围绕对应关键词生成单一主体,不生成背景、场景、人物、手、篮子、礼物盒、文字、水印或 UI;
|
||||||
|
4. 篮子格只生成一个主体饱满、开口清晰的大号篮子,不放入待分类物品,手柄和篮口镂空处不得留下白底描边或毛边;
|
||||||
|
5. 礼物盒格只生成一个主体饱满、中心构图的大号礼物盒;
|
||||||
|
6. 每格使用纯白或接近纯白背景,不绘制网格线、标签、按钮或边框;
|
||||||
|
7. 服务端按 `2x2` 固定格切图,并按单格边缘采样背景色转透明 PNG,返回的物品、篮子和礼物盒素材必须已完成透明背景后处理;
|
||||||
|
8. 篮子切图在通用透明背景处理后,还必须额外清理近白、低饱和的白底毛边,优先覆盖手柄镂空、篮口镂空和边缘残留白底;该处理仅应用于篮子格,不应用于两个物品格,避免误伤白色物品主体。
|
||||||
|
|
||||||
|
后端场景背景 prompt 约束:
|
||||||
|
|
||||||
|
1. 背景图单独生成,总风格继续锁定寓教于乐明亮卡通绘本插画风;
|
||||||
|
2. 若关键词偏动漫角色、玩具或公仔,背景环境匹配动漫、玩具主题;若关键词偏水果,匹配果园、自然主题;其它关键词按语义匹配合适主题;
|
||||||
|
3. 背景环境图使用非透明横向图,但必须保证中间、中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右篮子预留空间;
|
||||||
|
4. 背景图不画入礼物盒、篮子、物品、人物、文字或操作 UI;
|
||||||
|
5. 左右篮子的固定选项规则不受主题包影响,运行态只把 `basket` 作为篮子造型包装复用。
|
||||||
|
|
||||||
|
运行态左右手位置指示器不随创作生成。默认素材保存在 `public/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png` 与 `public/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png`,姿势沿用图1的圆形手与斜向手臂结构,并按寓教于乐明亮绘本插画风完成 image2 填色和风格化处理。后续若要替换默认手型,应更新这两个静态资源和运行态 CSS 默认变量,而不是恢复每次创作的左右手 image-2 生成。
|
||||||
|
|
||||||
|
## 5. UI 边界
|
||||||
|
|
||||||
|
工作台只展示两个必填输入和生成按钮。
|
||||||
|
|
||||||
|
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
|
||||||
|
|
||||||
|
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
|
||||||
|
|
||||||
|
## 6. 运行态边界
|
||||||
|
|
||||||
|
前端运行态放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
|
||||||
|
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`。
|
||||||
|
|
||||||
|
若草稿包含 `visualPackage`,运行态通过背景图片层、CSS 变量和图片节点消费:
|
||||||
|
|
||||||
|
1. `background`:作为舞台最底层 `ResolvedAssetImage` 背景图;存在该资源时必须关闭默认草地兜底层,避免生成场景被 CSS 草地遮住或弱化;
|
||||||
|
2. `gift-box`:替换 CSS 礼物盒主体,按旧视觉约 2 倍尺寸展示,只在礼盒入场和打开阶段存在;
|
||||||
|
3. `basket`:替换篮子主体造型,按旧视觉约 1.5 倍尺寸展示,左右两侧复用同一张主题篮子图;
|
||||||
|
4. 左右手位置指示器:始终使用运行态默认静态素材;历史草稿中若带有 `left-hand` / `right-hand` 资源,不再作为视觉包完整性或运行时皮肤来源。
|
||||||
|
|
||||||
|
左右篮子的选项 UI 必须以篮子中心线为基准居中展示:物品图标位于篮子上方,图标下方展示对应物品名称短标签,左侧固定展示草稿第一个物品,右侧固定展示草稿第二个物品。该名称标签是运行态 UI 的一部分,用于后续只看图案或只看名称的玩法变体预留,但当前不新增额外规则。
|
||||||
|
|
||||||
|
历史草稿若包含 `ui-frame` 或 `smoke-puff`,运行态继续兼容读取;新生成链路不再把这两类资源作为必需 image-2 产物。礼物盒打开烟雾特效优先使用 CSS 动效兜底,避免为了单个特效额外增加生图调用。
|
||||||
|
|
||||||
|
旧草稿或接口失败时 `visualPackage = null`,运行态继续使用现有 CSS 绘本风兜底。
|
||||||
|
|
||||||
|
中央物品 UI 与左右篮子上方物品图标必须使用固定正方形槽位,不允许因为生成物品是手机、长条玩具等窄长形状而拉伸外层 UI 框。素材图片在槽位内使用等比 `contain` 缩放,长条形状只缩小主体,不改变圆形 UI 框尺寸。
|
||||||
|
|
||||||
|
首关状态机:
|
||||||
|
|
||||||
|
1. `intro-left-showing`:物品 A 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定;
|
||||||
|
2. `intro-left-flying`:物品 A 和名称飞入左侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定;
|
||||||
|
3. `intro-left-ready`:左侧目标就绪后等待 1 秒,不接受动作判定;
|
||||||
|
4. `intro-right-showing`:物品 B 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定;
|
||||||
|
5. `intro-right-flying`:物品 B 和名称飞入右侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定;
|
||||||
|
6. `intro-right-ready`:右侧目标就绪后等待 1 秒,不接受动作判定;
|
||||||
|
7. `gift-entering`:礼物盒从上方落下入场动画阶段,不接受动作判定;首次进入该状态必须发生在两个目标展示完成后,后续正确反馈结束后直接进入该状态;
|
||||||
|
8. `gift-opening`:礼物盒打开并播放烟雾特效阶段,不接受动作判定;
|
||||||
|
9. `item-appearing`:礼物盒从舞台移除,当前物品从烟雾中出现并停稳,不接受动作判定;
|
||||||
|
10. `active`:物品彻底出现后才开放选篮判定;
|
||||||
|
11. `correct`:展示“真棒”反馈,对应篮筐播放正确特效并停顿,成功次数加 1;特效完全结束后重新进入 `gift-entering`,下一轮礼物盒从上方落下,不重复目标展示;
|
||||||
|
12. `wrong`:展示“再想一想吧”反馈,物品弹回中央;反馈结束后回到 `active`,不重新随机物品;
|
||||||
|
13. `complete`:成功次数达到 20,展示“恭喜你!小朋友!”和按钮。
|
||||||
|
|
||||||
|
动作输入:
|
||||||
|
|
||||||
|
1. 运行态实时展示左右手位置,手部位置来自 `useMocapInput` 的明确左/右手坐标;
|
||||||
|
2. 任意一只手先接触中央物品 UI 后,当前物品绑定到该手并跟随移动;
|
||||||
|
3. 绑定手带物品进入左侧篮子区域时选择左篮,进入右侧篮子区域时选择右篮;
|
||||||
|
4. 正确时沿用“真棒”反馈和对应篮筐特效,错误时物品弹回中央并回到可再次抓取状态;
|
||||||
|
5. 物品被某只手持有时,手部指示器不再压在物品图标中心;左手吸附到当前物品图标左下角,右手吸附到当前物品图标右下角,保持图案主体可读;
|
||||||
|
6. 不再使用“左手固定选左篮、右手固定选右篮”的规则,也不再使用连续横向轨迹阈值直接选篮。
|
||||||
|
|
||||||
|
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。宝贝识物优先使用 `general.limb_nodes` / `limb_nodes` 里的骨架手腕节点作为左右手指示器、抓取和选篮坐标;若当前帧没有骨架手腕,再回退到每只手的 `wrist` 挂点,最后才回退到 `hand.x / hand.y`。该策略只让 `useMocapInput` 额外暴露 `bodyJoints.leftWrist/rightWrist`,不修改全局掌心派生点规则,避免影响拼图、热身关和其它运行态。选篮不再通过 `wave_left_hand`、`wave_right_hand`、`wave` 等动作名触发;侧别为 `unknown` 的手部轨迹不参与抓取或选篮。动作判定只在 `active` 阶段开放,礼盒入场、礼盒打开、物品出现、正确反馈和错误反馈阶段收到的动作包必须清空持有状态并忽略,不允许跨阶段补判定。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:骨架 `rightWrist` / `rightHand.wrist` / `rightHand` 坐标映射玩家左手,骨架 `leftWrist` / `leftHand.wrist` / `leftHand` 坐标映射玩家右手;换算只用于展示和抓取手身份,不再决定只能选择哪一侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
|
||||||
|
|
||||||
|
开发者调试输入:
|
||||||
|
|
||||||
|
1. 鼠标左键按下并拖动:映射左手位置;
|
||||||
|
2. 鼠标右键按下并拖动:映射右手位置;
|
||||||
|
3. 调试输入同样必须先触碰中央物品,物品绑定到目标手后,再拖入左侧或右侧篮子完成选择。
|
||||||
|
|
||||||
|
运行态控制按钮不参与调试输入和选篮判定。左上角返回按钮、完成弹层按钮以及后续新增的运行态控制元素,其 `pointerdown` 不得被舞台拖拽逻辑 `preventDefault` 或指针捕获吞掉,保证游戏进行中仍可直接点击返回。
|
||||||
|
|
||||||
|
当前篮子判定仍只认篮子主体附近区域,但在上一版核心区基础上扩大约 50%;命中阈值为左篮 `x <= 0.36 && y >= 0.62`、右篮 `x >= 0.64 && y >= 0.62`,既避免物品尚未贴近篮子主体就提前判定,也避免贴到篮子边缘后仍难以命中。
|
||||||
|
|
||||||
|
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
|
||||||
|
|
||||||
|
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
|
||||||
|
|
||||||
|
## 7. 发布约束
|
||||||
|
|
||||||
|
发布前必须执行:
|
||||||
|
|
||||||
|
1. 两个物品名非空;
|
||||||
|
2. 两个物品名对应的 asset 存在;
|
||||||
|
3. 标签补齐精确 `寓教于乐`;
|
||||||
|
4. `publicationStatus` 从 `draft` 变为 `published`。
|
||||||
|
|
||||||
|
发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。
|
||||||
|
|
||||||
|
## 8. 热身关衔接
|
||||||
|
|
||||||
|
`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`。
|
||||||
|
|
||||||
|
热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。
|
||||||
|
|
||||||
|
## 9. 验收命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
|
||||||
|
cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml
|
||||||
|
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
|
||||||
|
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
|
||||||
|
npm run check:encoding
|
||||||
|
npm run typecheck
|
||||||
|
npm run build:raw
|
||||||
|
```
|
||||||
|
|
||||||
|
若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server`、`/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
# 儿童动作识别互动玩法 Demo 热身关开发规格文档
|
||||||
|
|
||||||
|
> 日期:2026-05-09
|
||||||
|
> 关联设计文档:[CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](../design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md)
|
||||||
|
> 适用范围:儿童动作识别互动玩法 Demo 固定启动热身关
|
||||||
|
> 文档性质:开发落地规格
|
||||||
|
> 说明:本文只将已确认的热身关设计内容拆解为工程可执行规格,不新增未确认的玩法、文案或视觉设计。
|
||||||
|
|
||||||
|
## 1. 开发目标
|
||||||
|
|
||||||
|
热身关作为 Demo 启动后的固定流程,需要完成以下开发目标:
|
||||||
|
|
||||||
|
1. 调用摄像头并识别用户和环境。
|
||||||
|
2. 使用横屏比例展示热身关。
|
||||||
|
3. 在屏幕中央地面生成绿色圆环,引导用户到达建议位置。
|
||||||
|
4. 将用户实际位置生成纯描边小人指示器。
|
||||||
|
5. 只对摄像头背景做虚化处理,表达隐私保护、屏蔽环境干扰,并营造空间感。
|
||||||
|
6. 按固定步骤完成站位、招手、左右移动、挥动左右手检测。
|
||||||
|
7. 记录用户左右移动距离和挥动手臂空间。
|
||||||
|
8. 将记录结果仅保存在当前 Demo 体验会话内。
|
||||||
|
9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。
|
||||||
|
10. 热身结束后进入关卡选择。
|
||||||
|
|
||||||
|
当前阶段先落浏览器本地 Demo。浏览器摄像头视频流仅作为舞台背景;热身动作检测以本地 mocap 动作数据源为准,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费 `general.body.center_norm` 身体中心、手势和左右手坐标推进站位、招手与左右手挥动步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。
|
||||||
|
|
||||||
|
## 2. 非目标范围
|
||||||
|
|
||||||
|
热身关当前不包含以下内容:
|
||||||
|
|
||||||
|
1. 不接入创作模块。
|
||||||
|
2. 不作为可配置玩法模板提供给创作者。
|
||||||
|
3. 不允许跳过步骤。
|
||||||
|
4. 不允许系统自动进入下一步。
|
||||||
|
5. 不设置动作检测最长等待时间。
|
||||||
|
6. 不做特定用户识别。
|
||||||
|
7. 不跨会话保存左右空间边界和手臂挥动空间。
|
||||||
|
8. 不对手部细节进行识别,只对肢体进行区分。
|
||||||
|
9. 本阶段不处理无硬件、拒绝摄像头、多人入镜、识别丢失等异常流程;这些问题记录为待决策事项,后续硬件与摄像头方案稳定后再重新设计。
|
||||||
|
|
||||||
|
## 3. 运行入口与流向
|
||||||
|
|
||||||
|
### 3.1 入口
|
||||||
|
|
||||||
|
用户进入 Demo 后,先进入热身关。
|
||||||
|
|
||||||
|
### 3.2 出口
|
||||||
|
|
||||||
|
用户完成热身关所有步骤后,进入关卡选择。
|
||||||
|
|
||||||
|
热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证;正式平台体验仍必须通过“宝贝识物”创作模板发布后,在寓教于乐板块进入。
|
||||||
|
|
||||||
|
### 3.3 固定流程顺序
|
||||||
|
|
||||||
|
热身关必须按照以下顺序执行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
进入热身关
|
||||||
|
↓
|
||||||
|
到达中央绿色圆环并保持 2 秒
|
||||||
|
↓
|
||||||
|
招手 / 摆手
|
||||||
|
↓
|
||||||
|
热身说明
|
||||||
|
↓
|
||||||
|
向左一步,到达左侧绿色圆环并保持 2 秒
|
||||||
|
↓
|
||||||
|
回到中间,到达中央绿色圆环并保持 2 秒
|
||||||
|
↓
|
||||||
|
向右一步,到达右侧绿色圆环并保持 2 秒
|
||||||
|
↓
|
||||||
|
回到中间,到达中央绿色圆环并保持 2 秒
|
||||||
|
↓
|
||||||
|
挥动左手
|
||||||
|
↓
|
||||||
|
挥动右手
|
||||||
|
↓
|
||||||
|
播放热身结束特效和结束语音
|
||||||
|
↓
|
||||||
|
进入关卡选择
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 页面基础表现规格
|
||||||
|
|
||||||
|
### 4.1 横屏比例
|
||||||
|
|
||||||
|
热身关需要使用横屏比例制作和展示,适用于电视屏幕、电脑屏幕等环境。
|
||||||
|
|
||||||
|
### 4.2 摄像头画面处理
|
||||||
|
|
||||||
|
用户进入热身关时调用摄像头。
|
||||||
|
|
||||||
|
摄像头画面处理要求:
|
||||||
|
|
||||||
|
1. 识别用户和环境。
|
||||||
|
2. 将用户实际位置生成纯描边小人指示器。
|
||||||
|
3. 只对摄像头背景做虚化处理。
|
||||||
|
4. 用户纯描边小人指示器用于表达用户在画面中的实际位置。
|
||||||
|
5. 背景虚化用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。
|
||||||
|
|
||||||
|
### 4.3 绿色圆环
|
||||||
|
|
||||||
|
绿色圆环用于指引用户到达指定位置。
|
||||||
|
|
||||||
|
绿色圆环出现位置包括:
|
||||||
|
|
||||||
|
1. 屏幕中央位置的地面。
|
||||||
|
2. 屏幕中心向左一个身位,约半米的地面位置。
|
||||||
|
3. 屏幕中心向右一个身位,约半米的地面位置。
|
||||||
|
|
||||||
|
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||||
|
|
||||||
|
### 4.4 绿色圆环选中状态
|
||||||
|
|
||||||
|
用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。
|
||||||
|
|
||||||
|
用户需要在绿色圆环内保持 2 秒,才算完成该位置检测。
|
||||||
|
|
||||||
|
## 5. 通用交互规则
|
||||||
|
|
||||||
|
### 5.1 不允许跳过
|
||||||
|
|
||||||
|
每个步骤都必须由用户完成。
|
||||||
|
|
||||||
|
系统不提供跳过,也不自动进入下一步。
|
||||||
|
|
||||||
|
### 5.2 引导动画规则
|
||||||
|
|
||||||
|
每个动作等待 3 秒后可以播放对应引导动画。
|
||||||
|
|
||||||
|
当前不设置最长等待时间。
|
||||||
|
|
||||||
|
### 5.3 手势检测规则
|
||||||
|
|
||||||
|
招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。
|
||||||
|
|
||||||
|
检测只区分肢体,不识别手部细节。
|
||||||
|
|
||||||
|
### 5.4 手势引导规则
|
||||||
|
|
||||||
|
挥动哪只手,就使用对应手的引导。
|
||||||
|
|
||||||
|
## 6. 状态机规格
|
||||||
|
|
||||||
|
### 6.1 状态列表
|
||||||
|
|
||||||
|
热身关至少需要支持以下流程状态:
|
||||||
|
|
||||||
|
| 状态 ID | 状态名称 | 进入条件 | 完成条件 | 下一状态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| warmup_enter | 进入热身关 | 用户进入 Demo | 摄像头调用并展示中央绿色圆环 | center_arrive |
|
||||||
|
| center_arrive | 到达中央圆环 | 中央绿色圆环出现 | 用户到达中央圆环并保持 2 秒 | wave_greeting |
|
||||||
|
| wave_greeting | 招手教学 | 中央圆环完成并播放圆圈消失特效 | 用户完成招手 / 摆手 | warmup_intro |
|
||||||
|
| warmup_intro | 热身说明 | 招手 / 摆手完成 | 播放热身说明文案与语音 | move_left |
|
||||||
|
| move_left | 向左一步 | 热身说明完成 | 用户到达左侧圆环并保持 2 秒 | return_center_1 |
|
||||||
|
| return_center_1 | 回到中间(一) | 向左一步完成 | 用户到达中央圆环并保持 2 秒 | move_right |
|
||||||
|
| move_right | 向右一步 | 回到中间(一)完成 | 用户到达右侧圆环并保持 2 秒 | return_center_2 |
|
||||||
|
| return_center_2 | 回到中间(二) | 向右一步完成 | 用户到达中央圆环并保持 2 秒 | wave_left_hand |
|
||||||
|
| wave_left_hand | 挥动左手 | 回到中间(二)完成 | 用户完成挥动左手 | wave_right_hand |
|
||||||
|
| wave_right_hand | 挥动右手 | 挥动左手完成 | 用户完成挥动右手 | warmup_finish |
|
||||||
|
| warmup_finish | 热身结束 | 挥动右手完成 | 播放热身结束特效和结束语音 | level_select |
|
||||||
|
| level_select | 关卡选择 | 热身结束 | 进入关卡选择 | - |
|
||||||
|
|
||||||
|
### 6.2 状态推进约束
|
||||||
|
|
||||||
|
1. 状态必须按顺序推进。
|
||||||
|
2. 用户未完成当前状态检测目标时,不进入下一状态。
|
||||||
|
3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。
|
||||||
|
4. 动作类状态没有最长等待时间。
|
||||||
|
5. 动作类状态等待 3 秒后可以播放对应引导动画。
|
||||||
|
6. 每个步骤进入时需要先展示本步骤文字字幕和语音播报入口,约 1 秒后再进入可交互阶段并展示绿色圆环、手势引导等检测提示。
|
||||||
|
7. 步骤完成后需要先进入完成停顿阶段,当前停顿约 0.8 秒;停顿期间保留完成反馈位置,后续可在该阶段补充完成特效或音效,再切换到下一步骤。
|
||||||
|
8. 入场等待和完成停顿阶段不消费动作完成判定,避免用户上一步残留动作直接触发下一步。
|
||||||
|
|
||||||
|
### 6.3 开发者调试输入
|
||||||
|
|
||||||
|
本地 Demo 需要支持开发者调试模式,用于无摄像头和自动化验证场景。
|
||||||
|
|
||||||
|
调试映射如下:
|
||||||
|
|
||||||
|
1. `A` 键映射用户向左移动。
|
||||||
|
2. `D` 键映射用户向右移动。
|
||||||
|
3. 鼠标左键按下并拖动映射左手轨迹。
|
||||||
|
4. 鼠标右键按下并拖动映射右手轨迹。
|
||||||
|
5. 空格键仅映射小人弹起调试动画,不触发流程推进。
|
||||||
|
|
||||||
|
调试输入只作为本地 Demo 与测试辅助,不代表正式动作识别硬件口径。正式摄像头接入后,位置和手势判断需要按摄像头硬件调教结果重新校准。
|
||||||
|
|
||||||
|
## 7. 分步骤开发规格
|
||||||
|
|
||||||
|
### 7.1 进入热身关
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
- 调用摄像头。
|
||||||
|
- 识别用户和环境。
|
||||||
|
- 屏幕中央地面显示绿色圆环。
|
||||||
|
- 用户实际位置显示为纯描边小人指示器。
|
||||||
|
- 只对摄像头背景做虚化。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
欢迎你,小朋友,见到你真开心
|
||||||
|
来圆圈这里和我打个招呼吧
|
||||||
|
```
|
||||||
|
|
||||||
|
首个 `center_arrive` 步骤不显示顶部大标题,只显示字幕文案。第一句展示后停顿 2 秒,再切换到第二句;绿色圆环仍按步骤入场节奏约 1 秒后出现。
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户到达中央绿色圆环并保持 2 秒。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
播放圆圈消失特效。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.2 招手教学
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
播放招手的手势引导。
|
||||||
|
|
||||||
|
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户完成招手 / 摆手手势。
|
||||||
|
|
||||||
|
#### 完成后
|
||||||
|
|
||||||
|
进入热身说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.3 热身说明
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 完成后
|
||||||
|
|
||||||
|
进入“向左一步”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.4 向左一步
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
屏幕中心向左一个身位,约半米的地面位置出现新的绿色圆圈。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
向左一步
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户到达左侧绿色圆环并保持 2 秒。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据记录
|
||||||
|
|
||||||
|
记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.5 回到中间来(一)
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
场地中心位置出现绿色圆圈。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
回到中间来
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户到达中央绿色圆环并保持 2 秒。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.6 向右一步
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
屏幕中心向右一个身位,约半米的地面位置出现新的绿色圆圈。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
向右一步
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户到达右侧绿色圆环并保持 2 秒。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据记录
|
||||||
|
|
||||||
|
记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.7 回到中间来(二)
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
场地中心位置出现绿色圆圈。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
回到中间来
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户到达中央绿色圆环并保持 2 秒。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.8 挥动左手
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
播放伸展手臂挥动左手的手势引导。
|
||||||
|
|
||||||
|
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
挥动左手
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户完成挥动左手。
|
||||||
|
|
||||||
|
当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头右侧手对应用户左手。挥动左手不是普通横向轨迹检测,而是用于确认现实环境中用户左侧手臂打开空间足够和安全。
|
||||||
|
|
||||||
|
完成条件必须同时满足:
|
||||||
|
|
||||||
|
1. 使用用户身体左手轨迹。
|
||||||
|
2. 手腕在左肩外侧达到最小外展距离。
|
||||||
|
3. 手腕不能处于自然下垂低位。
|
||||||
|
4. 最近连续有效帧中,手臂存在足够上下摆动幅度。
|
||||||
|
5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。
|
||||||
|
6. 至少出现一次上下摆动方向变化。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据记录
|
||||||
|
|
||||||
|
记录用户挥动左手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.9 挥动右手
|
||||||
|
|
||||||
|
#### 展示内容
|
||||||
|
|
||||||
|
播放伸展手臂挥动右手的手势引导。
|
||||||
|
|
||||||
|
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||||
|
|
||||||
|
#### 文案与语音
|
||||||
|
|
||||||
|
```text
|
||||||
|
挥动右手
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 检测目标
|
||||||
|
|
||||||
|
用户完成挥动右手。
|
||||||
|
|
||||||
|
当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头左侧手对应用户右手。挥动右手不是普通横向轨迹检测,而是用于确认现实环境中用户右侧手臂打开空间足够和安全。
|
||||||
|
|
||||||
|
完成条件必须同时满足:
|
||||||
|
|
||||||
|
1. 使用用户身体右手轨迹。
|
||||||
|
2. 手腕在右肩外侧达到最小外展距离。
|
||||||
|
3. 手腕不能处于自然下垂低位。
|
||||||
|
4. 最近连续有效帧中,手臂存在足够上下摆动幅度。
|
||||||
|
5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。
|
||||||
|
6. 至少出现一次上下摆动方向变化。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
```text
|
||||||
|
真棒
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 数据记录
|
||||||
|
|
||||||
|
记录用户挥动右手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7.10 热身结束
|
||||||
|
|
||||||
|
#### 进入条件
|
||||||
|
|
||||||
|
用户完成挥动右手后,直接进入热身结束阶段。
|
||||||
|
|
||||||
|
#### 完成反馈
|
||||||
|
|
||||||
|
播放热身结束特效、上浮字幕和语音:
|
||||||
|
|
||||||
|
```text
|
||||||
|
真厉害,你是我见过最聪明的小朋友
|
||||||
|
别走开,现在开始我们的游戏吧
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 完成后
|
||||||
|
|
||||||
|
进入关卡选择。
|
||||||
|
|
||||||
|
## 8. 当前 Demo 体验会话数据
|
||||||
|
|
||||||
|
### 8.1 保存范围
|
||||||
|
|
||||||
|
以下数据仅在当前 Demo 体验会话内保存:
|
||||||
|
|
||||||
|
1. 左侧空间边界。
|
||||||
|
2. 右侧空间边界。
|
||||||
|
3. 左手挥动空间。
|
||||||
|
4. 右手挥动空间。
|
||||||
|
|
||||||
|
当前 Demo 体验会话数据需要满足:
|
||||||
|
|
||||||
|
1. 用户刷新产品或退出产品后失效。
|
||||||
|
2. 用户只关闭当前游戏关卡并重新进入时,可以直接来到开始游戏界面,不强制重复热身。
|
||||||
|
3. 首版可使用前端运行时内存或同等生命周期容器保存;不得跨产品刷新持久化保存。
|
||||||
|
|
||||||
|
### 8.2 当前 Demo 体验会话定义
|
||||||
|
|
||||||
|
“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。
|
||||||
|
|
||||||
|
当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。
|
||||||
|
|
||||||
|
### 8.3 仅会话内保存原因
|
||||||
|
|
||||||
|
采用仅当前 Demo 体验会话内保存的原因:
|
||||||
|
|
||||||
|
1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。
|
||||||
|
2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。
|
||||||
|
3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。
|
||||||
|
4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。
|
||||||
|
|
||||||
|
## 9. 后续关卡安全边界使用规则
|
||||||
|
|
||||||
|
后续关卡需要使用热身关记录的左右空间边界进行安全判断。
|
||||||
|
|
||||||
|
### 9.1 覆盖安全边界线
|
||||||
|
|
||||||
|
当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||||
|
|
||||||
|
### 9.2 超出安全边界线
|
||||||
|
|
||||||
|
当用户身体主体超出安全边界线时:
|
||||||
|
|
||||||
|
1. 关卡内容暂停。
|
||||||
|
2. 屏幕虚化。
|
||||||
|
3. 屏幕中央地面出现绿色圆圈。
|
||||||
|
4. 屏幕提示文案:
|
||||||
|
|
||||||
|
```text
|
||||||
|
小朋友,要注意安全哦
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。
|
||||||
|
|
||||||
|
## 10. 识别能力清单
|
||||||
|
|
||||||
|
热身关需要接入或实现以下识别能力:
|
||||||
|
|
||||||
|
1. 摄像头调用。
|
||||||
|
2. 用户识别。
|
||||||
|
3. 环境识别。
|
||||||
|
4. 用户实际位置识别。
|
||||||
|
5. 用户是否到达中央绿色圆环位置。
|
||||||
|
6. 用户是否在绿色圆环内持续保持 2 秒。
|
||||||
|
7. 用户是否到达左侧约半米绿色圆环位置。
|
||||||
|
8. 用户是否到达右侧约半米绿色圆环位置。
|
||||||
|
9. 招手 / 摆手手势识别。
|
||||||
|
10. 挥动左手识别。
|
||||||
|
11. 挥动右手识别。
|
||||||
|
12. 用户左右移动距离记录。
|
||||||
|
13. 用户挥动手臂空间记录。
|
||||||
|
14. 用户身体主体覆盖安全边界线判断。
|
||||||
|
15. 用户身体主体超出安全边界线判断。
|
||||||
|
16. 用户回到中心绿色圆环并保持 2 秒判断。
|
||||||
|
|
||||||
|
## 11. 表现能力清单
|
||||||
|
|
||||||
|
热身关需要实现以下表现能力:
|
||||||
|
|
||||||
|
1. 横屏比例显示。
|
||||||
|
2. 摄像头背景虚化。
|
||||||
|
3. 用户位置生成纯描边小人指示器。
|
||||||
|
4. 屏幕中央地面绿色圆环。
|
||||||
|
5. 左侧约半米地面绿色圆环。
|
||||||
|
6. 右侧约半米地面绿色圆环。
|
||||||
|
7. 绿色圆环 2 秒选中状态。
|
||||||
|
8. 圆圈消失特效。
|
||||||
|
9. 招手手势引导。
|
||||||
|
10. 伸展手臂挥动左手手势引导。
|
||||||
|
11. 伸展手臂挥动右手手势引导。
|
||||||
|
12. 热身结束特效。
|
||||||
|
13. 上浮字幕。
|
||||||
|
14. 语音播报。
|
||||||
|
15. 安全边界虚影提醒。
|
||||||
|
16. 关卡暂停时屏幕虚化。
|
||||||
|
17. 关卡暂停时屏幕中央地面绿色圆圈。
|
||||||
|
18. 关卡暂停提示文案。
|
||||||
|
|
||||||
|
角色剪影、绿色圆环、虚影提醒、圆圈消失特效、手势引导动画和热身结束特效的正式视觉资源将通过 gpt-image-2 设计和生成。本地 Demo 阶段可以先使用 CSS、Canvas 或临时占位资源实现相同交互位置与状态,不把占位资源写死为正式资产。
|
||||||
|
|
||||||
|
## 12. 固定文案与语音清单
|
||||||
|
|
||||||
|
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
|
||||||
|
|
||||||
|
正式语音播报后续接入语音播报功能接口。本地 Demo 阶段保留播报适配层与调用点,可先只展示文字,不强制生成或播放正式语音资产。
|
||||||
|
|
||||||
|
```text
|
||||||
|
欢迎你,小朋友,见到你真开心
|
||||||
|
来圆圈这里和我打个招呼吧
|
||||||
|
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||||
|
向左一步
|
||||||
|
真棒
|
||||||
|
回到中间来
|
||||||
|
真棒
|
||||||
|
向右一步
|
||||||
|
真棒
|
||||||
|
回到中间来
|
||||||
|
真棒
|
||||||
|
挥动左手
|
||||||
|
真棒
|
||||||
|
挥动右手
|
||||||
|
真厉害,你是我见过最聪明的小朋友
|
||||||
|
别走开,现在开始我们的游戏吧
|
||||||
|
小朋友,要注意安全哦
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. 开发验收标准
|
||||||
|
|
||||||
|
### 13.1 热身流程验收
|
||||||
|
|
||||||
|
1. 用户进入 Demo 后先进入热身关。
|
||||||
|
2. 热身关使用横屏比例展示。
|
||||||
|
3. 摄像头被调用。
|
||||||
|
4. 用户位置显示为纯描边小人指示器。
|
||||||
|
5. 摄像头背景被虚化。
|
||||||
|
6. 中央、左侧、右侧绿色圆环可以按流程出现。
|
||||||
|
7. 用户到达每个绿色圆环后,需要保持 2 秒才算完成。
|
||||||
|
8. 每个步骤未完成时不能跳过,也不能自动进入下一步。
|
||||||
|
9. 动作等待 3 秒后可以播放对应引导动画。
|
||||||
|
10. 所有固定文案可以展示并语音播报。
|
||||||
|
11. 完成全部热身步骤后进入关卡选择。
|
||||||
|
|
||||||
|
### 13.2 数据记录验收
|
||||||
|
|
||||||
|
1. 完成向左一步后,可以记录左侧空间边界。
|
||||||
|
2. 完成向右一步后,可以记录右侧空间边界。
|
||||||
|
3. 完成挥动左手后,可以记录左手挥动空间。
|
||||||
|
4. 完成挥动右手后,可以记录右手挥动空间。
|
||||||
|
5. 以上数据仅在当前 Demo 体验会话内保存。
|
||||||
|
6. 重新进入 Demo 后,不沿用上一次热身记录,需要重新完成热身关。
|
||||||
|
|
||||||
|
### 13.3 后续关卡安全边界验收
|
||||||
|
|
||||||
|
1. 用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||||
|
2. 用户身体主体超出安全边界线时,关卡内容暂停。
|
||||||
|
3. 关卡暂停时,屏幕虚化。
|
||||||
|
4. 关卡暂停时,屏幕中央地面出现绿色圆圈。
|
||||||
|
5. 关卡暂停时,展示提示文案:
|
||||||
|
|
||||||
|
```text
|
||||||
|
小朋友,要注意安全哦
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 用户回到中心绿色圆圈并保持 2 秒后,游戏内容继续。
|
||||||
|
|
||||||
|
## 14. 不确定项与补充确认
|
||||||
|
|
||||||
|
当前需求已明确本文所需的热身关开发规格。
|
||||||
|
|
||||||
|
以下内容作为待决策事项保留,后续硬件、摄像头和正式关卡设计稳定后再补充:
|
||||||
|
|
||||||
|
1. 具体接入的动作识别 SDK、硬件接口和摄像头接口。
|
||||||
|
2. 无硬件、摄像头拒绝授权、多人入镜、识别不到用户、跟踪丢失等异常流程。
|
||||||
|
3. 小人指示器、圆环、虚影提醒、特效、手势引导动画的正式资源文件命名。
|
||||||
|
4. 绿色圆环、小人指示器、安全边界在线性空间或屏幕坐标中的正式计算公式。
|
||||||
|
5. 正式关卡选择页与后续游戏关卡的具体页面结构。
|
||||||
|
|
||||||
|
## 15. 第 3 项本地 Demo 落地记录
|
||||||
|
|
||||||
|
本地浏览器 Demo 入口已落在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/child-motion-demo
|
||||||
|
```
|
||||||
|
|
||||||
|
当前实现范围:
|
||||||
|
|
||||||
|
1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。
|
||||||
|
2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。
|
||||||
|
3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo。
|
||||||
|
4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。
|
||||||
|
5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。
|
||||||
|
|
||||||
|
当前调试输入:
|
||||||
|
|
||||||
|
1. `A` 键映射用户向左移动,松开后回到中心。
|
||||||
|
2. `D` 键映射用户向右移动,松开后回到中心。
|
||||||
|
3. 鼠标左键按下并拖动映射左手轨迹。
|
||||||
|
4. 鼠标右键按下并拖动映射右手轨迹。
|
||||||
|
5. 空格键仅映射小人弹起调试动画,不触发流程推进。
|
||||||
|
6. 调试输入只在步骤可交互阶段触发步骤完成;步骤入场字幕阶段和完成停顿阶段会忽略完成判定,便于观察节奏和后续补充特效。
|
||||||
|
|
||||||
|
当前硬件和动作检测接口接入:
|
||||||
|
|
||||||
|
1. 浏览器摄像头视频流已接入舞台背景。
|
||||||
|
2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。
|
||||||
|
3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新小人指示器横向位置并完成圆环保持检测。
|
||||||
|
4. 身体中心横向坐标进入小人指示器前必须做输入稳定化处理:先 clamp 到 `0..1`,再使用小幅死区、低通阻尼和单包最大步长限制,避免硬件噪声造成角色左右误判、画面抽搐或视觉上的忽大忽小。当前实现参数为死区 `0.012`、阻尼系数 `0.28`、单包最大步长 `0.035`;位置保持检测使用稳定化后的角色坐标。
|
||||||
|
5. 小人指示器渲染需要把水平位移和跳跃表现拆开:外层只负责横向定位,内层资源只负责轮廓图和跳跃位移,避免 `left` 与 `transform` 同时抢占导致资源重采样抖动。
|
||||||
|
6. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]`、`leftHand/rightHand`、`left_hand/right_hand` 读取左右手坐标。
|
||||||
|
7. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark,再退回 hand 直出坐标。
|
||||||
|
8. 热身舞台需要复用宝贝识物运行态的左右手指示器资源与样式,显示用户当前左右手位置;mocap 显示同样按摄像头视角换算成用户身体视角,用户左手使用 camera-right,用户右手使用 camera-left。手部指示器优先使用 `general.limb_nodes` / `limb_nodes` 中换算后同侧的 `right_wrist` / `left_wrist` 骨架手腕节点,骨架手腕缺失时再回退到手部 landmark 的 `wrist`,最后回退到 hand 直出坐标,避免手掌识别不稳时指示器跟随掌心抽搐。鼠标左键 / 右键调试时也同步显示同款左手 / 右手指示器。
|
||||||
|
9. `wave_greeting` 只消费左手、右手或未知单手的连续横向挥手轨迹,不再使用 `wave`、`hand_wave`、`open_palm`、张手状态或动作名直接完成判定;进入轨迹判定前必须先满足抬手有效区:优先使用 `hands[].landmarks.wrist` 与 `general.limb_nodes` 的同侧 `*_elbow` / `*_shoulder` 判断,当前阈值为 `wrist.y <= elbow.y + 0.04`,缺少肘部时使用 `wrist.y <= shoulder.y + 0.08`;缺少同侧肘部和肩膀参考时不允许招呼通过,不再使用身体中心兜底判断抬手。轨迹阈值为至少 5 个连续抬手点,横向 `x` 范围差值不小于 `0.075`,且至少出现 1 次横向方向变化,避免“手刚露出画面”或“手自然下垂抖动”被误判为招手。
|
||||||
|
10. `wave_greeting` 完成后直接进入 `warmup_intro` 的“准备热身 / 你好呀小朋友...”字幕节奏,不显示“真棒”完成飘字;后续位置移动、左右手挥动等正式热身步骤仍保留“真棒”反馈。
|
||||||
|
11. `wave_left_hand` 和 `wave_right_hand` 只消费用户身体侧对应手的连续坐标轨迹,不再使用动作名、张手状态或 primary hand 兜底完成判定;本地 mocap handedness 当前按摄像头视角输出,因此用户左手使用 camera-right,用户右手使用 camera-left。完成判定必须同时满足对应肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化,当前阈值为连续外展点不少于 5 个、横向 `x` 范围不小于 `0.055`、垂直 `y` 范围不小于 `0.08`、肩腕角度范围不小于 `28°`、外展距离不小于 `0.12`、手腕相对肩膀外侧距离不小于 `0.1`;后续以真实体验结果继续调参。
|
||||||
|
12. 挥动右手完成后直接进入 `warmup_finish`,不再要求原地跳跃检测或记录跳跃空间。
|
||||||
|
13. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径;其中 `Space` 只播放小人弹起调试动画,不推进热身流程。
|
||||||
|
|
||||||
|
当前未接入但已保留边界:
|
||||||
|
|
||||||
|
1. 正式语音播报接口暂不接入,当前先展示热身文案。
|
||||||
|
2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接。
|
||||||
|
|
||||||
|
## 16. 当前视觉资产与生图口径补充
|
||||||
|
|
||||||
|
儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台:
|
||||||
|
|
||||||
|
1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。
|
||||||
|
2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。
|
||||||
|
3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。
|
||||||
|
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`。
|
||||||
|
5. 当前已生成并接入以下正式 Demo 资源:
|
||||||
|
- `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。
|
||||||
|
- `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。
|
||||||
|
- `public/child-motion-demo/picture-book-ground-ring-v3.png`:已按透视绘制的浅蓝与暖黄色地面椭圆指示环,和草地材质做明显区分,CSS 只等比缩放。
|
||||||
|
- `public/child-motion-demo/picture-book-character-outline-v4.png`:用户位置小人指示器,基于 v2 本地后处理为更细的白色描边样式,中间完全透明,耳朵、手指、脚趾等细节已弱化;页面显示尺寸相对上一版放大 50%。
|
||||||
|
- `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。
|
||||||
|
- `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。
|
||||||
|
- `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。
|
||||||
|
- `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。
|
||||||
|
- `public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png`:招手阶段中央猫咪身体底座资源,按可动纸偶结构只包含猫头和短身体;v7 基于 v6 局部去除了身体左右两侧不协调的小圆点,不再和旧猫头、胸口或猫爪资源叠加。
|
||||||
|
- `public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png`:招手阶段左右独立手臂资源,也用于左右手阶段单手提示;网页用同一拆件承接挥手摆动动画,但左手阶段使用 `picture-book-wave-cat-paw-left-v1.png`,右手阶段使用 `picture-book-wave-cat-paw-right-v1.png`,不再依赖同图镜像猜方向。v7 重点修正猫爪掌面朝向,末端圆猫爪必须正面对玩家,避免看起来朝内或朝向角色自己。
|
||||||
|
6. v2 资源按最终用途拆分,CSS 必须按资源原始比例、`aspect-ratio` 或 `background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板,也禁止把底部草坪扩展成覆盖角色脚下的大色块。
|
||||||
|
7. 猫咪招手引导拆件必须由 `.child-motion-gesture-guide__wave-cat` 父级统一承接上下浮动;身体层不再单独 bob,左右手臂只在同一父级坐标系内围绕肩部挂点旋转,并且手臂层级必须位于身体层前方。招手阶段使用独立全屏定位容器,猫咪整体放在上半屏幕、顶部字幕 UI 下方,避免压到地面小人指示器和圆环。当前 v7 资源的手臂贴近身体外缘摆放,左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴;左右手臂同步摆动,挥手动画周期为 `0.47s`,相对上一版约提速 50%,避免身体和手臂在动画过程中产生相对位移或压住胸口主体。8/11 挥动左手和 9/11 挥动右手阶段的单手猫猫手臂提示需要与打招呼双臂区分:不再使用左右招手式摆动,而是显示单侧外展安全弧线,并让猫爪沿外侧弧线做上下摆动,和“手臂外展、上下摆动幅度、角度变化、方向变化”的判定规则保持一致。
|
||||||
|
8. 猫咪招手引导资源使用 `cat-guide` 透明后处理:先由 image-2 生成品红底源图,再通过边缘背景连通区域去背,避免把浅粉、淡橘和暖棕主体误删。源图只保存在 `tmp/child-motion-demo-assets/`,正式页面只引用 `public/child-motion-demo/` 下的最终 PNG。
|
||||||
|
9. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only <asset-id>` 小批量生成;仅调整透明去背、裁切、画布归一、品红边缘、`character-outline-only-v3` / `character-outline-white-v4` 或 `wave-cat-body-guide-v7` 这种基于正式资源的局部后处理时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用本地源图,不额外请求 image-2;不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。
|
||||||
|
|
||||||
|
已执行的定向验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eslint src/components/child-motion-demo/ChildMotionWarmupDemo.tsx src/components/child-motion-demo/childMotionWarmupModel.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/services/child-motion-demo/index.ts src/ChildMotionDemoApp.tsx src/routing/appRoutes.tsx src/routing/appRoutes.test.ts --ext .ts,.tsx --max-warnings 0
|
||||||
|
npx vitest run src/components/child-motion-demo/childMotionWarmupModel.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
|
||||||
|
npm run check:encoding
|
||||||
|
```
|
||||||
@@ -40,6 +40,12 @@ server-rs + Axum + SpacetimeDB
|
|||||||
npm run check:server-rs-ddd
|
npm run check:server-rs-ddd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `spacetime-client` mapper 组织
|
||||||
|
|
||||||
|
`server-rs/crates/spacetime-client/src/mapper.rs` 只作为聚合入口,负责声明 `src/mapper/` 下的领域子模块并 re-export 原有 record / mapper 能力;不要在该文件继续堆叠大段映射实现。
|
||||||
|
|
||||||
|
当前子模块按调用领域拆分:`assets.rs`、`auth.rs`、`runtime.rs`、`runtime_profile.rs`、`custom_world.rs`、`puzzle.rs`、`match3d.rs`、`square_hole.rs`、`visual_novel.rs`、`big_fish.rs`、`story.rs`、`ai.rs`、`bark_battle.rs`、`combat.rs`、`inventory.rs`、`npc.rs`,跨领域轻量 helper 和共享 record 统一放在 `common.rs`。该拆分只改变 `spacetime-client` 文件组织,不改变 SpacetimeDB schema、生成绑定、procedure result 契约或外部 DTO;后续新增 mapper 时优先落到对应领域子模块,不得重新引入跨层 JSON 字符串兼容结构。
|
||||||
|
|
||||||
## API 路由分组
|
## API 路由分组
|
||||||
|
|
||||||
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
||||||
@@ -73,6 +79,33 @@ npm run check:server-rs-ddd
|
|||||||
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。
|
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。
|
||||||
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
|
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
|
||||||
4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
|
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` 布局。
|
||||||
|
|
||||||
|
拼图 `api-server` 内部拆分:
|
||||||
|
|
||||||
|
- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。
|
||||||
|
- `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 和初始资产就绪校验。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||||
|
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
|
||||||
|
|
||||||
|
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
|
||||||
|
|
||||||
|
抓大鹅 Match3D `api-server` 内部拆分:
|
||||||
|
|
||||||
|
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
||||||
|
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
||||||
|
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
||||||
|
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
||||||
|
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
||||||
|
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品 sheet 生成、绿幕 / 近白底透明化、切图、append / replace / delete / sort / merge 和素材持久化。
|
||||||
|
- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 承接 VectorEngine Gemini 请求体、响应解析、base64 图片下载和上游错误归一。
|
||||||
|
- `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper;`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。
|
||||||
|
|
||||||
|
该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*`、`/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`。
|
||||||
|
|
||||||
生成资产 Adapter 规则:
|
生成资产 Adapter 规则:
|
||||||
|
|
||||||
@@ -84,14 +117,19 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
## SpacetimeDB schema 变更规则
|
## SpacetimeDB schema 变更规则
|
||||||
|
|
||||||
1. 任何 table、reducer、procedure、row shape 或 bindings 变化,都必须同步 `server-rs/crates/spacetime-module/src/migration.rs`、本文件表目录和生成绑定。
|
1. 任何 table、view、reducer、procedure、row shape 或 bindings 变化,都必须同步本文件表 / view 目录和生成绑定;真实 table 变化还必须同步 `server-rs/crates/spacetime-module/src/migration.rs`,view 属于派生投影,不写入迁移导入导出表清单。
|
||||||
2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`。
|
2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`。
|
||||||
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
||||||
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>` 加 `#[default(None::<Vec<T>>)]`,业务层归一为空数组。
|
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<Vec<T>>` 加 `#[default(None::<Vec<T>>)]`,业务层归一为空数组。
|
||||||
5. 修改后运行:
|
5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor,列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
|
||||||
|
6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server` 的 `spacetime-client` 长期订阅后读本地 cache。短期不把作品列表整体交给浏览器前端直接订阅;不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍可走 procedure / reducer。中期如要让前端可选直连订阅,只能新增或统一稳定的专用 public read model,例如 `public_work_gallery_entry`,并保持字段、排序键、公开权限和降级语义由后端投影定义;前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 仍由 `api-server` BFF 维持。
|
||||||
|
7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
|
||||||
|
8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
|
||||||
|
9. 修改后运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run spacetime:generate
|
npm run spacetime:generate
|
||||||
|
npm run check:spacetime-runtime-access
|
||||||
npm run check:spacetime-schema
|
npm run check:spacetime-schema
|
||||||
npm run check:server-rs-ddd
|
npm run check:server-rs-ddd
|
||||||
```
|
```
|
||||||
@@ -222,7 +260,7 @@ npm run check:server-rs-ddd
|
|||||||
### `battle_state`
|
### `battle_state`
|
||||||
|
|
||||||
- Rust 结构体:`BattleState`
|
- Rust 结构体:`BattleState`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `big_fish_agent_message`
|
### `big_fish_agent_message`
|
||||||
|
|
||||||
@@ -238,6 +276,7 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`BigFishCreationSession`
|
- Rust 结构体:`BigFishCreationSession`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
|
||||||
|
- 索引:`by_big_fish_session_owner_user_id`、`by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。
|
||||||
|
|
||||||
### `big_fish_event`
|
### `big_fish_event`
|
||||||
|
|
||||||
@@ -249,10 +288,17 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`BigFishRuntimeRun`
|
- Rust 结构体:`BigFishRuntimeRun`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
|
||||||
|
|
||||||
|
### SpacetimeDB view:`big_fish_gallery_view`
|
||||||
|
|
||||||
|
- Rust view:`big_fish_gallery_view`
|
||||||
|
- 返回类型:`Vec<BigFishWorkSummarySnapshot>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/big_fish/session.rs`
|
||||||
|
- 说明:大鱼吃小鱼公开广场列表投影,只从 `Published` creation session 组装公开卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM big_fish_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` 后,从本地 cache 构造 `/api/runtime/big-fish/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_big_fish_works` procedure;个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
|
||||||
|
|
||||||
### `chapter_progression`
|
### `chapter_progression`
|
||||||
|
|
||||||
- Rust 结构体:`ChapterProgression`
|
- Rust 结构体:`ChapterProgression`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `creation_entry_config`
|
### `creation_entry_config`
|
||||||
|
|
||||||
@@ -267,37 +313,38 @@ npm run check:server-rs-ddd
|
|||||||
### `custom_world_agent_message`
|
### `custom_world_agent_message`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldAgentMessage`
|
- Rust 结构体:`CustomWorldAgentMessage`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
|
||||||
### `custom_world_agent_operation`
|
### `custom_world_agent_operation`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldAgentOperation`
|
- Rust 结构体:`CustomWorldAgentOperation`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
|
||||||
### `custom_world_agent_session`
|
### `custom_world_agent_session`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldAgentSession`
|
- Rust 结构体:`CustomWorldAgentSession`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
|
||||||
### `custom_world_draft_card`
|
### `custom_world_draft_card`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldDraftCard`
|
- Rust 结构体:`CustomWorldDraftCard`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
|
||||||
### `custom_world_gallery_entry`
|
### `custom_world_gallery_entry`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldGalleryEntry`
|
- Rust 结构体:`CustomWorldGalleryEntry`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
- 作用:自定义世界公开作品列表读模型。`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`,`/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。
|
||||||
|
|
||||||
### `custom_world_profile`
|
### `custom_world_profile`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldProfile`
|
- Rust 结构体:`CustomWorldProfile`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
|
||||||
### `custom_world_session`
|
### `custom_world_session`
|
||||||
|
|
||||||
- Rust 结构体:`CustomWorldSession`
|
- Rust 结构体:`CustomWorldSession`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||||
|
|
||||||
### `database_migration_import_chunk`
|
### `database_migration_import_chunk`
|
||||||
|
|
||||||
@@ -312,7 +359,7 @@ npm run check:server-rs-ddd
|
|||||||
### `inventory_slot`
|
### `inventory_slot`
|
||||||
|
|
||||||
- Rust 结构体:`InventorySlot`
|
- Rust 结构体:`InventorySlot`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `match3d_agent_message`
|
### `match3d_agent_message`
|
||||||
|
|
||||||
@@ -334,15 +381,22 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`Match3DWorkProfileRow`
|
- Rust 结构体:`Match3DWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
|
||||||
|
|
||||||
|
### SpacetimeDB view:`match_3_d_gallery_view`
|
||||||
|
|
||||||
|
- Rust view:`match3d_gallery_view`
|
||||||
|
- 返回类型:`Vec<Match3DGalleryViewRow>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/match3d.rs`
|
||||||
|
- 说明:抓大鹅公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM match_3_d_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` 后,从本地 cache 构造 `/api/runtime/match3d/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_match3d_works` procedure;个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
|
||||||
|
|
||||||
### `npc_state`
|
### `npc_state`
|
||||||
|
|
||||||
- Rust 结构体:`NpcState`
|
- Rust 结构体:`NpcState`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `player_progression`
|
### `player_progression`
|
||||||
|
|
||||||
- Rust 结构体:`PlayerProgression`
|
- Rust 结构体:`PlayerProgression`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `profile_dashboard_state`
|
### `profile_dashboard_state`
|
||||||
|
|
||||||
@@ -460,15 +514,64 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`PuzzleWorkProfileRow`
|
- Rust 结构体:`PuzzleWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
|
||||||
|
### SpacetimeDB view:`puzzle_gallery_view`
|
||||||
|
|
||||||
|
- Rust view:`puzzle_gallery_view`
|
||||||
|
- 返回类型:`Vec<PuzzleWorkProfile>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
- 说明:拼图广场公开详情兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;公开列表主路径不再订阅该 view。
|
||||||
|
|
||||||
|
### SpacetimeDB view:`puzzle_gallery_card_view`
|
||||||
|
|
||||||
|
- Rust view:`puzzle_gallery_card_view`
|
||||||
|
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||||
|
- 说明:拼图广场公开列表卡片投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_card_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||||
|
|
||||||
|
### 拼图公开列表 HTTP 窗口缓存
|
||||||
|
|
||||||
|
- 接口:`GET /api/runtime/puzzle/gallery`
|
||||||
|
- 响应契约:保留 `items` 字段兼容旧前端;当前 `items` 只返回前 10 个完整卡片,新增 `previewRefs` 返回后 10 个 `workId/profileId` 引用,并返回 `hasMore`、`nextCursor` 与 `totalCount`。
|
||||||
|
- 缓存策略:`api-server` 在 `PuzzleGalleryCache` 中缓存最终 `PuzzleGalleryResponse` 的预序列化 data JSON。缓存 miss / 过期时单飞重建,避免并发请求重复排序、映射、DTO 深拷贝和 `serde_json::Value` 树构造;开启响应 envelope 时只按请求拼接轻量 meta,缓存短 TTL 刷新 `recentPlayCount7d`,后台 cleanup task 周期清理超过最大空闲窗口的旧响应。OTLP 通过 `genarrative.puzzle_gallery.cache.*`、`genarrative.spacetime.read.*`、`genarrative.http.server.response_bodies.in_flight` 和 `genarrative.http.server.request_permits.available` 区分缓存重建、SpacetimeDB 本地订阅读、响应 body 生命周期和 HTTP 背压状态。
|
||||||
|
- 详情路径:公开详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理;前端拿到 `previewRefs` 后如果需要展开更多内容,应优先使用后续列表窗口能力或详情 cache,不要把自动详情预取变成新的 procedure 热点。
|
||||||
|
|
||||||
|
### api-server 长期订阅读模型
|
||||||
|
|
||||||
|
`spacetime-client` 建立每个池连接时会等待下列订阅初始同步:
|
||||||
|
|
||||||
|
- `SELECT * FROM puzzle_gallery_card_view`
|
||||||
|
- `SELECT * FROM custom_world_gallery_entry`
|
||||||
|
- `SELECT * FROM match_3_d_gallery_view`
|
||||||
|
- `SELECT * FROM square_hole_gallery_view`
|
||||||
|
- `SELECT * FROM visual_novel_gallery_view`
|
||||||
|
- `SELECT * FROM big_fish_gallery_view`
|
||||||
|
|
||||||
|
下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底:
|
||||||
|
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'`
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'`
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'`
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
|
||||||
|
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
|
||||||
|
- `SELECT * FROM creation_entry_config`
|
||||||
|
- `SELECT * FROM creation_entry_type_config`
|
||||||
|
|
||||||
|
拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer;不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
|
||||||
|
|
||||||
|
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置;cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
|
||||||
|
|
||||||
|
未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile`、`custom_world_profile` 等源表后自行拼装列表;BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。
|
||||||
|
|
||||||
### `quest_log`
|
### `quest_log`
|
||||||
|
|
||||||
- Rust 结构体:`QuestLog`
|
- Rust 结构体:`QuestLog`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `quest_record`
|
### `quest_record`
|
||||||
|
|
||||||
- Rust 结构体:`QuestRecord`
|
- Rust 结构体:`QuestRecord`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `refresh_session`
|
### `refresh_session`
|
||||||
|
|
||||||
@@ -505,15 +608,22 @@ npm run check:server-rs-ddd
|
|||||||
- Rust 结构体:`SquareHoleWorkProfileRow`
|
- Rust 结构体:`SquareHoleWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs`
|
||||||
|
|
||||||
|
### SpacetimeDB view:`square_hole_gallery_view`
|
||||||
|
|
||||||
|
- Rust view:`square_hole_gallery_view`
|
||||||
|
- 返回类型:`Vec<SquareHoleGalleryViewRow>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/square_hole.rs`
|
||||||
|
- 说明:方洞挑战公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM square_hole_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` 后,从本地 cache 构造 `/api/runtime/square-hole/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_square_hole_works` procedure;个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
|
||||||
|
|
||||||
### `story_event`
|
### `story_event`
|
||||||
|
|
||||||
- Rust 结构体:`StoryEvent`
|
- Rust 结构体:`StoryEvent`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `story_session`
|
### `story_session`
|
||||||
|
|
||||||
- Rust 结构体:`StorySession`
|
- Rust 结构体:`StorySession`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `tracking_daily_stat`
|
### `tracking_daily_stat`
|
||||||
|
|
||||||
@@ -528,7 +638,7 @@ npm run check:server-rs-ddd
|
|||||||
### `treasure_record`
|
### `treasure_record`
|
||||||
|
|
||||||
- Rust 结构体:`TreasureRecord`
|
- Rust 结构体:`TreasureRecord`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||||
|
|
||||||
### `user_account`
|
### `user_account`
|
||||||
|
|
||||||
@@ -569,3 +679,10 @@ npm run check:server-rs-ddd
|
|||||||
|
|
||||||
- Rust 结构体:`VisualNovelWorkProfileRow`
|
- Rust 结构体:`VisualNovelWorkProfileRow`
|
||||||
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
|
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
|
||||||
|
|
||||||
|
### SpacetimeDB view:`visual_novel_gallery_view`
|
||||||
|
|
||||||
|
- Rust view:`visual_novel_gallery_view`
|
||||||
|
- 返回类型:`Vec<VisualNovelGalleryViewRow>`
|
||||||
|
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
|
||||||
|
- 说明:视觉小说公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM visual_novel_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` 后,从本地 cache 构造 `/api/runtime/visual-novel/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_visual_novel_works` procedure;个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ npm run lint
|
|||||||
npm run check
|
npm run check
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`npm run build` 由 `scripts/build-gate.mjs` 串行构建主站和后台;该门禁会把 Vite warning 当成失败处理。若看到 `Build gate failed because warnings were emitted`,先看 warning 原文,例如 chunk 体积超过 `vite.config.ts` / `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`,不要先按 Rust 编译失败排查。
|
||||||
|
|
||||||
视觉小说负向扫描与验收门禁:
|
视觉小说负向扫描与验收门禁:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -147,8 +149,49 @@ Nginx 负责站点和反向代理
|
|||||||
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step;本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`。
|
||||||
|
|
||||||
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
||||||
|
|
||||||
|
50 HTTP req/s 首版压测优化口径:
|
||||||
|
|
||||||
|
- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024`、`GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。
|
||||||
|
- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压,超过并发许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 不受该限制。该值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程,需要结合真实容量调阈值或在 Nginx 前置限流。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
|
||||||
|
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
||||||
|
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 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。
|
||||||
|
|
||||||
|
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run container:init
|
||||||
|
npm run container:config
|
||||||
|
npm run container:build
|
||||||
|
npm run container:up
|
||||||
|
npm run container:k6
|
||||||
|
npm run container:down
|
||||||
|
```
|
||||||
|
|
||||||
|
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 默认仍连接宿主机 `http://host.docker.internal:3101`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||||
|
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||||
|
|
||||||
|
OpenTelemetry 现阶段可选 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
|
||||||
|
|
||||||
|
- 默认 `GENARRATIVE_OTEL_ENABLED=false`,未开启时 api-server 不依赖 Collector。
|
||||||
|
- Collector 使用官方 `otelcol-contrib`,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter,用 `npm run otel:rider` 转发到 Rider,再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。
|
||||||
|
- api-server 开启时使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`。
|
||||||
|
- api-server 当前发 OTLP HTTP,`OTEL_EXPORTER_OTLP_ENDPOINT` 指向 Collector HTTP base endpoint;不要改到 gRPC `4317` 或 Rider 端口,Rider 由 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。
|
||||||
|
- 应用日志仍通过 `journalctl -u genarrative-api.service` 查看,Nginx 日志仍写文件;日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。
|
||||||
|
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
|
||||||
|
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`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`,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录命中、未命中、重建耗时和预序列化 data JSON 字节数。
|
||||||
|
- 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 查看。
|
||||||
|
- 指标 label 只允许低基数字段:HTTP 使用 `method`、`route`、`status_class`,SpacetimeDB 调用使用 `procedure`、`status_class`;`request_id` 只进入 trace/log attribute,不进入 metric label。
|
||||||
|
|
||||||
常见外部服务变量:
|
常见外部服务变量:
|
||||||
|
|
||||||
- `GENARRATIVE_SPACETIME_SERVER_URL`
|
- `GENARRATIVE_SPACETIME_SERVER_URL`
|
||||||
@@ -164,6 +207,30 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
|||||||
- `WECHAT_*`
|
- `WECHAT_*`
|
||||||
- `ALIYUN_OSS_*`
|
- `ALIYUN_OSS_*`
|
||||||
|
|
||||||
|
### 手机验证码短信
|
||||||
|
|
||||||
|
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
||||||
|
|
||||||
|
生产默认短信配置:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||||
|
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
|
||||||
|
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||||
|
```
|
||||||
|
|
||||||
|
阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH`、`ALIYUN_SMS_CODE_TYPE`、`ALIYUN_SMS_RETURN_VERIFY_CODE`、`ALIYUN_SMS_CASE_AUTH_POLICY`、`ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun`、`SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。修改 `.env.local` 后必须重启 `api-server`,再用 `/api/auth/login-options` 确认返回包含 `phone`;如果通过 shell 临时覆盖,PowerShell 使用 `$env:SMS_AUTH_ENABLED="true"`,cmd 使用 `set SMS_AUTH_ENABLED=true`,不要把引号作为环境变量值的一部分传给进程。
|
||||||
|
|
||||||
|
如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:ALIYUN_SMS_ACCESS_KEY_ID="..."
|
||||||
|
$env:ALIYUN_SMS_ACCESS_KEY_SECRET="..."
|
||||||
|
$env:ALIYUN_SMS_REAL_TEST_PHONE_NUMBER="13800138000"
|
||||||
|
cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
## 埋点与运营查询
|
## 埋点与运营查询
|
||||||
|
|
||||||
用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入:
|
用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入:
|
||||||
|
|||||||
@@ -8,13 +8,17 @@
|
|||||||
|
|
||||||
当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。
|
当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。
|
||||||
|
|
||||||
|
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
||||||
|
|
||||||
## 草稿与作品架
|
## 草稿与作品架
|
||||||
|
|
||||||
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
||||||
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
|
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
|
||||||
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
|
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
|
||||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||||
5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
||||||
|
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
|
||||||
|
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||||
|
|
||||||
## 拼图
|
## 拼图
|
||||||
|
|
||||||
@@ -28,8 +32,13 @@
|
|||||||
|
|
||||||
- 图像输入复用 `CreativeImageInputPanel`。
|
- 图像输入复用 `CreativeImageInputPanel`。
|
||||||
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
|
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
|
||||||
- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。
|
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。
|
||||||
|
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
|
||||||
|
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
|
||||||
|
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。
|
||||||
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
|
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
|
||||||
|
- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。
|
||||||
|
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
|
||||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||||
@@ -51,24 +60,24 @@
|
|||||||
难度映射:
|
难度映射:
|
||||||
|
|
||||||
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
|
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
| ---- | ---------: | ---------: | -------: | -------: |
|
||||||
| 轻松 | 8 | 2 | 24 | 3 |
|
| 轻松 | 8 | 2 | 24 | 3 |
|
||||||
| 标准 | 12 | 4 | 36 | 9 |
|
| 标准 | 12 | 4 | 36 | 9 |
|
||||||
| 进阶 | 16 | 6 | 48 | 15 |
|
| 进阶 | 16 | 6 | 48 | 15 |
|
||||||
| 硬核 | 21 | 8 | 63 | 21 |
|
| 硬核 | 21 | 8 | 63 | 21 |
|
||||||
|
|
||||||
当前素材生成流水线:
|
当前素材生成流水线:
|
||||||
|
|
||||||
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
|
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
|
||||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。
|
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。
|
||||||
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
|
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
|
||||||
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
|
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
|
||||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
||||||
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
|
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
|
||||||
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
|
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
|
||||||
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
|
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
|
||||||
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
|
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
|
||||||
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材。
|
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。
|
||||||
|
|
||||||
结果页当前结构:
|
结果页当前结构:
|
||||||
|
|
||||||
@@ -81,15 +90,16 @@
|
|||||||
运行态当前口径:
|
运行态当前口径:
|
||||||
|
|
||||||
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
|
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
|
||||||
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。
|
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击。
|
||||||
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
|
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
|
||||||
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
|
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
|
||||||
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
|
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
|
||||||
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
|
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
|
||||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
||||||
- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]` 与 `top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
||||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||||
- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。
|
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
||||||
|
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
||||||
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
|
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
|
||||||
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
|
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
|
||||||
|
|
||||||
@@ -153,3 +163,4 @@
|
|||||||
3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。
|
3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。
|
||||||
4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。
|
4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。
|
||||||
5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。
|
5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。
|
||||||
|
6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底;generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
|||||||
|
|
||||||
内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。
|
内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。
|
||||||
|
|
||||||
|
## 账户与登录
|
||||||
|
|
||||||
|
1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。
|
||||||
|
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
|
||||||
|
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
|
||||||
|
|
||||||
## 账户与充值
|
## 账户与充值
|
||||||
|
|
||||||
1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。
|
1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
def runWindowsPowerShell(String scriptName, String scriptBody) {
|
||||||
|
def scriptPath = ".jenkins-${scriptName}.ps1"
|
||||||
|
writeFile file: scriptPath, text: scriptBody, encoding: 'UTF-8'
|
||||||
|
bat label: "PowerShell ${scriptName}", script: """
|
||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set "GENARRATIVE_POWERSHELL=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
|
||||||
|
if not exist "%GENARRATIVE_POWERSHELL%" (
|
||||||
|
echo [jenkins-powershell] powershell.exe not found: %GENARRATIVE_POWERSHELL%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo [jenkins-powershell] user:
|
||||||
|
whoami
|
||||||
|
echo [jenkins-powershell] exe: %GENARRATIVE_POWERSHELL%
|
||||||
|
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "\$path = '%CD%\\${scriptPath}'; \$text = [System.IO.File]::ReadAllText(\$path, [System.Text.Encoding]::UTF8); \$utf8Bom = New-Object System.Text.UTF8Encoding(\$true); [System.IO.File]::WriteAllText(\$path, \$text, \$utf8Bom)"
|
||||||
|
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||||
|
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%CD%\\${scriptPath}"
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
agent {
|
agent {
|
||||||
label 'windows'
|
label 'windows'
|
||||||
@@ -45,23 +66,95 @@ pipeline {
|
|||||||
],
|
],
|
||||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
||||||
])
|
])
|
||||||
powershell '''
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
$sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' }
|
|
||||||
$commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' }
|
|
||||||
$gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' }
|
|
||||||
git fetch --no-tags --prune --depth=1 $gitRemoteUrl "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
|
|
||||||
if ($commitHash) {
|
|
||||||
git checkout --force $commitHash
|
|
||||||
} else {
|
|
||||||
git checkout --force "origin/$sourceBranch"
|
|
||||||
}
|
|
||||||
git clean -ffdx
|
|
||||||
$resolvedCommit = (git rev-parse HEAD).Trim()
|
|
||||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
|
||||||
[System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom)
|
|
||||||
'''
|
|
||||||
script {
|
script {
|
||||||
|
runWindowsPowerShell('stdb-checkout', '''
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' }
|
||||||
|
$commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' }
|
||||||
|
$gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' }
|
||||||
|
|
||||||
|
function Invoke-GitCommand {
|
||||||
|
param(
|
||||||
|
[string]$Label,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host "[stdb-checkout] $Label"
|
||||||
|
& git @Arguments
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
throw "[stdb-checkout] $Label failed with exit code $exitCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[stdb-checkout] sourceBranch: $sourceBranch"
|
||||||
|
Write-Host "[stdb-checkout] remote: $gitRemoteUrl"
|
||||||
|
$currentCommit = (git rev-parse HEAD).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $currentCommit) {
|
||||||
|
throw '[stdb-checkout] cannot resolve current HEAD'
|
||||||
|
}
|
||||||
|
Write-Host "[stdb-checkout] current HEAD: $currentCommit"
|
||||||
|
|
||||||
|
if ($commitHash) {
|
||||||
|
Write-Host "[stdb-checkout] requested commit: $commitHash"
|
||||||
|
$resolvedCommit = (git rev-parse --verify "${commitHash}^{commit}" 2>$null).Trim()
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $resolvedCommit -eq $currentCommit) {
|
||||||
|
Write-Host '[stdb-checkout] requested commit already matches Jenkins GitSCM checkout'
|
||||||
|
} else {
|
||||||
|
Invoke-GitCommand -Label 'fetch source branch history' -Arguments @(
|
||||||
|
'fetch',
|
||||||
|
'--no-tags',
|
||||||
|
'--prune',
|
||||||
|
$gitRemoteUrl,
|
||||||
|
"+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
|
||||||
|
)
|
||||||
|
$isShallowRepository = (git rev-parse --is-shallow-repository 2>$null).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw '[stdb-checkout] cannot determine whether repository is shallow'
|
||||||
|
}
|
||||||
|
if ($isShallowRepository -eq 'true') {
|
||||||
|
Invoke-GitCommand -Label 'deepen source branch history' -Arguments @(
|
||||||
|
'fetch',
|
||||||
|
'--unshallow',
|
||||||
|
'--no-tags',
|
||||||
|
$gitRemoteUrl,
|
||||||
|
"+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Invoke-GitCommand -Label 'validate source branch ref' -Arguments @(
|
||||||
|
'cat-file',
|
||||||
|
'-e',
|
||||||
|
"refs/remotes/origin/${sourceBranch}^{commit}"
|
||||||
|
)
|
||||||
|
Invoke-GitCommand -Label 'validate requested commit' -Arguments @(
|
||||||
|
'cat-file',
|
||||||
|
'-e',
|
||||||
|
"${commitHash}^{commit}"
|
||||||
|
)
|
||||||
|
$resolvedCommit = (git rev-parse --verify "${commitHash}^{commit}").Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $resolvedCommit) {
|
||||||
|
throw "[stdb-checkout] cannot resolve requested commit: $commitHash"
|
||||||
|
}
|
||||||
|
Invoke-GitCommand -Label 'validate requested commit belongs to branch' -Arguments @(
|
||||||
|
'merge-base',
|
||||||
|
'--is-ancestor',
|
||||||
|
$resolvedCommit,
|
||||||
|
"refs/remotes/origin/${sourceBranch}"
|
||||||
|
)
|
||||||
|
Invoke-GitCommand -Label "checkout commit $resolvedCommit" -Arguments @(
|
||||||
|
'checkout',
|
||||||
|
'--force',
|
||||||
|
$resolvedCommit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "[stdb-checkout] COMMIT_HASH empty, reusing Jenkins GitSCM checkout result"
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedCommit = (git rev-parse HEAD).Trim()
|
||||||
|
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom)
|
||||||
|
''')
|
||||||
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim()
|
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim()
|
||||||
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
||||||
}
|
}
|
||||||
@@ -72,7 +165,7 @@ pipeline {
|
|||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
def buildStep = {
|
def buildStep = {
|
||||||
powershell '''
|
runWindowsPowerShell('stdb-build', '''
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
|
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
|
||||||
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
|
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
|
||||||
@@ -110,6 +203,7 @@ pipeline {
|
|||||||
}
|
}
|
||||||
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
|
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
|
||||||
'''
|
'''
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||||
withCredentials([
|
withCredentials([
|
||||||
|
|||||||
78
package-lock.json
generated
78
package-lock.json
generated
@@ -72,6 +72,7 @@
|
|||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1515,7 +1516,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -1528,7 +1528,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -1542,8 +1541,7 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/react": {
|
"node_modules/@testing-library/react": {
|
||||||
"version": "16.3.2",
|
"version": "16.3.2",
|
||||||
@@ -1606,8 +1604,7 @@
|
|||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -1650,7 +1647,8 @@
|
|||||||
"version": "4.3.20",
|
"version": "4.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||||
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/chai-subset": {
|
"node_modules/@types/chai-subset": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
@@ -1696,6 +1694,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1705,6 +1704,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -1796,6 +1796,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -2126,6 +2127,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2216,7 +2218,6 @@
|
|||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
@@ -2338,6 +2339,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2629,7 +2631,6 @@
|
|||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -2685,8 +2686,7 @@
|
|||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/domexception": {
|
"node_modules/domexception": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
@@ -2873,6 +2873,7 @@
|
|||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -3697,6 +3698,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||||
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abab": "^2.0.6",
|
"abab": "^2.0.6",
|
||||||
"cssstyle": "^3.0.0",
|
"cssstyle": "^3.0.0",
|
||||||
@@ -4096,7 +4098,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -4435,6 +4436,7 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4486,6 +4488,7 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -4619,6 +4622,7 @@
|
|||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -4627,6 +4631,7 @@
|
|||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -5074,6 +5079,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -5126,6 +5132,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -5207,6 +5214,7 @@
|
|||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
@@ -7027,6 +7035,7 @@
|
|||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -7835,15 +7844,13 @@
|
|||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"pretty-format": {
|
"pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -7854,8 +7861,7 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7891,8 +7897,7 @@
|
|||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"@types/babel__core": {
|
"@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -7935,7 +7940,8 @@
|
|||||||
"version": "4.3.20",
|
"version": "4.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||||
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"@types/chai-subset": {
|
"@types/chai-subset": {
|
||||||
"version": "1.3.6",
|
"version": "1.3.6",
|
||||||
@@ -7978,6 +7984,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -7987,6 +7994,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"@types/semver": {
|
"@types/semver": {
|
||||||
@@ -8053,6 +8061,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@typescript-eslint/scope-manager": "6.21.0",
|
"@typescript-eslint/scope-manager": "6.21.0",
|
||||||
"@typescript-eslint/types": "6.21.0",
|
"@typescript-eslint/types": "6.21.0",
|
||||||
@@ -8263,7 +8272,8 @@
|
|||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"acorn-jsx": {
|
"acorn-jsx": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
@@ -8326,7 +8336,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
@@ -8396,6 +8405,7 @@
|
|||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -8605,8 +8615,7 @@
|
|||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"detect-libc": {
|
"detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
@@ -8646,8 +8655,7 @@
|
|||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"domexception": {
|
"domexception": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
@@ -8782,6 +8790,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
|
||||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -9360,6 +9369,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||||
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"abab": "^2.0.6",
|
"abab": "^2.0.6",
|
||||||
"cssstyle": "^3.0.0",
|
"cssstyle": "^3.0.0",
|
||||||
@@ -9566,8 +9576,7 @@
|
|||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"magic-string": {
|
"magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
@@ -9813,7 +9822,8 @@
|
|||||||
"picomatch": {
|
"picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"pkg-types": {
|
"pkg-types": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
@@ -9843,6 +9853,7 @@
|
|||||||
"version": "8.5.8",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -9926,12 +9937,14 @@
|
|||||||
"react": {
|
"react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
}
|
}
|
||||||
@@ -10256,6 +10269,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"fsevents": "~2.3.3",
|
"fsevents": "~2.3.3",
|
||||||
@@ -10287,7 +10301,8 @@
|
|||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"ufo": {
|
"ufo": {
|
||||||
"version": "1.6.3",
|
"version": "1.6.3",
|
||||||
@@ -10339,6 +10354,7 @@
|
|||||||
"version": "6.4.1",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
|
"peer": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -10,11 +10,14 @@
|
|||||||
"dev:web": "node scripts/dev.mjs web",
|
"dev:web": "node scripts/dev.mjs web",
|
||||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||||
|
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||||
|
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
||||||
"admin-web:build": "node scripts/admin-web-build.mjs build",
|
"admin-web:build": "node scripts/admin-web-build.mjs build",
|
||||||
"admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
|
"admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
|
||||||
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
|
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
|
||||||
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
||||||
"check:api-server-env": "node scripts/check-api-server-env.mjs",
|
"check:api-server-env": "node scripts/check-api-server-env.mjs",
|
||||||
|
"check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs",
|
||||||
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||||
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
|
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
|
||||||
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||||
@@ -29,7 +32,7 @@
|
|||||||
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
|
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
|
||||||
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
|
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
|
||||||
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
|
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
|
||||||
"check:server-rs-ddd": "npm run check:spacetime-schema && node scripts/check-server-rs-ddd-boundaries.mjs",
|
"check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && node scripts/check-server-rs-ddd-boundaries.mjs",
|
||||||
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
|
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
|
||||||
"lint:guardrails": "npm run lint:eslint",
|
"lint:guardrails": "npm run lint:eslint",
|
||||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||||
@@ -42,6 +45,14 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
|
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
|
||||||
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js",
|
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js",
|
||||||
|
"container:init": "node scripts/container-compose.mjs init",
|
||||||
|
"container:build": "node scripts/container-compose.mjs build",
|
||||||
|
"container:up": "node scripts/container-compose.mjs up",
|
||||||
|
"container:down": "node scripts/container-compose.mjs down",
|
||||||
|
"container:logs": "node scripts/container-compose.mjs logs",
|
||||||
|
"container:ps": "node scripts/container-compose.mjs ps",
|
||||||
|
"container:config": "node scripts/container-compose.mjs config",
|
||||||
|
"container:k6": "node scripts/container-compose.mjs k6",
|
||||||
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
||||||
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
||||||
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export type BabyObjectMatchVisualAssetKind =
|
|||||||
| 'ui-frame'
|
| 'ui-frame'
|
||||||
| 'gift-box'
|
| 'gift-box'
|
||||||
| 'basket'
|
| 'basket'
|
||||||
| 'smoke-puff';
|
| 'smoke-puff'
|
||||||
|
| 'left-hand'
|
||||||
|
| 'right-hand';
|
||||||
|
|
||||||
export type BabyObjectMatchVisualAsset = {
|
export type BabyObjectMatchVisualAsset = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { CreationAudioAsset } from './creationAudio';
|
import type { CreationAudioAsset } from './creationAudio';
|
||||||
|
|
||||||
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
|
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
|
||||||
|
export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string;
|
||||||
|
|
||||||
export type Match3DGeneratedItemAssetStatus =
|
export type Match3DGeneratedItemAssetStatus =
|
||||||
| 'pending'
|
| 'pending'
|
||||||
@@ -163,6 +164,7 @@ export interface Match3DWorkSummary {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
publishReady: boolean;
|
publishReady: boolean;
|
||||||
|
generationStatus?: Match3DWorkGenerationStatus | null;
|
||||||
backgroundPrompt?: string | null;
|
backgroundPrompt?: string | null;
|
||||||
backgroundImageSrc?: string | null;
|
backgroundImageSrc?: string | null;
|
||||||
backgroundImageObjectKey?: string | null;
|
backgroundImageObjectKey?: string | null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { JsonObject } from './common';
|
|||||||
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
|
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
|
||||||
|
|
||||||
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
|
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
|
||||||
|
export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus'];
|
||||||
|
|
||||||
export interface PuzzleWorkSummary {
|
export interface PuzzleWorkSummary {
|
||||||
workId: string;
|
workId: string;
|
||||||
@@ -28,6 +29,7 @@ export interface PuzzleWorkSummary {
|
|||||||
pointIncentiveTotalPoints?: number;
|
pointIncentiveTotalPoints?: number;
|
||||||
pointIncentiveClaimablePoints?: number;
|
pointIncentiveClaimablePoints?: number;
|
||||||
publishReady: boolean;
|
publishReady: boolean;
|
||||||
|
generationStatus?: PuzzleWorkGenerationStatus | null;
|
||||||
levels?: PuzzleDraftLevel[];
|
levels?: PuzzleDraftLevel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +42,19 @@ export interface PuzzleWorksResponse {
|
|||||||
items: PuzzleWorkSummary[];
|
items: PuzzleWorkSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PuzzleGalleryWorkRef {
|
||||||
|
workId: string;
|
||||||
|
profileId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleGalleryResponse {
|
||||||
|
items: PuzzleWorkSummary[];
|
||||||
|
previewRefs?: PuzzleGalleryWorkRef[];
|
||||||
|
hasMore?: boolean;
|
||||||
|
nextCursor?: string | null;
|
||||||
|
totalCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PuzzleWorkDetailResponse {
|
export interface PuzzleWorkDetailResponse {
|
||||||
item: PuzzleWorkProfile;
|
item: PuzzleWorkProfile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ function printStatus(key, present) {
|
|||||||
const env = mergeApiServerEnv(process.cwd(), process.env);
|
const env = mergeApiServerEnv(process.cwd(), process.env);
|
||||||
const missing = [];
|
const missing = [];
|
||||||
|
|
||||||
|
console.log('[api-server-env] 认证短信配置检查');
|
||||||
|
printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true');
|
||||||
|
printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER));
|
||||||
|
|
||||||
console.log('[api-server-env] 拼图真实生成配置检查');
|
console.log('[api-server-env] 拼图真实生成配置检查');
|
||||||
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
|
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
|
||||||
const present = hasValue(env[key]);
|
const present = hasValue(env[key]);
|
||||||
|
|||||||
221
scripts/check-spacetime-runtime-access.mjs
Normal file
221
scripts/check-spacetime-runtime-access.mjs
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
|
||||||
|
function readUtf8(relativePath) {
|
||||||
|
const absolute = path.join(repoRoot, relativePath);
|
||||||
|
if (!fs.existsSync(absolute)) {
|
||||||
|
failures.push(`${relativePath}: 文件不存在,无法执行 SpacetimeDB runtime access 检查`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return fs.readFileSync(absolute, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
const forbiddenSnippets = [
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
|
||||||
|
snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.owner_user_id == input.owner_user_id)',
|
||||||
|
reason: 'puzzle_work_profile 已有 by_puzzle_work_owner_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
|
||||||
|
snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.publication_status == PuzzlePublicationStatus::Published)',
|
||||||
|
reason: 'puzzle_work_profile 已有 by_puzzle_work_publication_status 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
|
||||||
|
snippet: '.puzzle_leaderboard_entry()\n .iter()\n .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)',
|
||||||
|
reason: 'puzzle_leaderboard_entry 已有 by_puzzle_leaderboard_profile_grid 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/match3d.rs',
|
||||||
|
snippet: '.match3d_work_profile()\n .iter()\n .filter(|row| {',
|
||||||
|
reason: 'match3d_work_profile 已有 owner/status 索引,列表不应整表过滤',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/visual_novel.rs',
|
||||||
|
snippet: '.visual_novel_work_profile()\n .iter()\n .filter(|row| {',
|
||||||
|
reason: 'visual_novel_work_profile 已有 owner/status 索引,列表不应整表过滤',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs',
|
||||||
|
snippet: '.asset_object()\n .iter()\n .find(|row| row.bucket == input.bucket && row.object_key == input.object_key)',
|
||||||
|
reason: 'asset_object 已有 by_bucket_object_key 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs',
|
||||||
|
snippet: '.asset_object()\n .iter()\n .filter(|row| row.asset_kind == asset_kind)',
|
||||||
|
reason: 'asset_object 已有 asset_kind 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/ai/stages.rs',
|
||||||
|
snippet: '.ai_task_stage()\n .iter()\n .filter(|row| row.task_id == task_id)',
|
||||||
|
reason: 'ai_task_stage 已有 by_ai_task_stage_task_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/ai/stages.rs',
|
||||||
|
snippet: '.ai_text_chunk()\n .iter()\n .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)',
|
||||||
|
reason: 'ai_text_chunk 已有 by_ai_text_chunk_task_id / by_ai_text_chunk_task_stage_sequence 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs',
|
||||||
|
snippet: '.ai_task_stage()\n .iter()\n .filter(|stage| stage.task_id == row.task_id)',
|
||||||
|
reason: 'ai_task_stage 快照组装应使用 by_ai_task_stage_task_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs',
|
||||||
|
snippet: '.ai_result_reference()\n .iter()\n .filter(|reference| reference.task_id == row.task_id)',
|
||||||
|
reason: 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||||
|
snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
|
||||||
|
reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||||
|
snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
|
||||||
|
reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||||
|
snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
|
||||||
|
reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||||
|
snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)',
|
||||||
|
reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||||
|
snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)',
|
||||||
|
reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||||
|
snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {',
|
||||||
|
reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/custom_world.rs',
|
||||||
|
snippet: '.custom_world_profile()\n .iter()\n .find(|row| {',
|
||||||
|
reason: 'custom_world_profile owner 维度已有 by_custom_world_profile_owner_user_id 索引',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'server-rs/crates/spacetime-module/src/custom_world.rs',
|
||||||
|
snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {',
|
||||||
|
reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const procedureResultFiles = [
|
||||||
|
'server-rs/crates/module-puzzle/src/application.rs',
|
||||||
|
'server-rs/crates/module-big-fish/src/domain.rs',
|
||||||
|
'server-rs/crates/spacetime-module/src/match3d/types.rs',
|
||||||
|
'server-rs/crates/spacetime-module/src/square_hole/types.rs',
|
||||||
|
'server-rs/crates/spacetime-module/src/visual_novel.rs',
|
||||||
|
'server-rs/crates/spacetime-module/src/bark_battle/types.rs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const mapperCompatibilityFiles = [
|
||||||
|
'server-rs/crates/spacetime-client/src/mapper.rs',
|
||||||
|
'server-rs/crates/spacetime-client/src/lib.rs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const bigFishRuntimeFiles = [
|
||||||
|
'server-rs/crates/module-big-fish/src/commands.rs',
|
||||||
|
'server-rs/crates/spacetime-module/src/big_fish/runtime.rs',
|
||||||
|
'server-rs/crates/spacetime-module/src/big_fish/session.rs',
|
||||||
|
];
|
||||||
|
|
||||||
|
const legacyMapperPatterns = [
|
||||||
|
{
|
||||||
|
pattern: /\b[A-Za-z0-9_]*JsonRecord\b/u,
|
||||||
|
reason: 'spacetime-client mapper 不应保留旧 ProcedureResult JSON 兼容 Record',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bCompatibleBigFish[A-Za-z0-9_]*\b/u,
|
||||||
|
reason: 'spacetime-client mapper 不应保留 BigFish 旧 JSON 兼容结构',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\bmap_[A-Za-z0-9_]*_json\b/u,
|
||||||
|
reason: 'spacetime-client mapper 不应再通过 map_*_json 反序列化 procedure payload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /serde_json::from_str::<[A-Za-z0-9_:]*JsonRecord/u,
|
||||||
|
reason: 'spacetime-client mapper 不应把 procedure result 再反序列化为 JsonRecord',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /\b(?:items|run|work|session|event|feedback)_json:\s*Some\(/u,
|
||||||
|
reason: 'mapper 测试与兼容路径不应再构造旧 procedure JSON 字符串字段',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const typedProcedurePayloadFieldPattern =
|
||||||
|
/\b(?:row|session|work|item|items|run|event|feedback)_json:\s*Option<String>/gu;
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const rule of forbiddenSnippets) {
|
||||||
|
const content = readUtf8(rule.file);
|
||||||
|
if (content === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (content.includes(rule.snippet)) {
|
||||||
|
failures.push(`${rule.file}: ${rule.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of procedureResultFiles) {
|
||||||
|
const content = readUtf8(file);
|
||||||
|
if (content === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? [];
|
||||||
|
for (const block of resultBlocks) {
|
||||||
|
const jsonFields = block.match(typedProcedurePayloadFieldPattern);
|
||||||
|
if (jsonFields?.length) {
|
||||||
|
const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult';
|
||||||
|
failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of mapperCompatibilityFiles) {
|
||||||
|
const content = readUtf8(file);
|
||||||
|
if (content === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const rule of legacyMapperPatterns) {
|
||||||
|
if (rule.pattern.test(content)) {
|
||||||
|
failures.push(`${file}: ${rule.reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of bigFishRuntimeFiles) {
|
||||||
|
const content = readUtf8(file);
|
||||||
|
if (content === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? [];
|
||||||
|
for (const block of resultBlocks) {
|
||||||
|
const jsonFields = block.match(typedProcedurePayloadFieldPattern);
|
||||||
|
if (jsonFields?.length) {
|
||||||
|
const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult';
|
||||||
|
failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('SpacetimeDB runtime access 检查失败:');
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.error(`- ${failure}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SpacetimeDB runtime access 检查通过。');
|
||||||
99
scripts/container-compose.mjs
Normal file
99
scripts/container-compose.mjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import {spawn} from 'node:child_process';
|
||||||
|
import {copyFileSync, existsSync} from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const [, , rawCommand = 'help', ...args] = process.argv;
|
||||||
|
const command = rawCommand.trim();
|
||||||
|
const printComposeConfig = args.includes('--print');
|
||||||
|
const passthroughArgs = args.filter((arg) => arg !== '--print');
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml');
|
||||||
|
const envExamplePath = path.join('deploy', 'container', 'api-server.env.example');
|
||||||
|
const envPath = path.join('deploy', 'container', 'api-server.env');
|
||||||
|
|
||||||
|
const supportedCommands = new Set(['init', 'build', 'up', 'down', 'logs', 'ps', 'config', 'k6']);
|
||||||
|
|
||||||
|
if (command === 'help' || !supportedCommands.has(command)) {
|
||||||
|
printHelp(command !== 'help');
|
||||||
|
process.exit(command === 'help' ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === 'init') {
|
||||||
|
ensureEnvFile();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(envPath)) {
|
||||||
|
ensureEnvFile();
|
||||||
|
console.error('[container] 请先检查 deploy/container/api-server.env 中的 SpacetimeDB 地址、库名和 token。');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const composeArgs = buildComposeArgs(command, passthroughArgs);
|
||||||
|
const child = spawn('docker', composeArgs, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: process.env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(`[container] docker compose 启动失败: ${error.message}`);
|
||||||
|
console.error('[container] 请确认 Docker Desktop 或 Docker Engine 已安装,并且 docker 在 PATH 中。');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
console.error(`[container] docker compose 被信号终止: ${signal}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildComposeArgs(selectedCommand, extraArgs) {
|
||||||
|
const baseArgs = ['compose', '-f', composeFile];
|
||||||
|
switch (selectedCommand) {
|
||||||
|
case 'build':
|
||||||
|
return [...baseArgs, 'build', ...extraArgs];
|
||||||
|
case 'up':
|
||||||
|
return [...baseArgs, 'up', '-d', ...extraArgs];
|
||||||
|
case 'down':
|
||||||
|
return [...baseArgs, 'down', ...extraArgs];
|
||||||
|
case 'logs':
|
||||||
|
return [...baseArgs, 'logs', ...extraArgs];
|
||||||
|
case 'ps':
|
||||||
|
return [...baseArgs, 'ps', ...extraArgs];
|
||||||
|
case 'config':
|
||||||
|
return [...baseArgs, 'config', ...(printComposeConfig ? [] : ['--quiet']), ...extraArgs];
|
||||||
|
case 'k6':
|
||||||
|
return [...baseArgs, '--profile', 'loadtest', 'run', '--rm', 'k6', ...extraArgs];
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported command: ${selectedCommand}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEnvFile() {
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
console.log(`[container] 已存在 ${envPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyFileSync(envExamplePath, envPath);
|
||||||
|
console.log(`[container] 已从 ${envExamplePath} 生成 ${envPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(isError) {
|
||||||
|
const output = isError ? console.error : console.log;
|
||||||
|
output(`Usage: npm run container:<command> -- [docker compose args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
container:init 生成 deploy/container/api-server.env
|
||||||
|
container:build 构建 api-server 容器镜像
|
||||||
|
container:up 后台启动 api-server + nginx + otelcol
|
||||||
|
container:down 停止并清理容器
|
||||||
|
container:logs 查看容器日志
|
||||||
|
container:ps 查看容器状态
|
||||||
|
container:config 校验 compose 配置,传 -- --print 可展开完整配置
|
||||||
|
container:k6 在 compose 网络内运行 k6
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs';
|
|||||||
import {dirname, isAbsolute, resolve} from 'node:path';
|
import {dirname, isAbsolute, resolve} from 'node:path';
|
||||||
|
|
||||||
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
|
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
|
||||||
|
const LOCAL_ENV_OVERRIDE_KEYS = new Set([
|
||||||
|
'SMS_AUTH_ENABLED',
|
||||||
|
'SMS_AUTH_PROVIDER',
|
||||||
|
'SMS_AUTH_MOCK_VERIFY_CODE',
|
||||||
|
'WECHAT_AUTH_ENABLED',
|
||||||
|
'WECHAT_AUTH_PROVIDER',
|
||||||
|
]);
|
||||||
|
|
||||||
export function buildProtectedEnvKeys(baseEnv) {
|
export function buildProtectedEnvKeys(baseEnv) {
|
||||||
return new Set(
|
return new Set(
|
||||||
@@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [, key, rawValue] = match;
|
const [, key, rawValue] = match;
|
||||||
if (protectedKeys.has(key)) {
|
if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ describe('dev utils env merge', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('本地认证开关覆盖外层 shell 旧值', () => {
|
||||||
|
withTempEnvFiles(
|
||||||
|
{
|
||||||
|
'.env.local': [
|
||||||
|
'SMS_AUTH_ENABLED=true',
|
||||||
|
'SMS_AUTH_PROVIDER=aliyun',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
(_env, tempDir) => {
|
||||||
|
const env = mergeApiServerEnv(tempDir, {
|
||||||
|
SMS_AUTH_ENABLED: 'false',
|
||||||
|
SMS_AUTH_PROVIDER: 'mock',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.SMS_AUTH_ENABLED).toBe('true');
|
||||||
|
expect(env.SMS_AUTH_PROVIDER).toBe('aliyun');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
|
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
|
||||||
withTempEnvFiles(
|
withTempEnvFiles(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -158,6 +158,26 @@ const assetDefinitions = [
|
|||||||
chromaKeyNote,
|
chromaKeyNote,
|
||||||
].join(''),
|
].join(''),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'character-outline-only-v3',
|
||||||
|
output: 'picture-book-character-outline-v3.png',
|
||||||
|
sourceOutput: 'picture-book-character-outline-v2.png',
|
||||||
|
sourceDirectory: 'asset',
|
||||||
|
transparent: true,
|
||||||
|
localPostprocess: 'character-outline-only',
|
||||||
|
prompt:
|
||||||
|
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,只保留浅青白描边,中间完全透明,不保留原有半透明材质、填充和明暗变化。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'character-outline-white-v4',
|
||||||
|
output: 'picture-book-character-outline-v4.png',
|
||||||
|
sourceOutput: 'picture-book-character-outline-v2.png',
|
||||||
|
sourceDirectory: 'asset',
|
||||||
|
transparent: true,
|
||||||
|
localPostprocess: 'character-outline-white-thin',
|
||||||
|
prompt:
|
||||||
|
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,先弱化耳朵、手指、脚趾等细碎凸起,再输出更细的白色描边,中间完全透明。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'hud-strip',
|
id: 'hud-strip',
|
||||||
output: 'picture-book-hud-strip-v2.png',
|
output: 'picture-book-hud-strip-v2.png',
|
||||||
@@ -601,6 +621,16 @@ const assetDefinitions = [
|
|||||||
chromaKeyNote,
|
chromaKeyNote,
|
||||||
].join(''),
|
].join(''),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-body-guide-v7',
|
||||||
|
output: 'picture-book-wave-cat-body-guide-v7.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-body-guide-v6.png',
|
||||||
|
sourceDirectory: 'asset',
|
||||||
|
transparent: true,
|
||||||
|
localPostprocess: 'remove-cat-body-shoulder-dots',
|
||||||
|
prompt:
|
||||||
|
'本地后处理资源:基于 wave-cat-body-guide-v6 去除身体左右两侧不协调的小圆点,保留猫头、身体、透明边界和整体水彩风格。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'wave-cat-arm-guide-v6',
|
id: 'wave-cat-arm-guide-v6',
|
||||||
output: 'picture-book-wave-cat-arm-guide-v6.png',
|
output: 'picture-book-wave-cat-arm-guide-v6.png',
|
||||||
@@ -632,6 +662,37 @@ const assetDefinitions = [
|
|||||||
chromaKeyNote,
|
chromaKeyNote,
|
||||||
].join(''),
|
].join(''),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wave-cat-arm-guide-v7',
|
||||||
|
output: 'picture-book-wave-cat-arm-guide-v7.png',
|
||||||
|
sourceOutput: 'picture-book-wave-cat-arm-guide-v7-source.png',
|
||||||
|
size: '1024x1024',
|
||||||
|
transparent: true,
|
||||||
|
transparencyCleanup: 'cat-guide',
|
||||||
|
useWaveCatHeadReference: true,
|
||||||
|
useWaveCatArmReference: true,
|
||||||
|
layoutNormalization: {
|
||||||
|
canvasWidth: 1024,
|
||||||
|
canvasHeight: 1024,
|
||||||
|
fit: 'contain',
|
||||||
|
fillWidth: 0.74,
|
||||||
|
fillHeight: 0.88,
|
||||||
|
anchorY: 'bottom',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
prompt: [
|
||||||
|
'请在参考手臂资源的基础上重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
|
||||||
|
'关键修改:末端圆猫爪必须正面对镜头,像在对观众挥手。圆爪正面轮廓要清楚可见,不要转成侧面,不要转向画面内侧或角色中心,不要画成握拳或背面。可以用浅奶油白圆形爪面、柔和高光和非常淡的短弧线表现正面对镜头。',
|
||||||
|
'猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润圆爪,不画分开的手指,不画尖爪,不画黑色或深色爪垫。若需要爪面细节,只允许非常浅的桃色小圆面或柔和弧线,不能变成真实动物爪垫。',
|
||||||
|
'手臂短而厚实,像小猫上肢,不要成人类长手臂。资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
|
||||||
|
'颜色参考输入猫猫头和参考手臂:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
|
||||||
|
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
|
||||||
|
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||||||
|
styleReferenceNote,
|
||||||
|
noStretchNote,
|
||||||
|
chromaKeyNote,
|
||||||
|
].join(''),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const args = new Map();
|
const args = new Map();
|
||||||
@@ -811,6 +872,12 @@ function buildRequestBody(asset, size) {
|
|||||||
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
|
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (asset.useWaveCatArmReference) {
|
||||||
|
pushReferenceImage(
|
||||||
|
body,
|
||||||
|
path.join(assetDir, 'picture-book-wave-cat-arm-guide-v6.png'),
|
||||||
|
);
|
||||||
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,6 +931,9 @@ function outputPathFor(asset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceOutputPathFor(asset) {
|
function sourceOutputPathFor(asset) {
|
||||||
|
if (asset.sourceDirectory === 'asset') {
|
||||||
|
return path.join(assetDir, asset.sourceOutput || asset.output);
|
||||||
|
}
|
||||||
return path.join(intermediateDir, asset.sourceOutput || asset.output);
|
return path.join(intermediateDir, asset.sourceOutput || asset.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1038,6 +1108,92 @@ function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCharacterOutlineOnlyIndicator(sourcePath, finalPath) {
|
||||||
|
const script = [
|
||||||
|
'from PIL import Image, ImageChops, ImageFilter',
|
||||||
|
'import sys',
|
||||||
|
'source, out = sys.argv[1], sys.argv[2]',
|
||||||
|
'im = Image.open(source).convert("RGBA")',
|
||||||
|
'alpha = im.getchannel("A")',
|
||||||
|
'mask = alpha.point(lambda v: 255 if v > 24 else 0)',
|
||||||
|
'mask = mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.MinFilter(5))',
|
||||||
|
'outer = mask.filter(ImageFilter.MaxFilter(47))',
|
||||||
|
'inner = mask.filter(ImageFilter.MinFilter(47))',
|
||||||
|
'stroke = ImageChops.subtract(outer, inner)',
|
||||||
|
'stroke = stroke.filter(ImageFilter.GaussianBlur(0.45))',
|
||||||
|
'glow = stroke.filter(ImageFilter.GaussianBlur(3.0)).point(lambda v: int(v * 0.34))',
|
||||||
|
'result = Image.new("RGBA", im.size, (0, 0, 0, 0))',
|
||||||
|
'glow_layer = Image.new("RGBA", im.size, (91, 205, 197, 0))',
|
||||||
|
'glow_layer.putalpha(glow)',
|
||||||
|
'line_layer = Image.new("RGBA", im.size, (224, 255, 247, 0))',
|
||||||
|
'line_layer.putalpha(stroke.point(lambda v: min(235, int(v * 0.92))))',
|
||||||
|
'result.alpha_composite(glow_layer)',
|
||||||
|
'result.alpha_composite(line_layer)',
|
||||||
|
'result.save(out)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create outline-only character indicator: ${(result.stderr || result.stdout).trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWhiteCharacterOutlineIndicator(sourcePath, finalPath) {
|
||||||
|
const script = [
|
||||||
|
'from pathlib import Path',
|
||||||
|
'import cv2',
|
||||||
|
'import numpy as np',
|
||||||
|
'from PIL import Image',
|
||||||
|
'import sys',
|
||||||
|
'source, out = Path(sys.argv[1]), Path(sys.argv[2])',
|
||||||
|
'rgba = np.array(Image.open(source).convert("RGBA"))',
|
||||||
|
'alpha = rgba[:, :, 3]',
|
||||||
|
'mask = (alpha > 24).astype(np.uint8) * 255',
|
||||||
|
'contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)',
|
||||||
|
'body = np.zeros_like(mask)',
|
||||||
|
'if contours:',
|
||||||
|
' largest = max(contours, key=cv2.contourArea)',
|
||||||
|
' cv2.drawContours(body, [largest], -1, 255, thickness=cv2.FILLED)',
|
||||||
|
'open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25))',
|
||||||
|
'close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (35, 35))',
|
||||||
|
'body = cv2.morphologyEx(body, cv2.MORPH_OPEN, open_kernel, iterations=1)',
|
||||||
|
'body = cv2.morphologyEx(body, cv2.MORPH_CLOSE, close_kernel, iterations=1)',
|
||||||
|
'body = cv2.GaussianBlur(body, (0, 0), 7.0)',
|
||||||
|
'_, body = cv2.threshold(body, 92, 255, cv2.THRESH_BINARY)',
|
||||||
|
'body = cv2.GaussianBlur(body, (0, 0), 1.4)',
|
||||||
|
'_, body = cv2.threshold(body, 64, 255, cv2.THRESH_BINARY)',
|
||||||
|
'line_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))',
|
||||||
|
'outer = cv2.dilate(body, line_kernel, iterations=1)',
|
||||||
|
'inner = cv2.erode(body, line_kernel, iterations=1)',
|
||||||
|
'stroke = cv2.subtract(outer, inner)',
|
||||||
|
'stroke = cv2.GaussianBlur(stroke, (0, 0), 0.55)',
|
||||||
|
'glow = cv2.GaussianBlur(stroke, (0, 0), 2.2)',
|
||||||
|
'result = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)',
|
||||||
|
'glow_alpha = np.clip(glow.astype(np.float32) * 0.22, 0, 70).astype(np.uint8)',
|
||||||
|
'line_alpha = np.clip(stroke.astype(np.float32) * 0.78, 0, 205).astype(np.uint8)',
|
||||||
|
'result[:, :, 0:3] = 255',
|
||||||
|
'result[:, :, 3] = np.maximum(glow_alpha, line_alpha)',
|
||||||
|
'Image.fromarray(result, "RGBA").save(out)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to create thin white character indicator: ${(result.stderr || result.stdout).trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function removeCatGuideChromaKey(sourcePath, finalPath) {
|
function removeCatGuideChromaKey(sourcePath, finalPath) {
|
||||||
const script = [
|
const script = [
|
||||||
'from collections import deque',
|
'from collections import deque',
|
||||||
@@ -1253,6 +1409,50 @@ function scrubChromaFringe(finalPath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCatBodyShoulderDots(sourcePath, finalPath) {
|
||||||
|
const script = [
|
||||||
|
'from pathlib import Path',
|
||||||
|
'import cv2',
|
||||||
|
'import numpy as np',
|
||||||
|
'from PIL import Image',
|
||||||
|
'source, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2])',
|
||||||
|
'rgba = np.array(Image.open(source).convert("RGBA"))',
|
||||||
|
'rgb = rgba[:, :, :3].copy()',
|
||||||
|
'alpha = rgba[:, :, 3]',
|
||||||
|
'opaque = alpha > 10',
|
||||||
|
'known = opaque.astype(np.uint8)',
|
||||||
|
'unknown = (1 - known).astype(np.uint8)',
|
||||||
|
'_, labels = cv2.distanceTransformWithLabels(unknown, cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL)',
|
||||||
|
'flat_known_indices = np.flatnonzero(known.reshape(-1))',
|
||||||
|
'filled_rgb = rgb.copy().reshape(-1, 3)',
|
||||||
|
'labels_flat = labels.reshape(-1)',
|
||||||
|
'unknown_flat = unknown.reshape(-1).astype(bool)',
|
||||||
|
'if flat_known_indices.size > 0 and unknown_flat.any():',
|
||||||
|
' nearest_known_flat_index = flat_known_indices[np.maximum(labels_flat[unknown_flat] - 1, 0)]',
|
||||||
|
' filled_rgb[unknown_flat] = filled_rgb[nearest_known_flat_index]',
|
||||||
|
'filled_rgb = filled_rgb.reshape(rgb.shape)',
|
||||||
|
'bgr = cv2.cvtColor(filled_rgb, cv2.COLOR_RGB2BGR)',
|
||||||
|
'mask = np.zeros(alpha.shape, dtype=np.uint8)',
|
||||||
|
'cv2.ellipse(mask, (383, 763), (23, 26), 0, 0, 360, 255, -1)',
|
||||||
|
'cv2.ellipse(mask, (648, 762), (23, 26), 0, 0, 360, 255, -1)',
|
||||||
|
'mask = cv2.bitwise_and(mask, opaque.astype(np.uint8) * 255)',
|
||||||
|
'repaired = cv2.inpaint(bgr, mask, 7, cv2.INPAINT_TELEA)',
|
||||||
|
'repaired_rgb = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB)',
|
||||||
|
'Image.fromarray(np.dstack([repaired_rgb, alpha]), "RGBA").save(out)',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to remove cat body shoulder dots: ${(result.stderr || result.stdout).trim()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function writeOpaquePng(sourcePath, outputPath) {
|
function writeOpaquePng(sourcePath, outputPath) {
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
'python',
|
'python',
|
||||||
@@ -1291,6 +1491,54 @@ async function generateAsset(asset, env, size, force) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.has('--postprocess-only')) {
|
if (args.has('--postprocess-only')) {
|
||||||
|
if (asset.localPostprocess === 'character-outline-white-thin') {
|
||||||
|
const sourcePath = sourceOutputPathFor(asset);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
mkdirSync(assetDir, { recursive: true });
|
||||||
|
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
ok: true,
|
||||||
|
file: finalPath,
|
||||||
|
sourceFile: sourcePath,
|
||||||
|
postprocessedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.localPostprocess === 'character-outline-only') {
|
||||||
|
const sourcePath = sourceOutputPathFor(asset);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
mkdirSync(assetDir, { recursive: true });
|
||||||
|
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
ok: true,
|
||||||
|
file: finalPath,
|
||||||
|
sourceFile: sourcePath,
|
||||||
|
postprocessedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
|
||||||
|
const sourcePath = sourceOutputPathFor(asset);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
mkdirSync(assetDir, { recursive: true });
|
||||||
|
removeCatBodyShoulderDots(sourcePath, finalPath);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
ok: true,
|
||||||
|
file: finalPath,
|
||||||
|
sourceFile: sourcePath,
|
||||||
|
postprocessedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!asset.transparent) {
|
if (!asset.transparent) {
|
||||||
return {
|
return {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
@@ -1328,6 +1576,54 @@ async function generateAsset(asset, env, size, force) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.localPostprocess === 'character-outline-white-thin') {
|
||||||
|
const sourcePath = sourceOutputPathFor(asset);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
mkdirSync(assetDir, { recursive: true });
|
||||||
|
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
ok: true,
|
||||||
|
file: finalPath,
|
||||||
|
sourceFile: sourcePath,
|
||||||
|
postprocessedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.localPostprocess === 'character-outline-only') {
|
||||||
|
const sourcePath = sourceOutputPathFor(asset);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
mkdirSync(assetDir, { recursive: true });
|
||||||
|
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
ok: true,
|
||||||
|
file: finalPath,
|
||||||
|
sourceFile: sourcePath,
|
||||||
|
postprocessedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
|
||||||
|
const sourcePath = sourceOutputPathFor(asset);
|
||||||
|
if (!existsSync(sourcePath)) {
|
||||||
|
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
|
||||||
|
}
|
||||||
|
mkdirSync(assetDir, { recursive: true });
|
||||||
|
removeCatBodyShoulderDots(sourcePath, finalPath);
|
||||||
|
return {
|
||||||
|
id: asset.id,
|
||||||
|
ok: true,
|
||||||
|
file: finalPath,
|
||||||
|
sourceFile: sourcePath,
|
||||||
|
postprocessedOnly: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody = buildRequestBody(asset, size);
|
const requestBody = buildRequestBody(asset, size);
|
||||||
const payloadText = await fetchWithTimeout(
|
const payloadText = await fetchWithTimeout(
|
||||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||||
@@ -1427,6 +1723,7 @@ function dryRun(selectedAssets, size) {
|
|||||||
? sourceOutputPathFor(asset)
|
? sourceOutputPathFor(asset)
|
||||||
: undefined,
|
: undefined,
|
||||||
transparent: asset.transparent,
|
transparent: asset.transparent,
|
||||||
|
localPostprocess: asset.localPostprocess,
|
||||||
body: {
|
body: {
|
||||||
...body,
|
...body,
|
||||||
image: body.image ? ['<local style reference image>'] : undefined,
|
image: body.image ? ['<local style reference image>'] : undefined,
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ const TARGETS = [
|
|||||||
'src',
|
'src',
|
||||||
'module_bindings',
|
'module_bindings',
|
||||||
),
|
),
|
||||||
|
entryFile: path.join(
|
||||||
|
REPO_ROOT,
|
||||||
|
'server-rs',
|
||||||
|
'crates',
|
||||||
|
'spacetime-client',
|
||||||
|
'src',
|
||||||
|
'module_bindings.rs',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -64,6 +72,7 @@ for (const target of selectedTargets) {
|
|||||||
|
|
||||||
console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
|
console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
|
||||||
await replaceGeneratedDir(tempOutDir, target.outDir);
|
await replaceGeneratedDir(tempOutDir, target.outDir);
|
||||||
|
await moveGeneratedEntryFile(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(tempRoot, {recursive: true, force: true});
|
await rm(tempRoot, {recursive: true, force: true});
|
||||||
@@ -111,6 +120,23 @@ async function replaceGeneratedDir(fromDir, toDir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveGeneratedEntryFile(target) {
|
||||||
|
if (!target.entryFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertInside(target.entryFile, REPO_ROOT, '生成入口文件');
|
||||||
|
const generatedModFile = path.join(target.outDir, 'mod.rs');
|
||||||
|
|
||||||
|
if (!existsSync(generatedModFile)) {
|
||||||
|
throw new Error(`${target.name} bindings 缺少入口文件: ${generatedModFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(target.entryFile, {force: true});
|
||||||
|
await cp(generatedModFile, target.entryFile, {force: true});
|
||||||
|
await rm(generatedModFile, {force: true});
|
||||||
|
}
|
||||||
|
|
||||||
function assertInside(candidate, parent, label) {
|
function assertInside(candidate, parent, label) {
|
||||||
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
||||||
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
@@ -156,6 +158,12 @@ const handsConcepts = [
|
|||||||
prompt:
|
prompt:
|
||||||
'围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。',
|
'围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-hands-cradle-v2',
|
||||||
|
title: '托星软掌',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。图形是上下两片圆润软托,托住中央一颗小星,像把灵感轻轻捏成作品。不要画具体手指,只保留抽象软掌感觉。适合 App icon,简单、亲和、醒目、小尺寸清楚。配色:珊瑚红、薄荷青、奶油白,最多三色。不要播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D、碎元素。',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'taonier-hands-soft-bowl',
|
id: 'taonier-hands-soft-bowl',
|
||||||
title: '创意托碗',
|
title: '创意托碗',
|
||||||
@@ -176,6 +184,464 @@ const handsConcepts = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const broadConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-clay-dot-crown',
|
||||||
|
title: '泥点皇冠',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲互动内容平台,用户用“泥点”驱动 AI,把一句脑洞、一张图或一个梗捏成小游戏和可分享作品。本方向把“泥点”做成核心品牌符号:3 到 5 个圆润泥点自然聚合,形成一个像皇冠、火苗、作品星核之间的抽象主轮廓,表达很多灵感汇聚成精品作品。整体必须像成熟 App 主标,亲和、明亮、可注册感强,小尺寸清楚。避免播放三角、聊天气泡、笑脸、真实陶艺、褐色陶土主色、人物、手、复杂碎点。风格:flat vector logo, bold simple silhouette, modern consumer app, warm, memorable, scalable, solid colors。配色:珊瑚红、奶油白、青绿色、少量金色,最多 4 色。无文字、无字母、无水印、无 3D、无厚阴影、无玻璃高光。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-soft-portal',
|
||||||
|
title: '软泥入口',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品把 AI 创作、UGC、小游戏、视觉小说、拼图和轻互动作品放在同一平台内,核心感觉是“打开一个软软的创作入口,进去就能造作品”。图形主体是一枚被捏开的柔软入口/门洞,外轮廓像软泥被拉开,中心留出干净负形作品核或小星点。图形要完整、抽象、主流,不像播放器、不像聊天框、不像眼睛。风格:flat vector brand mark, simple, iconic, friendly premium, strong silhouette, app icon ready。配色使用亮珊瑚、薄荷青、奶油白、深墨中的 3 色。禁止中文字、英文字母、真实门、真实陶土、3D、复杂纹理、碎小装饰、UI 按钮。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-work-embryo',
|
||||||
|
title: '作品胚芽',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。品牌隐喻不是传统陶艺,而是“灵感胚胎被 AI 塑形成可玩的作品”。图形主体是一颗圆润的作品胚芽:外形像软泥种子、游戏棋子和小宇宙的结合,内部只有一条柔软切面和一个小星点负形。整体高级、温柔、年轻,适合平台主 Logo 和 App icon。避免植物叶子过强、教育儿童感、播放按钮、聊天气泡、笑脸、循环箭头、褐色主色。风格:flat vector, premium friendly app logo, minimal, bold, clear at 32px, solid colors。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,最多 3 色。无文字、无字母、无水印、无 3D、无照片质感。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-game-mold',
|
||||||
|
title: '游戏模芯',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品不是工具后台,而是能把脑洞生成拼图、抓大鹅、视觉小说、文字游戏等互动作品的平台。本方向用“游戏模芯”做符号:一个圆润软泥主形中嵌入极简十字方向键或小方块负形,但不要画传统手柄,不要出现播放三角。图形要表达可玩、轻休闲、低门槛创作,同时保持品牌主标感。风格:flat vector logo, simple geometric, friendly, playful but mature, app icon, high contrast。配色:珊瑚红、青绿、奶油白、深墨,最多 4 色。禁止文字、字母、水印、3D、复杂按钮、真实手柄、聊天气泡、笑脸、儿童玩具感。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-tao-negative',
|
||||||
|
title: '陶字负形',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试从“陶”的结构提炼抽象负形,但不要直接写汉字,也不要让模型生成可读文字。图形主体是一枚圆润软泥徽标,内部用两到三块负形构成类似陶器开口、耳部、土块和作品核的抽象关系,让熟悉中文的人隐约感到“陶”,但第一眼仍是现代 App 标志。风格:flat vector brand symbol, abstract Chinese-inspired, clean, iconic, friendly premium, scalable。配色:深墨或莓红主形,奶油白负形,青绿小点缀。禁止真实汉字、书法、篆刻、传统印章、褐色陶艺、播放按钮、聊天气泡、人物、3D、水印。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-soft-totem',
|
||||||
|
title: '软体图腾',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。基于 Taonier / 陶泥儿 的品牌声母感觉做一个抽象软体图腾,但不要直接画英文字母 T,也不要生成任何文字。图形由一笔连续的圆润软泥带形成稳定的竖向图腾,顶部像被轻捏出的小角,中心有一颗作品星核负形,表达“捏、造、发布”。整体要比普通字母标更独特,适合 App icon、favicon 和平台顶栏。风格:flat vector logo, bold, simple, modern, friendly, memorable, solid colors。配色:珊瑚红主形、奶油白负形、薄荷青小面积辅助。禁止文字、字母直出、播放三角、聊天气泡、笑脸、无限循环、褐色陶土、3D、复杂纹理。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-creation-spark',
|
||||||
|
title: '开捏火花',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。核心动作为“开捏”:用户输入灵感,AI 立刻生成可玩的作品。图形不要画真实手,用两块极简软形挤压出中心火花,火花不是爆炸特效,而是一个稳定的四角作品星核。外轮廓要比上一轮左右括号更完整,像一个独立品牌图腾。风格:flat vector logo, iconic, minimal, high contrast, friendly, youthful, app icon ready。配色:莓红或珊瑚红主形,奶油白负形,青绿中心点缀,最多 3 色。禁止文字、字母、水印、播放三角、聊天气泡、笑脸、眼睛、真实手指、碎粒、3D、厚阴影。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-broad-content-orbit',
|
||||||
|
title: '作品星轨',
|
||||||
|
prompt:
|
||||||
|
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品承载多种互动内容:RPG、拼图、抓大鹅、视觉小说、文字游戏、儿童寓教于乐。图形用一个软泥圆核和两条极简短弧形成“作品星轨”,表达一个灵感生成多个作品形态;但整体必须是一个凝聚的主标,不是天文图标。风格:flat vector brand mark, simple, premium friendly, clean geometry, app icon, scalable。配色:青绿主核、珊瑚红弧线、奶油白负形、深墨小轮廓可选。禁止文字、字母、水印、真实星球、复杂轨道、科技冷硬、播放键、聊天气泡、循环箭头、3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const freshConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-wheel-imprint',
|
||||||
|
title: '陶轮印记',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:俯视一个正在旋转的创作轮盘,圆环被轻轻压出一处缺口,像把灵感旋成作品。成熟消费级 App 主标,几何、干净、有速度感。配色:钴蓝、奶白、珊瑚红、少量深墨。不要软手、星核、聊天气泡、播放键、笑脸、真实陶艺、褐色、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-mold-window',
|
||||||
|
title: '模具窗格',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆角模具窗口,内部是 2x2 的不规则负形窗格,像多种小游戏和互动作品从同一个模具里生成。主流、简洁、品牌感强、小尺寸清楚。配色:深墨主形、奶油白负形、亮青绿和珊瑚小点缀。不要软手、星星、播放键、聊天气泡、脸、真实陶土、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-dot-dice',
|
||||||
|
title: '泥点骰面',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一枚圆润方形骰面或游戏牌面,5 个泥点孔组成独特节奏,表达泥点、玩法和随机脑洞。不要画立体骰子,只要正面抽象符号。潮流、轻游戏、可注册。配色:象牙白底、黑色主形、荧光青、珊瑚红。不要播放键、聊天气泡、笑脸、星星、软手、褐色、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-pinwheel',
|
||||||
|
title: '灵感风车',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:抽象纸风车,由四片圆润色块围成旋转中心,表达简单、轻松、人人能造内容。它要像品牌主标,不像儿童玩具。配色:莓红、天蓝、薄荷、奶白、深墨。不要软泥团、手、星核、播放键、聊天气泡、笑脸、花朵、文字、字母、3D、复杂渐变。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-pocket-world',
|
||||||
|
title: '口袋世界',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个抽象口袋形徽标,口袋里露出一小块世界切片或舞台切片,表示把脑洞装进口袋随手开玩。现代、亲和、平台感强。配色:青绿色主形、奶白负形、珊瑚红小块、深墨轮廓。不要软手、星核、播放键、聊天气泡、笑脸、地图图钉、真实口袋、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-builder-blocks',
|
||||||
|
title: '创作积木',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:三块圆角积木以不对称方式咬合,形成一个稳定主轮廓,表达 UGC 搭建、模板生成和小游戏创作。不要儿童玩具感,要成熟、潮流、清晰。配色:黑色或深紫主轮廓,珊瑚、青绿、奶白填色。不要软手、星星、播放键、聊天气泡、笑脸、褐色、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-stage-window',
|
||||||
|
title: '叙事舞台窗',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个极简舞台窗或小剧场窗口,左右两片抽象幕布形成负形中心,代表视觉小说、RPG 和互动叙事。它要是 App icon 主标,不是插画。配色:深墨、珊瑚红、奶油白、少量湖蓝。不要播放键、聊天气泡、笑脸、软手、星核、真实舞台、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-ribbon-knot',
|
||||||
|
title: '灵感绳结',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一条圆润彩色泥条打成简洁绳结,像把多个创意线索系成一个作品。形状必须凝聚成单个主标,不能散。配色:珊瑚、钴蓝、薄荷、奶白,边缘干净。不要无限符号、软手、星核、播放键、聊天气泡、笑脸、褐色陶土、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-folded-sticker',
|
||||||
|
title: '贴纸折角',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一张圆角贴纸或作品卡片,右上角轻轻折起,负形像一个小入口。表达 UGC、作品发布、随手开玩。成熟、潮流、极简。配色:奶白、黑、珊瑚、青绿。不要播放键、聊天气泡、笑脸、手、星星、褐色、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-fresh-punch-hole',
|
||||||
|
title: '印模孔洞',
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆润印模形状,中间被冲出一个不规则圆孔,像从泥板里取出作品。抽象、强轮廓、可注册、小尺寸清楚。配色:黑色主形、奶白负形、荧光青小块、珊瑚红。不要播放键、聊天气泡、笑脸、手、星星、陶罐、文字、字母、3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const punchReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-fresh-concepts',
|
||||||
|
'taonier-fresh-punch-hole.png',
|
||||||
|
);
|
||||||
|
const punch04ReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-punch-hole-concepts',
|
||||||
|
'taonier-punch-color-inlay.png',
|
||||||
|
);
|
||||||
|
const paletteRefineReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-transfer',
|
||||||
|
'taonier-ref04-palette-transfer-warm-yellow-sparkle.png',
|
||||||
|
);
|
||||||
|
const paletteShapeReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-locked-color-concepts',
|
||||||
|
'taonier-ref04-locked-warm-ink.png',
|
||||||
|
);
|
||||||
|
const sparkleRefineReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-warm-sparkle-v2-concepts',
|
||||||
|
'taonier-ref04-warm-sparkle-terracotta.png',
|
||||||
|
);
|
||||||
|
const sparkleCropReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-concepts',
|
||||||
|
'taonier-sparkle-reference-crop.png',
|
||||||
|
);
|
||||||
|
const paletteRefineV2ReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-v2-concepts',
|
||||||
|
'taonier-ref04-palette-refine-v2-pale-cream.png',
|
||||||
|
);
|
||||||
|
const paletteRefineV4PaleButterReferencePath = path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-v4-concepts',
|
||||||
|
'taonier-ref04-palette-refine-v4-pale-butter.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
const punchConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-punch-locked-shape',
|
||||||
|
title: '原型锁定微调',
|
||||||
|
referenceImages: [punchReferencePath],
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”继续打磨参考图 06 印模孔洞 logo。必须保持参考图基本造型不变:黑色圆润不规则环形主形、中央白色不规则孔洞、右上珊瑚红辅形、左下青蓝辅形。只优化比例、边缘、留白和小尺寸识别,让它更像成熟 App icon。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch-stable-icon',
|
||||||
|
title: '稳定主标',
|
||||||
|
referenceImages: [punchReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图 06 印模孔洞,为“陶泥儿”做无文字扁平矢量 logo 延展。保留黑色冲孔主形和中央不规则白洞,但让外轮廓更稳定、更像长期品牌主标。右上珊瑚红和左下青蓝辅形更克制,白底,强轮廓,小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch-hole-balance',
|
||||||
|
title: '孔洞比例',
|
||||||
|
referenceImages: [punchReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图 06 印模孔洞,为“陶泥儿”延展一个更干净的无文字 logo。核心仍是黑色圆润印模环和中央不规则白色孔洞,重点调整孔洞大小、厚薄关系和负形节奏,让黑形更有张力。珊瑚红、青蓝只作为小面积辅形。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch-color-inlay',
|
||||||
|
title: '彩色嵌合',
|
||||||
|
referenceImages: [punchReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图 06 印模孔洞,为“陶泥儿”做彩色嵌合版 logo。黑色主环保持冲孔感,右上珊瑚红和左下青蓝两块辅形与主形更自然嵌合,像从泥板里取出的两片作品碎片。造型简洁、可注册、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch-mono-test',
|
||||||
|
title: '单色测试',
|
||||||
|
referenceImages: [punchReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图 06 印模孔洞,为“陶泥儿”做单色极简版 logo。只保留黑色圆润冲孔主形和中央白色不规则孔洞,去掉彩色辅形。强调强轮廓、可注册、小尺寸识别和品牌符号感。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch-app-token',
|
||||||
|
title: '应用图标',
|
||||||
|
referenceImages: [punchReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图 06 印模孔洞,为“陶泥儿”延展一个更完整的 App icon 核心图形。黑色不规则冲孔主形更饱满,中央白洞更清晰,珊瑚红与青蓝辅形保持年轻感但不抢主体。整体像可长期使用的品牌符号,不像插画。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const punch04Concepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-punch04-warm-ink-core',
|
||||||
|
title: '暖墨填芯',
|
||||||
|
referenceImages: [punch04ReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图“04 彩色嵌合”为“陶泥儿”继续做 logo 延展。保持原有基本结构不变:一个圆润不规则环形主形,右上珊瑚红嵌合块,左下青蓝嵌合块,中央不规则孔洞。重点调整配色:中间黑色主形改为温暖深墨灰,不要纯黑;中央孔洞内部加入一枚很简洁的奶油色软泥种子/作品核填充,不要填满,保留留白呼吸。扁平矢量、品牌主标、小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch04-navy-game-core',
|
||||||
|
title: '靛蓝作品核',
|
||||||
|
referenceImages: [punch04ReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图“04 彩色嵌合”为“陶泥儿”设计一版配色延展。保持黑环、右上红块、左下青块的基本结构和嵌合关系,但把主形从黑色改为深靛蓝或蓝黑色,整体更年轻、更像互联网 App。中央空心区域加入一个极简浅色作品核:小圆角方块或软形小岛,不能像播放键、不能像字母。白底,扁平矢量,干净可注册。无文字、无字母、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch04-cream-window',
|
||||||
|
title: '奶油内窗',
|
||||||
|
referenceImages: [punch04ReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图“04 彩色嵌合”为“陶泥儿”做一版更柔和的 logo。基本结构不变:主环、右上珊瑚红、左下青蓝、中央孔洞都保留。把原黑色主环调整为柔和深紫灰或墨绿色,降低硬度。中央孔洞不再是纯空白,设计成奶油色内窗,里面有两块极简小色面,表达多个作品从同一模具生成。整体仍然极简,不要复杂插画。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch04-clay-gradient-flat',
|
||||||
|
title: '陶盒彩芯',
|
||||||
|
referenceImages: [punch04ReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图“04 彩色嵌合”为“陶泥儿”做配色与中孔设计。保持 04 的基本轮廓和红青嵌合块位置。主形不要纯黑,改成深陶紫、莓紫或炭灰紫,仍保持强轮廓。中央孔洞加入一个扁平的彩色泥芯,由珊瑚、青蓝、奶白三块圆润小面组成,像作品被捏出来的内核。不要渐变高光,不要立体,不要复杂细节。无文字、无字母、无播放键、无聊天气泡、无手、无星星。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch04-mint-shadow',
|
||||||
|
title: '薄荷深影',
|
||||||
|
referenceImages: [punch04ReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图“04 彩色嵌合”为“陶泥儿”做一版更清爽的品牌 logo。保持 04 的三块嵌合结构不变。把中间黑色主形改成深青绿/墨绿,右上红块更偏珊瑚,左下青块更偏亮薄荷。中央空心处加入一枚小小的浅黄色或奶白圆角形,像可玩的作品胚,不要过大。整体强识别、轻休闲、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-punch04-negative-tile',
|
||||||
|
title: '内嵌拼片',
|
||||||
|
referenceImages: [punch04ReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于参考图“04 彩色嵌合”为“陶泥儿”做一版中间内容更明确的 logo。保持外部基本结构和红青嵌合块位置不变。主形从纯黑改为深墨蓝灰。中央不规则孔洞内部放入一个极简拼片/圆角模块组合,表示拼图、小游戏、互动作品,但必须非常简洁,不能像 UI 图标堆叠。白底,扁平矢量,主标感强。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const paletteRefineConcepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-butter',
|
||||||
|
title: '淡黄黄油',
|
||||||
|
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”继续调整 REF-04 配色迁移版。必须锁定参考图一的外轮廓和分区:主形、右上红块、左下青块和中间孔洞都保持不变;把中间主形改成温暖、低饱和、很淡的黄油黄或奶油黄,不要脏黄、土黄、芥末黄或偏橙黄。中间的星星必须保持参考图二的原样:四角闪光星,带短小光芒,不能拉伸成细长十字,不能变成五角星,不能加厚底托。整体要像成熟、干净、轻松的品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-cream',
|
||||||
|
title: '奶油淡黄',
|
||||||
|
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
|
||||||
|
prompt:
|
||||||
|
'基于 REF-04 造型锁定版和四角闪光星参考图,生成一版更高级的暖黄配色。保持图一的造型完全不变,只把中间主形改成低饱和奶油淡黄,颜色要轻、透、干净,避免脏、沉、厚。中心星星完全沿用参考图二的四角闪光样式和短光芒,不要拉伸,不要变形,不要变成五角星。红块和青块保持现有位置与比例。白底、扁平、品牌标志感。无文字、无字母、无手、无播放键、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-biscuit',
|
||||||
|
title: '饼干淡黄',
|
||||||
|
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
|
||||||
|
prompt:
|
||||||
|
'继续基于 REF-04 造型锁定版做色彩优化。外轮廓、红青辅形、中孔边界全部锁住不变;中间主形换成更淡的饼干黄、奶油黄或浅麦黄,必须低饱和、暖而不脏。中心填充严格使用参考图二的四角闪光星和短光芒,保持原样,不许被拉长,也不许改成几何五角星。整体要简洁、轻盈、专业。无文字、无字母、无聊天气泡、无3D、无复杂阴影。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-milk',
|
||||||
|
title: '牛奶暖黄',
|
||||||
|
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
|
||||||
|
prompt:
|
||||||
|
'在 REF-04 锁形轮廓上做最后一轮暖黄微调。只改中间主形的颜色,把它变成接近牛奶、黄油、奶霜的浅暖黄,低饱和、柔和、干净,不要土气,不要发灰。中间星星必须保持参考图二的四角闪光星原型和短光芒,不能被拉伸,不能变瘦,不能加底托。红青两块辅形位置不动。白底,极简 logo。无文字、无字母、无手、无播放键、无3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const paletteRefineV2Concepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v2-soft-butter',
|
||||||
|
title: '柔和奶黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”修正 REF-04 配色迁移版。严格锁定参考图一的造型和分区:不改变外轮廓、不改变右上辅形、不改变左下辅形、不改变中央孔洞边界。只做两处调整:1)把中间主形改成温暖、低饱和、淡淡的奶油黄/黄油黄,颜色要高级、轻、干净,绝对不要土黄、脏黄、芥末黄、焦糖黄、偏橙黄;2)中心空洞里的星星必须使用参考图三的原始四角闪光星和短光芒,保持饱满菱形闪光,不要拉伸成十字,不要变成五角星,不要加底托。保持白底和扁平 logo。无文字、无字母、无手、无播放键、无聊天气泡、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v2-pale-cream',
|
||||||
|
title: '浅奶油黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'基于三张参考图生成一版修正版 logo:参考图一只用于锁定 REF-04 造型;参考图二只用于当前粉红与薄荷青位置;参考图三用于中心星星样式。中间主形颜色改为低饱和浅奶油黄,接近柔和奶霜,不要土气、不要脏、不要高饱和。中心星星必须照参考图三,四角闪光星带短光芒,比例自然饱满,不能被压扁或拉长。外轮廓和孔洞边界不变。白底、干净、成熟品牌 logo。无文字、无字母、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v2-light-vanilla',
|
||||||
|
title: '香草淡黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'继续优化 REF-04 造型锁定 logo。必须保持参考图一的所有轮廓位置,只把中间原黑色区域换成温暖低饱和的香草淡黄,颜色像轻柔黄油、奶油纸、浅米黄,不能像陶土、咖啡、焦糖或芥末。中心空洞填入参考图三的星星:圆润四角闪光、短小光芒、自然比例,不要变瘦,不要拉伸,不要五角星。粉红和薄荷青辅形沿用参考图二的气质。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const paletteRefineV3Concepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v3-butter-soft',
|
||||||
|
title: '淡奶油黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineV2ReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”继续修正 REF-04 的配色迁移版。锁定参考图一的外轮廓、红块、青块和孔洞边界不动;把中间主形调成更高级的淡奶油黄、黄油白黄或柔软黄米色,颜色要更淡一点、更轻一点、更透一点,不要土黄、脏黄、焦糖黄、芥末黄,也不要偏橙偏褐。中心空洞使用参考图三的星星:必须是饱满的四角闪光星,带短小光芒,不能被拉长成细十字,不能变成五角星,也不能出现厚底托。整体保持白底、扁平、品牌 logo 感。无文字、无字母、无3D、无聊天气泡。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v3-milk-cream',
|
||||||
|
title: '奶霜淡黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineV2ReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'基于三张参考图输出一版更轻的 REF-04 logo。第一张参考只负责锁定原始造型;第二张参考只负责当前配色关系;第三张参考只负责中心闪光星的样子。中间主形改成低饱和的奶霜淡黄,颜色要轻柔、通透、像淡淡的黄油和牛奶混合,不要土、不要厚、不要脏。星星保持参考图三的四角闪光星和短光芒,不许拉伸,不许变形,不许五角星化。红块和青块位置固定。无文字、无字母、无手、无播放键、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v3-soft-vanilla',
|
||||||
|
title: '香草奶黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineV2ReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'继续保持 REF-04 的造型锁定,做一次更安静的暖黄修正。中间主形变成香草奶黄或浅奶油黄,必须是低饱和、柔和、高级的淡黄,不要像土黄、咖喱黄、焦糖黄或偏橙黄。中心填充沿用参考图三的四角闪光星,星体要圆润饱满,旁边的短光芒保留,但不能夸张,不能拉长。外轮廓完全不动。白底、logo 感、扁平。无文字、无字母、无聊天气泡、无3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const paletteRefineV4Concepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v4-cream-paper',
|
||||||
|
title: '奶油纸淡黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'继续用 image-2 修正“陶泥儿” REF-04 logo。参考图一只用于锁定 REF-04 原型:外轮廓、右上粉红块、左下薄荷青块、中央孔洞边界都不要重新设计;参考图二只说明当前需要修正的版本;参考图三只用于中心星星。把中间原本土黄/脏黄的主形改成温暖、低饱和、淡淡的奶油纸黄色,接近 #F3E5B4 或 #F6E9C5,颜色要轻、干净、高级,不要陶土黄、芥末黄、咖喱黄、焦糖黄、橙黄、棕黄。中心孔洞里的星星必须保持参考图三原本的四角闪光星比例:上下左右四个圆润尖角,宽高自然,不能被横向或纵向拉伸,不能变成细十字,不能变成五角星,旁边短光芒也保持短小。白底、扁平品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v4-warm-ivory',
|
||||||
|
title: '暖象牙淡黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'基于三张参考图输出一版 REF-04 精修 logo。第一张参考图的形状和分区必须优先:主形轮廓不改、粉红块和薄荷青块位置不改、中间白色孔洞不改;只把中间主形从现在偏土的黄改成暖象牙淡黄,像很淡的黄油白、奶油米白、暖白纸,低饱和、柔和、通透,不要厚重和脏感。第三张参考图的四角闪光星需要原样放进中心:星体不能被压扁、不能拉长、不能瘦成十字,短光芒不要变多。整体保持成熟、干净、可做 App icon 的扁平 logo。无文字、无字母、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v4-soft-champagne',
|
||||||
|
title: '淡香槟暖黄',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineV2ReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”做一版更高级的 REF-04 暖黄精修。参考图一锁定基本造型,不允许改成播放按钮、三角形、气泡或新图标;参考图二只参考淡黄的轻盈程度;参考图三锁定星星。中间主形使用低饱和淡香槟黄/奶霜黄,颜色要非常淡、温暖、干净,不能像泥土、咖喱、焦糖、芥末或橙棕。中心星星必须是参考图三那种饱满四角闪光,保留短光芒,按原始宽高比例绘制,不能拉伸、不能变形、不能五角星化。粉红和薄荷青辅形保持克制。白底、扁平、品牌主标感。无文字、无字母、无3D、无复杂阴影。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v4-pale-butter',
|
||||||
|
title: '淡黄油暖白',
|
||||||
|
referenceImages: [
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
paletteRefineV2ReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'继续调整 REF-04 配色版本,只修正颜色和中心星星,不重画 logo。外轮廓、三块嵌合关系、中央孔洞边界以参考图一为准;中间主形换成淡黄油暖白,像轻薄奶油、温暖米白、浅黄纸,低饱和、不土、不脏、不橙、不褐。中心孔洞填入参考图三原样的四角闪光星:星星要圆润饱满,四个尖角长度均衡,短光芒短而自然,不能拉伸成细长十字。保留白底和扁平矢量 logo 气质。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const paletteRefineV5Concepts = [
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v5-filled-centered-spark',
|
||||||
|
title: '填心居中亮星',
|
||||||
|
referenceImages: [
|
||||||
|
paletteRefineV4PaleButterReferencePath,
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'根据参考图修改“陶泥儿”04 图标,保留右上粉红块、左下薄荷青块和整体软泥圆润气质。重点做三处修改:1)补全左侧外轮廓曲线,让左侧从上到下形成连续、顺滑、饱满的弧线,不能有缺口、锯齿、截断或不自然凹陷;2)把中央白色空心孔洞完全用主体同色的温暖低饱和淡奶油黄填平,不能再出现白色中孔、白色环或内窗;3)把参考星星改成明亮的黄色四角闪光星,放在整个淡黄主体的视觉中央,星星清晰、圆润、比例自然,不要五角星,不要拉伸成十字。白底、扁平品牌 logo、干净高级。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v5-smooth-left-small-spark',
|
||||||
|
title: '顺滑左弧小亮星',
|
||||||
|
referenceImages: [
|
||||||
|
paletteRefineV4PaleButterReferencePath,
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'继续精修“陶泥儿”04 图标。以参考图一的 04 配色和比例为基础,但不要保留中央白洞。左侧外边缘需要补成更完整、更协调的连续曲线,像一整块柔软陶泥的自然外轮廓;中间原空心区域必须填成和主形一致的淡奶油黄色,与主体融为一体。中心放一枚明亮黄色四角闪光星,星星略小、居中、干净,不带复杂底托,不是五角星,不是细长十字。粉红块和薄荷青块仍然分离在右上和左下,白色间隔保持干净。无文字、无字母、无3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v5-balanced-bright-spark',
|
||||||
|
title: '平衡亮星',
|
||||||
|
referenceImages: [
|
||||||
|
paletteRefineV4PaleButterReferencePath,
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'为“陶泥儿”输出一版更协调的 04 图标修改稿。主形是温暖、低饱和、淡淡的奶油黄色;请补齐左侧曲线,让左边外轮廓更圆润完整,整体重心更稳。中央空心区域不再留白,必须填平为同样的淡黄色主形。把四角闪光星改成更明亮、更清楚的黄色,准确放在图标中央,星体饱满,四个尖角均衡,可以有很短的小光芒但不要抢主体。保持扁平 logo 感和白底。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taonier-ref04-palette-refine-v5-solid-core-no-hole',
|
||||||
|
title: '实体主形亮星',
|
||||||
|
referenceImages: [
|
||||||
|
paletteRefineV4PaleButterReferencePath,
|
||||||
|
paletteShapeReferencePath,
|
||||||
|
sparkleCropReferencePath,
|
||||||
|
],
|
||||||
|
prompt:
|
||||||
|
'按用户参考图修改 04 logo:把淡黄主形做成一个更完整的实体软泥形。左侧曲线补全并顺滑化,外轮廓不要破碎;原中央白色孔洞完全消失,改成与主形同色的淡奶油黄实体面;在实体面的正中央放一枚明亮黄色四角闪光星,星星比主体颜色更亮,有明确识别但不幼稚。保持右上粉红块和左下薄荷青块的年轻配色,整体干净、轻盈、品牌主标感。无文字、无字母、无内孔、无白色中窗、无五角星、无播放键、无3D。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const args = new Map();
|
const args = new Map();
|
||||||
for (let index = 2; index < process.argv.length; index += 1) {
|
for (let index = 2; index < process.argv.length; index += 1) {
|
||||||
const raw = process.argv[index];
|
const raw = process.argv[index];
|
||||||
@@ -201,6 +667,24 @@ const concepts =
|
|||||||
? magicDotConcepts
|
? magicDotConcepts
|
||||||
: style === 'hands'
|
: style === 'hands'
|
||||||
? handsConcepts
|
? handsConcepts
|
||||||
|
: style === 'broad'
|
||||||
|
? broadConcepts
|
||||||
|
: style === 'fresh'
|
||||||
|
? freshConcepts
|
||||||
|
: style === 'punch'
|
||||||
|
? punchConcepts
|
||||||
|
: style === 'punch04'
|
||||||
|
? punch04Concepts
|
||||||
|
: style === 'palette-refine'
|
||||||
|
? paletteRefineConcepts
|
||||||
|
: style === 'palette-refine-v2'
|
||||||
|
? paletteRefineV2Concepts
|
||||||
|
: style === 'palette-refine-v3'
|
||||||
|
? paletteRefineV3Concepts
|
||||||
|
: style === 'palette-refine-v4'
|
||||||
|
? paletteRefineV4Concepts
|
||||||
|
: style === 'palette-refine-v5'
|
||||||
|
? paletteRefineV5Concepts
|
||||||
: dimensionalConcepts;
|
: dimensionalConcepts;
|
||||||
const selectedOutputDir =
|
const selectedOutputDir =
|
||||||
style === 'flat'
|
style === 'flat'
|
||||||
@@ -221,6 +705,69 @@ const selectedOutputDir =
|
|||||||
'branding',
|
'branding',
|
||||||
'taonier-logo-hands-concepts',
|
'taonier-logo-hands-concepts',
|
||||||
)
|
)
|
||||||
|
: style === 'broad'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-broad-concepts',
|
||||||
|
)
|
||||||
|
: style === 'fresh'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-fresh-concepts',
|
||||||
|
)
|
||||||
|
: style === 'punch'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-punch-hole-concepts',
|
||||||
|
)
|
||||||
|
: style === 'punch04'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-punch04-color-concepts',
|
||||||
|
)
|
||||||
|
: style === 'palette-refine'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-concepts',
|
||||||
|
)
|
||||||
|
: style === 'palette-refine-v2'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-v2-concepts',
|
||||||
|
)
|
||||||
|
: style === 'palette-refine-v3'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-v3-concepts',
|
||||||
|
)
|
||||||
|
: style === 'palette-refine-v4'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-v4-concepts',
|
||||||
|
)
|
||||||
|
: style === 'palette-refine-v5'
|
||||||
|
? path.join(
|
||||||
|
repoRoot,
|
||||||
|
'public',
|
||||||
|
'branding',
|
||||||
|
'taonier-logo-ref04-palette-refine-v5-concepts',
|
||||||
|
)
|
||||||
: outputDir;
|
: outputDir;
|
||||||
|
|
||||||
function readDotenv(fileName) {
|
function readDotenv(fileName) {
|
||||||
@@ -276,6 +823,82 @@ function buildUrl(baseUrl) {
|
|||||||
: `${baseUrl}/v1/images/generations`;
|
: `${baseUrl}/v1/images/generations`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasHeader(headers, targetName) {
|
||||||
|
return Object.keys(headers).some(
|
||||||
|
(name) => name.toLowerCase() === targetName.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestBuffer(url, options, timeoutMs, redirectCount = 0) {
|
||||||
|
const body =
|
||||||
|
typeof options.body === 'string'
|
||||||
|
? Buffer.from(options.body)
|
||||||
|
: options.body || null;
|
||||||
|
const headers = { ...(options.headers || {}) };
|
||||||
|
if (body && !hasHeader(headers, 'content-length')) {
|
||||||
|
headers['Content-Length'] = String(body.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const transport = parsedUrl.protocol === 'http:' ? http : https;
|
||||||
|
const request = transport.request(
|
||||||
|
parsedUrl,
|
||||||
|
{
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const statusCode = response.statusCode || 0;
|
||||||
|
const location = response.headers.location;
|
||||||
|
if (
|
||||||
|
statusCode >= 300 &&
|
||||||
|
statusCode < 400 &&
|
||||||
|
location &&
|
||||||
|
redirectCount < 5
|
||||||
|
) {
|
||||||
|
response.resume();
|
||||||
|
const redirectedUrl = new URL(location, parsedUrl).toString();
|
||||||
|
const preserveBody = statusCode === 307 || statusCode === 308;
|
||||||
|
requestBuffer(
|
||||||
|
redirectedUrl,
|
||||||
|
preserveBody
|
||||||
|
? options
|
||||||
|
: {
|
||||||
|
method: 'GET',
|
||||||
|
headers: options.headers,
|
||||||
|
},
|
||||||
|
timeoutMs,
|
||||||
|
redirectCount + 1,
|
||||||
|
)
|
||||||
|
.then(resolve)
|
||||||
|
.catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
response.on('end', () =>
|
||||||
|
resolve({
|
||||||
|
statusCode,
|
||||||
|
headers: response.headers,
|
||||||
|
bytes: Buffer.concat(chunks),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
request.setTimeout(timeoutMs, () => {
|
||||||
|
request.destroy(new Error(`request timed out after ${timeoutMs}ms`));
|
||||||
|
});
|
||||||
|
request.on('error', reject);
|
||||||
|
if (body) {
|
||||||
|
request.write(body);
|
||||||
|
}
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function collectStringsByKey(value, targetKey, output) {
|
function collectStringsByKey(value, targetKey, output) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
|
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
|
||||||
@@ -331,45 +954,58 @@ function inferExtensionFromBytes(bytes) {
|
|||||||
return 'png';
|
return 'png';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imagePathToDataUrl(imagePath) {
|
||||||
|
if (!existsSync(imagePath)) {
|
||||||
|
throw new Error(`Reference image not found: ${imagePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = readFileSync(imagePath);
|
||||||
|
const extension = path.extname(imagePath).toLowerCase();
|
||||||
|
const mimeType =
|
||||||
|
extension === '.jpg' || extension === '.jpeg'
|
||||||
|
? 'image/jpeg'
|
||||||
|
: extension === '.webp'
|
||||||
|
? 'image/webp'
|
||||||
|
: 'image/png';
|
||||||
|
return `data:${mimeType};base64,${bytes.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJson(url, options, timeoutMs) {
|
async function fetchJson(url, options, timeoutMs) {
|
||||||
const abortController = new AbortController();
|
|
||||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await requestBuffer(url, options, timeoutMs);
|
||||||
...options,
|
const text = response.bytes.toString('utf8');
|
||||||
signal: abortController.signal,
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
});
|
throw new Error(
|
||||||
const text = await response.text();
|
`VectorEngine ${response.statusCode}: ${text.slice(0, 600)}`,
|
||||||
if (!response.ok) {
|
);
|
||||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
|
||||||
}
|
}
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (String(error?.message || '').includes('timed out')) {
|
||||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
throw new Error(
|
||||||
|
`VectorEngine request timed out after ${timeoutMs}ms`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadUrl(url, timeoutMs) {
|
async function downloadUrl(url, timeoutMs) {
|
||||||
const abortController = new AbortController();
|
|
||||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { signal: abortController.signal });
|
const response = await requestBuffer(url, { method: 'GET' }, timeoutMs);
|
||||||
if (!response.ok) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw new Error(`download ${response.status}`);
|
throw new Error(`download ${response.statusCode}`);
|
||||||
}
|
}
|
||||||
return Buffer.from(await response.arrayBuffer());
|
return response.bytes;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (String(error?.message || '').includes('timed out')) {
|
||||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
throw new Error(
|
||||||
|
`Generated image download timed out after ${timeoutMs}ms`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +1016,9 @@ async function generateConcept(env, concept) {
|
|||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024',
|
||||||
};
|
};
|
||||||
|
if (concept.referenceImages?.length) {
|
||||||
|
requestBody.image = concept.referenceImages.map(imagePathToDataUrl);
|
||||||
|
}
|
||||||
const payload = await fetchJson(
|
const payload = await fetchJson(
|
||||||
buildUrl(env.baseUrl),
|
buildUrl(env.baseUrl),
|
||||||
{
|
{
|
||||||
@@ -438,6 +1077,13 @@ if (dryRun) {
|
|||||||
prompt: concept.prompt,
|
prompt: concept.prompt,
|
||||||
n: 1,
|
n: 1,
|
||||||
size: '1024x1024',
|
size: '1024x1024',
|
||||||
|
...(concept.referenceImages?.length
|
||||||
|
? {
|
||||||
|
image: concept.referenceImages.map((imagePath) =>
|
||||||
|
path.relative(repoRoot, imagePath),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ $env:WORKS_DATA="data/works-list.local.json"
|
|||||||
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 50 HTTP req/s 口径
|
||||||
|
|
||||||
|
`k6-works-list.js` 默认一次 iteration 会依次请求两个公开列表接口:`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery`。因此目标约 50 HTTP req/s 时,`ramping-arrival-rate` 的 `PEAK_RPS` 应设置为 `25`。如果传入 `AUTH_TOKEN` 或把 `DETAIL_RATIO` 设为大于 0,每次 iteration 的请求数会增加,需要重新折算。
|
||||||
|
|
||||||
|
验收目标:
|
||||||
|
|
||||||
|
- `http_req_failed < 1%`
|
||||||
|
- `http_req_duration p95 < 2000ms`
|
||||||
|
- `dropped_iterations = 0`
|
||||||
|
- 压测窗口内 Nginx 无新增 502
|
||||||
|
|
||||||
## Smoke
|
## Smoke
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -151,17 +162,38 @@ BASE_URL=http://127.0.0.1:8787 \
|
|||||||
WORKS_DATA=data/works-list.local.json \
|
WORKS_DATA=data/works-list.local.json \
|
||||||
SCENARIO=spike \
|
SCENARIO=spike \
|
||||||
START_RPS=5 \
|
START_RPS=5 \
|
||||||
PEAK_RPS=100 \
|
PEAK_RPS=25 \
|
||||||
HOLD=2m \
|
HOLD=60s \
|
||||||
DETAIL_RATIO=0 \
|
DETAIL_RATIO=0 \
|
||||||
npm run loadtest:k6:works
|
npm run loadtest:k6:works
|
||||||
```
|
```
|
||||||
|
|
||||||
默认阈值:
|
默认阈值:
|
||||||
|
|
||||||
- `http_req_failed < 5%`
|
- `http_req_failed < 1%`
|
||||||
- `http_req_duration p95 < 2000ms`
|
- `http_req_duration p95 < 2000ms`
|
||||||
- `works_list_shape_error_rate < 5%`
|
- `dropped_iterations = 0`
|
||||||
|
- `works_list_shape_error_rate < 1%`
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:BASE_URL="https://genarrative.world"
|
||||||
|
$env:WORKS_DATA="data/works-list.local.json"
|
||||||
|
$env:SCENARIO="spike"
|
||||||
|
$env:START_RPS="5"
|
||||||
|
$env:PEAK_RPS="25"
|
||||||
|
$env:HOLD="60s"
|
||||||
|
$env:END_RPS="5"
|
||||||
|
$env:DETAIL_RATIO="0"
|
||||||
|
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||||
|
```
|
||||||
|
|
||||||
|
线上 release 回归可使用同一组环境变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works
|
||||||
|
```
|
||||||
|
|
||||||
## 带登录态压测个人作品列表
|
## 带登录态压测个人作品列表
|
||||||
|
|
||||||
@@ -194,9 +226,121 @@ npm run loadtest:k6:works
|
|||||||
## 排障
|
## 排障
|
||||||
|
|
||||||
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。
|
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。
|
||||||
|
- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS`。429 表示 api-server 应用层背压已生效,不等同于业务错误;继续看内存、p95、`http_req_failed` 和 OTLP / Nginx timing 判断阈值是否偏低。
|
||||||
|
- 如果直连 `api-server` 压测出现 `connection refused` 或 status 0,说明压力已经打到 TCP 监听 / accept 层;此时同时检查 `GENARRATIVE_API_LISTEN_BACKLOG`、Nginx upstream keepalive 和是否需要在 Nginx 前置限流,不能只靠应用层背压解释。
|
||||||
- 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
|
- 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
|
||||||
- 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
|
- 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
|
||||||
|
|
||||||
|
## 压测窗口采集
|
||||||
|
|
||||||
|
Nginx upstream timing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tail -f /var/log/nginx/genarrative.access.log
|
||||||
|
sudo tail -f /var/log/nginx/genarrative.error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
api-server 与 SpacetimeDB 日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u genarrative-api.service -f
|
||||||
|
sudo journalctl -u spacetimedb.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
api-server 的 OpenTelemetry 默认关闭。需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1` 的 `otelcol-contrib` debug exporter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run otel:debug
|
||||||
|
```
|
||||||
|
|
||||||
|
如果要把本机数据转发给 Rider OpenTelemetry 面板,先在 Rider 的 OpenTelemetry 设置中启用固定 OTLP server port,例如 `17011`,再运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RIDER_OTLP_GRPC_ENDPOINT=127.0.0.1:17011 npm run otel:rider
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会在 `.codex-temp/otelcol/` 生成临时 collector 配置,默认接收 api-server 发到 `http://127.0.0.1:4318` 的 OTLP HTTP 数据。需要改端口时可设置:
|
||||||
|
|
||||||
|
- `OTELCOL_OTLP_HTTP_ENDPOINT`,默认 `127.0.0.1:4318`
|
||||||
|
- `OTELCOL_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:4317`
|
||||||
|
- `RIDER_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:17011`
|
||||||
|
- `OTELCOL_BIN`,默认 `otelcol-contrib`
|
||||||
|
|
||||||
|
等价的 debug collector 配置如下:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 127.0.0.1:4317
|
||||||
|
http:
|
||||||
|
endpoint: 127.0.0.1:4318
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
debug:
|
||||||
|
verbosity: detailed
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
|
logs:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
otelcol-contrib --config /etc/otelcol-contrib/genarrative-debug.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 `/etc/genarrative/api-server.env` 中打开:
|
||||||
|
|
||||||
|
```env
|
||||||
|
GENARRATIVE_OTEL_ENABLED=true
|
||||||
|
OTEL_SERVICE_NAME=genarrative-api
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||||
|
```
|
||||||
|
|
||||||
|
注意 `api-server` 当前使用 OTLP HTTP exporter,`OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint `http://127.0.0.1:4318`。不要把它改成 Collector gRPC 端口 `4317`,也不要直接指向 Rider 的 gRPC 端口;Rider 只由 `npm run otel:rider` 启动的 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。
|
||||||
|
|
||||||
|
OTLP logs 是远端观测增量,不替代本地日志;api-server 日志仍看 `journalctl` / `logs/api-server/`,Nginx 日志仍看文件。日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。
|
||||||
|
|
||||||
|
Rider 的 Logs 面板展示的是 OTLP 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 关联查看。
|
||||||
|
|
||||||
|
压测期间可在 Metrics 面板或 debug exporter 中观察进程内存指标:
|
||||||
|
|
||||||
|
- `process.memory.usage`:进程常驻内存 / RSS。
|
||||||
|
- `process.memory.virtual`:进程虚拟内存;Windows 当前按 `PrivateUsage` 上报,Linux 取 `VmSize`。
|
||||||
|
- `genarrative.process.memory.private`:进程私有内存,Windows 来自 `PrivateUsage`,Linux 近似取 `/proc/self/status` 的 `VmData`。
|
||||||
|
- `process.thread.count`:线程数。
|
||||||
|
- `process.windows.handle.count`:Windows 句柄数。
|
||||||
|
- `process.unix.file_descriptor.count`:Linux 文件描述符数。
|
||||||
|
- `genarrative.http.server.response_bodies.in_flight`:Axum / Hyper 仍持有的响应 body 数;如果内存高但该值很低,说明热点不在业务 handler 生命周期内。
|
||||||
|
- `genarrative.http.server.request_permits.available`:应用层 HTTP 背压剩余 permit 数;如果该值未接近 0,说明没有打满 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS`。
|
||||||
|
- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存命中、未命中和重建次数。
|
||||||
|
- `genarrative.puzzle_gallery.cache.rebuild.duration`:拼图广场缓存重建耗时。
|
||||||
|
- `genarrative.puzzle_gallery.cache.data_json_bytes`:拼图广场缓存内预序列化 data JSON 大小。
|
||||||
|
- `genarrative.spacetime.read.calls` / `genarrative.spacetime.read.duration_ms`:SpacetimeDB 订阅本地 cache 读次数和耗时;`read=list_puzzle_gallery` 表示当前路径走 view / local cache,不是 procedure。
|
||||||
|
|
||||||
|
若 `/api/runtime/puzzle/gallery` 单接口压测出现 GB 级瞬时内存峰值,先区分“持续泄漏”和“请求期分配峰值”:关闭 OTEL 后若峰值仍复现且压测结束后回落,主因通常不是 Collector / exporter。当前拼图广场列表命中缓存时应复用 `PuzzleGalleryCache` 中的预序列化 data JSON,只按请求拼接 envelope meta,不应每个请求重新深拷贝 `PuzzleGalleryResponse` 或构造完整 `serde_json::Value`。
|
||||||
|
|
||||||
|
本地 Windows 直连 `api-server` 压测还要单独看 K6 的 VU / 连接模型。已验证在 250 RPS、`PREALLOCATED_VUS=300` 时,哪怕打 `/healthz` 这种小响应,也可能因为本地 300 个 Established 连接触发 `api-server` private memory 瞬时升到约 7GB,压测结束后回落到 100MB 级;同样 250 RPS 改成 `PREALLOCATED_VUS=20 MAX_VUS=40` 后,拼图广场 p95 约 9ms,峰值降到约 600MB。这个现象说明高水位主要来自本机直连连接 / 发送链路,不等价于 SpacetimeDB 或拼图 JSON 缓存泄漏。做本地容量判断时优先让 VU 接近真实并发,避免用过高预分配 VU 把测试变成 Windows 本机连接缓冲压力测试;生产仍以 Nginx upstream keepalive、系统内存和 OTLP 指标一起判断。
|
||||||
|
|
||||||
|
线上回归辅助命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax
|
||||||
|
cat /proc/$(pidof api-server)/limits
|
||||||
|
tr '\0' '\n' < /proc/$(pidof api-server)/environ | grep GENARRATIVE_API_MAX_CONCURRENT_REQUESTS
|
||||||
|
ss -ltnp | grep 8082
|
||||||
|
curl -sS http://127.0.0.1:8082/healthz
|
||||||
|
```
|
||||||
|
|
||||||
## 验证命令
|
## 验证命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
218
scripts/loadtest/data/works-list.sample.from-migration-1.json
Normal file
218
scripts/loadtest/data/works-list.sample.from-migration-1.json
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
{
|
||||||
|
"source": "spacetime-migration-1.json",
|
||||||
|
"generatedAt": "2026-05-16T13:35:40.282Z",
|
||||||
|
"counts": {
|
||||||
|
"puzzle_work_profile": 3,
|
||||||
|
"custom_world_profile": 1,
|
||||||
|
"match3d_work_profile": 0,
|
||||||
|
"square_hole_work_profile": 0,
|
||||||
|
"visual_novel_work_profile": 0
|
||||||
|
},
|
||||||
|
"tables": {
|
||||||
|
"puzzle_work_profile": [
|
||||||
|
{
|
||||||
|
"profile_id": "profile-001",
|
||||||
|
"work_id": "work-001",
|
||||||
|
"owner_user_id": "user-001",
|
||||||
|
"author_display_name": "author-001",
|
||||||
|
"cover_asset_id": "asset-001",
|
||||||
|
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||||
|
"work_title": "化学家",
|
||||||
|
"level_name": "文学家",
|
||||||
|
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||||
|
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
|
||||||
|
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
|
||||||
|
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
|
||||||
|
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
|
||||||
|
"publication_status": {
|
||||||
|
"Published": []
|
||||||
|
},
|
||||||
|
"play_count": 1,
|
||||||
|
"like_count": 0,
|
||||||
|
"remix_count": 1,
|
||||||
|
"updated_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777648804043558
|
||||||
|
},
|
||||||
|
"published_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777649364112270
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "profile-002",
|
||||||
|
"work_id": "work-002",
|
||||||
|
"owner_user_id": "user-002",
|
||||||
|
"author_display_name": "author-002",
|
||||||
|
"work_title": "我不知道",
|
||||||
|
"level_name": "",
|
||||||
|
"summary": "你猜我是谁",
|
||||||
|
"work_description": "你猜我是谁",
|
||||||
|
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||||
|
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
|
||||||
|
"theme_tags_json": "[\"我不知道\"]",
|
||||||
|
"publication_status": {
|
||||||
|
"Draft": []
|
||||||
|
},
|
||||||
|
"play_count": 0,
|
||||||
|
"like_count": 0,
|
||||||
|
"remix_count": 0,
|
||||||
|
"updated_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777619336673245
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"profile_id": "profile-003",
|
||||||
|
"work_id": "work-003",
|
||||||
|
"owner_user_id": "user-003",
|
||||||
|
"author_display_name": "author-002",
|
||||||
|
"work_title": "",
|
||||||
|
"level_name": "",
|
||||||
|
"summary": "",
|
||||||
|
"work_description": "",
|
||||||
|
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||||
|
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
|
||||||
|
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
|
||||||
|
"publication_status": {
|
||||||
|
"Draft": []
|
||||||
|
},
|
||||||
|
"play_count": 0,
|
||||||
|
"like_count": 0,
|
||||||
|
"remix_count": 0,
|
||||||
|
"updated_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"custom_world_profile": [
|
||||||
|
{
|
||||||
|
"profile_id": "profile-081",
|
||||||
|
"owner_user_id": "user-002",
|
||||||
|
"author_display_name": "author-012",
|
||||||
|
"author_public_user_code": "author-code-001",
|
||||||
|
"world_name": "青春飞扬校园",
|
||||||
|
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
|
||||||
|
"subtitle": "反内卷的自由学习之旅",
|
||||||
|
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
|
||||||
|
"publication_status": {
|
||||||
|
"Draft": []
|
||||||
|
},
|
||||||
|
"play_count": 0,
|
||||||
|
"like_count": 0,
|
||||||
|
"remix_count": 0,
|
||||||
|
"updated_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777531745887256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"match3d_work_profile": [],
|
||||||
|
"square_hole_work_profile": [],
|
||||||
|
"visual_novel_work_profile": []
|
||||||
|
},
|
||||||
|
"profileIds": {
|
||||||
|
"puzzle": [
|
||||||
|
"profile-001",
|
||||||
|
"profile-002",
|
||||||
|
"profile-003"
|
||||||
|
],
|
||||||
|
"customWorld": [
|
||||||
|
"profile-081"
|
||||||
|
],
|
||||||
|
"match3d": [],
|
||||||
|
"squareHole": [],
|
||||||
|
"bigFish": [],
|
||||||
|
"visualNovel": []
|
||||||
|
},
|
||||||
|
"workIds": {
|
||||||
|
"puzzle": [
|
||||||
|
"work-001",
|
||||||
|
"work-002",
|
||||||
|
"work-003"
|
||||||
|
],
|
||||||
|
"customWorld": [],
|
||||||
|
"match3d": [],
|
||||||
|
"squareHole": [],
|
||||||
|
"bigFish": [],
|
||||||
|
"visualNovel": []
|
||||||
|
},
|
||||||
|
"normalizedWorks": [
|
||||||
|
{
|
||||||
|
"type": "puzzle",
|
||||||
|
"workId": "work-001",
|
||||||
|
"profileId": "profile-001",
|
||||||
|
"ownerUserId": "user-001",
|
||||||
|
"title": "化学家",
|
||||||
|
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||||
|
"publicationStatus": {
|
||||||
|
"Published": []
|
||||||
|
},
|
||||||
|
"playCount": 1,
|
||||||
|
"likeCount": 0,
|
||||||
|
"remixCount": 1,
|
||||||
|
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||||
|
"updatedAt": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "puzzle",
|
||||||
|
"workId": "work-002",
|
||||||
|
"profileId": "profile-002",
|
||||||
|
"ownerUserId": "user-002",
|
||||||
|
"title": "我不知道",
|
||||||
|
"subtitle": "你猜我是谁",
|
||||||
|
"publicationStatus": {
|
||||||
|
"Draft": []
|
||||||
|
},
|
||||||
|
"playCount": 0,
|
||||||
|
"likeCount": 0,
|
||||||
|
"remixCount": 0,
|
||||||
|
"updatedAt": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "puzzle",
|
||||||
|
"workId": "work-003",
|
||||||
|
"profileId": "profile-003",
|
||||||
|
"ownerUserId": "user-003",
|
||||||
|
"title": "",
|
||||||
|
"subtitle": "",
|
||||||
|
"publicationStatus": {
|
||||||
|
"Draft": []
|
||||||
|
},
|
||||||
|
"playCount": 0,
|
||||||
|
"likeCount": 0,
|
||||||
|
"remixCount": 0,
|
||||||
|
"updatedAt": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "customWorld",
|
||||||
|
"profileId": "profile-081",
|
||||||
|
"ownerUserId": "user-002",
|
||||||
|
"title": "青春飞扬校园",
|
||||||
|
"subtitle": "反内卷的自由学习之旅",
|
||||||
|
"publicationStatus": {
|
||||||
|
"Draft": []
|
||||||
|
},
|
||||||
|
"playCount": 0,
|
||||||
|
"likeCount": 0,
|
||||||
|
"remixCount": 0,
|
||||||
|
"updatedAt": {
|
||||||
|
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -56,20 +56,22 @@ const scenarioOptions = {
|
|||||||
scenarios: {
|
scenarios: {
|
||||||
spike: {
|
spike: {
|
||||||
executor: 'ramping-arrival-rate',
|
executor: 'ramping-arrival-rate',
|
||||||
|
startRate: Number(__ENV.START_RPS || 5),
|
||||||
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
|
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
|
||||||
maxVUs: Number(__ENV.MAX_VUS || 200),
|
maxVUs: Number(__ENV.MAX_VUS || 200),
|
||||||
timeUnit: '1s',
|
timeUnit: '1s',
|
||||||
stages: [
|
stages: [
|
||||||
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
|
{ target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.RAMP_UP || '30s' },
|
||||||
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
|
{ target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.HOLD || '2m' },
|
||||||
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
|
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
thresholds: {
|
thresholds: {
|
||||||
http_req_failed: ['rate<0.05'],
|
http_req_failed: ['rate<0.01'],
|
||||||
http_req_duration: ['p(95)<2000'],
|
http_req_duration: ['p(95)<2000'],
|
||||||
works_list_shape_error_rate: ['rate<0.05'],
|
dropped_iterations: ['count==0'],
|
||||||
|
works_list_shape_error_rate: ['rate<0.01'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
119
scripts/run-otelcol.mjs
Normal file
119
scripts/run-otelcol.mjs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {spawn} from 'node:child_process';
|
||||||
|
import {mkdirSync, writeFileSync} from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const [, , rawMode = 'debug', ...args] = process.argv;
|
||||||
|
const mode = rawMode.trim();
|
||||||
|
const printConfigOnly = args.includes('--print-config');
|
||||||
|
|
||||||
|
const supportedModes = new Set(['debug', 'rider']);
|
||||||
|
if (!supportedModes.has(mode)) {
|
||||||
|
console.error('[otelcol] mode must be one of: debug, rider');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otlpHttpEndpoint = readEnv('OTELCOL_OTLP_HTTP_ENDPOINT', '127.0.0.1:4318');
|
||||||
|
const otlpGrpcEndpoint = readEnv('OTELCOL_OTLP_GRPC_ENDPOINT', '127.0.0.1:4317');
|
||||||
|
const riderEndpoint = readEnv('RIDER_OTLP_GRPC_ENDPOINT', '127.0.0.1:17011');
|
||||||
|
const debugVerbosity = readEnv('OTELCOL_DEBUG_VERBOSITY', 'detailed');
|
||||||
|
const otelcolBin = readEnv('OTELCOL_BIN', 'otelcol-contrib');
|
||||||
|
|
||||||
|
const configText = buildConfig(mode);
|
||||||
|
const configDir = path.resolve('.codex-temp', 'otelcol');
|
||||||
|
const configPath = path.join(configDir, `genarrative-${mode}.yaml`);
|
||||||
|
mkdirSync(configDir, {recursive: true});
|
||||||
|
writeFileSync(configPath, configText, 'utf8');
|
||||||
|
|
||||||
|
console.log(`[otelcol] wrote ${configPath}`);
|
||||||
|
console.log(`[otelcol] receiving OTLP HTTP at http://${otlpHttpEndpoint}`);
|
||||||
|
console.log(`[otelcol] receiving OTLP gRPC at ${otlpGrpcEndpoint}`);
|
||||||
|
if (mode === 'rider') {
|
||||||
|
console.log(`[otelcol] forwarding traces/metrics/logs to Rider OTLP gRPC at ${riderEndpoint}`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'[otelcol] api-server env: GENARRATIVE_OTEL_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (printConfigOnly) {
|
||||||
|
console.log(configText);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(otelcolBin, ['--config', configPath], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopChild = () => {
|
||||||
|
if (!child.killed) {
|
||||||
|
child.kill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
stopChild();
|
||||||
|
process.exit(130);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('exit', stopChild);
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(`[otelcol] failed to start ${otelcolBin}: ${error.message}`);
|
||||||
|
console.error('[otelcol] install otelcol-contrib and make sure it is on PATH, or set OTELCOL_BIN.');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
console.error(`[otelcol] exited by signal: ${signal}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function readEnv(key, fallback) {
|
||||||
|
const value = process.env[key]?.trim();
|
||||||
|
return value ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(selectedMode) {
|
||||||
|
const exporters =
|
||||||
|
selectedMode === 'rider'
|
||||||
|
? ` otlp/rider:
|
||||||
|
endpoint: ${riderEndpoint}
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
debug:
|
||||||
|
verbosity: ${debugVerbosity}`
|
||||||
|
: ` debug:
|
||||||
|
verbosity: ${debugVerbosity}`;
|
||||||
|
|
||||||
|
const pipelineExporters = selectedMode === 'rider' ? '[otlp/rider, debug]' : '[debug]';
|
||||||
|
|
||||||
|
return `receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: ${otlpGrpcEndpoint}
|
||||||
|
http:
|
||||||
|
endpoint: ${otlpHttpEndpoint}
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
${exporters}
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: ${pipelineExporters}
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: ${pipelineExporters}
|
||||||
|
logs:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: ${pipelineExporters}
|
||||||
|
`;
|
||||||
|
}
|
||||||
188
server-rs/Cargo.lock
generated
188
server-rs/Cargo.lock
generated
@@ -105,6 +105,7 @@ dependencies = [
|
|||||||
"module-square-hole",
|
"module-square-hole",
|
||||||
"module-story",
|
"module-story",
|
||||||
"module-visual-novel",
|
"module-visual-novel",
|
||||||
|
"opentelemetry",
|
||||||
"platform-agent",
|
"platform-agent",
|
||||||
"platform-auth",
|
"platform-auth",
|
||||||
"platform-llm",
|
"platform-llm",
|
||||||
@@ -118,6 +119,7 @@ dependencies = [
|
|||||||
"shared-contracts",
|
"shared-contracts",
|
||||||
"shared-kernel",
|
"shared-kernel",
|
||||||
"shared-logging",
|
"shared-logging",
|
||||||
|
"socket2 0.6.3",
|
||||||
"spacetime-client",
|
"spacetime-client",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -129,6 +131,7 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
"webp",
|
"webp",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1761,6 +1764,7 @@ dependencies = [
|
|||||||
"platform-auth",
|
"platform-auth",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"shared-kernel",
|
"shared-kernel",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2070,6 +2074,90 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project-lite",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-appender-tracing"
|
||||||
|
version = "0.31.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-http"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http 1.4.0",
|
||||||
|
"opentelemetry",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-otlp"
|
||||||
|
version = "0.31.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
|
||||||
|
dependencies = [
|
||||||
|
"http 1.4.0",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-http",
|
||||||
|
"opentelemetry-proto",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"reqwest 0.12.28",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-proto"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"tonic",
|
||||||
|
"tonic-prost",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry_sdk"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"opentelemetry",
|
||||||
|
"percent-encoding",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -2151,6 +2239,26 @@ dependencies = [
|
|||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -2320,6 +2428,29 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.14.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.14.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "protobuf"
|
name = "protobuf"
|
||||||
version = "3.7.2"
|
version = "3.7.2"
|
||||||
@@ -2622,6 +2753,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
@@ -3036,6 +3168,12 @@ dependencies = [
|
|||||||
name = "shared-logging"
|
name = "shared-logging"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-appender-tracing",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3130,6 +3268,7 @@ dependencies = [
|
|||||||
"module-square-hole",
|
"module-square-hole",
|
||||||
"module-story",
|
"module-story",
|
||||||
"module-visual-novel",
|
"module-visual-novel",
|
||||||
|
"opentelemetry",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"shared-contracts",
|
"shared-contracts",
|
||||||
@@ -3137,6 +3276,7 @@ dependencies = [
|
|||||||
"spacetimedb-sdk",
|
"spacetimedb-sdk",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3807,6 +3947,38 @@ version = "1.1.1+spec-1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic"
|
||||||
|
version = "0.14.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"http 1.4.0",
|
||||||
|
"http-body 1.0.1",
|
||||||
|
"http-body-util",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project",
|
||||||
|
"sync_wrapper 1.0.2",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic-prost"
|
||||||
|
version = "0.14.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -3898,6 +4070,22 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-opentelemetry"
|
||||||
|
version = "0.32.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"opentelemetry",
|
||||||
|
"smallvec",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.23"
|
version = "0.3.23"
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
socket2 = "0.6"
|
||||||
spacetimedb = "2.2.0"
|
spacetimedb = "2.2.0"
|
||||||
spacetimedb-sdk = "2.2.0"
|
spacetimedb-sdk = "2.2.0"
|
||||||
spacetimedb-lib = { version = "2.2.0", default-features = false }
|
spacetimedb-lib = { version = "2.2.0", default-features = false }
|
||||||
@@ -110,7 +111,13 @@ tokio-tungstenite = "0.27"
|
|||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = "0.6"
|
tower-http = "0.6"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
opentelemetry = "0.31"
|
||||||
|
opentelemetry-appender-tracing = { version = "0.31", default-features = false, features = ["experimental_use_tracing_span_context"] }
|
||||||
|
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["http-proto", "reqwest-blocking-client", "trace", "metrics", "logs"] }
|
||||||
|
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics", "logs"] }
|
||||||
|
tracing-opentelemetry = { version = "0.32", default-features = false }
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
windows-sys = "0.61"
|
||||||
url = "2"
|
url = "2"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
uuid = "1"
|
uuid = "1"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ base64 = { workspace = true }
|
|||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
dotenvy = { workspace = true }
|
dotenvy = { workspace = true }
|
||||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||||
|
http-body-util = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||||
webp = { workspace = true }
|
webp = { workspace = true }
|
||||||
module-ai = { workspace = true }
|
module-ai = { workspace = true }
|
||||||
@@ -43,18 +44,23 @@ sha2 = { workspace = true }
|
|||||||
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
||||||
shared-kernel = { workspace = true }
|
shared-kernel = { workspace = true }
|
||||||
shared-logging = { workspace = true }
|
shared-logging = { workspace = true }
|
||||||
|
socket2 = { workspace = true }
|
||||||
spacetime-client = { workspace = true }
|
spacetime-client = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
time = { workspace = true, features = ["formatting"] }
|
time = { workspace = true, features = ["formatting"] }
|
||||||
tower-http = { workspace = true, features = ["trace"] }
|
tower-http = { workspace = true, features = ["trace"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
opentelemetry = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
uuid = { workspace = true, features = ["v4"] }
|
uuid = { workspace = true, features = ["v4"] }
|
||||||
zip = { workspace = true, features = ["deflate"] }
|
zip = { workspace = true, features = ["deflate"] }
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
hmac = { workspace = true }
|
hmac = { workspace = true }
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
use axum::Json;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
body::Body,
|
||||||
|
http::{HeaderValue, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures_util::stream;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -32,6 +41,30 @@ where
|
|||||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn json_success_data_bytes_response(
|
||||||
|
request_context: Option<&RequestContext>,
|
||||||
|
data_json: Bytes,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(context) = request_context
|
||||||
|
&& context.wants_envelope()
|
||||||
|
{
|
||||||
|
let meta = serde_json::to_vec(&build_api_response_meta(Some(context)))
|
||||||
|
.map(Bytes::from)
|
||||||
|
.unwrap_or_else(|_| Bytes::from_static(b"null"));
|
||||||
|
let chunks = [
|
||||||
|
Bytes::from_static(b"{\"ok\":true,\"data\":"),
|
||||||
|
data_json,
|
||||||
|
Bytes::from_static(b",\"error\":null,\"meta\":"),
|
||||||
|
meta,
|
||||||
|
Bytes::from_static(b"}"),
|
||||||
|
];
|
||||||
|
let stream = stream::iter(chunks.into_iter().map(Ok::<Bytes, Infallible>));
|
||||||
|
return json_body_response(Body::from_stream(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
json_bytes_response(data_json)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn json_error_body(
|
pub fn json_error_body(
|
||||||
request_context: Option<&RequestContext>,
|
request_context: Option<&RequestContext>,
|
||||||
error: &ApiErrorPayload,
|
error: &ApiErrorPayload,
|
||||||
@@ -65,6 +98,19 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn json_bytes_response(bytes: Bytes) -> Response {
|
||||||
|
json_body_response(Body::from(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_body_response(body: Body) -> Response {
|
||||||
|
let mut response = body.into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -106,6 +152,31 @@ mod tests {
|
|||||||
assert!(body.get("meta").is_none());
|
assert!(body.get("meta").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn success_response_streams_cached_data_inside_standard_envelope() {
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
|
||||||
|
let request_context = build_request_context(true);
|
||||||
|
let response = json_success_data_bytes_response(
|
||||||
|
Some(&request_context),
|
||||||
|
Bytes::from_static(br#"{"items":[]}"#),
|
||||||
|
);
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("response body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("body should be json");
|
||||||
|
|
||||||
|
assert_eq!(payload["ok"], Value::Bool(true));
|
||||||
|
assert_eq!(payload["data"]["items"], Value::Array(Vec::new()));
|
||||||
|
assert_eq!(
|
||||||
|
payload["meta"]["requestId"],
|
||||||
|
Value::String("req-test".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||||
let request_context = build_request_context(false);
|
let request_context = build_request_context(false);
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ use tower_http::{
|
|||||||
classify::ServerErrorsFailureClass,
|
classify::ServerErrorsFailureClass,
|
||||||
trace::{DefaultOnRequest, TraceLayer},
|
trace::{DefaultOnRequest, TraceLayer},
|
||||||
};
|
};
|
||||||
use tracing::{Level, Span, error, info, info_span, warn};
|
use tracing::{Level, Span, error, info_span};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
||||||
|
backpressure::limit_concurrent_requests,
|
||||||
creation_entry_config::require_creation_entry_route_enabled,
|
creation_entry_config::require_creation_entry_route_enabled,
|
||||||
error_middleware::normalize_error_response,
|
error_middleware::normalize_error_response,
|
||||||
modules,
|
modules,
|
||||||
@@ -22,6 +23,7 @@ use crate::{
|
|||||||
response_headers::propagate_request_id_header,
|
response_headers::propagate_request_id_header,
|
||||||
runtime_inventory::get_runtime_inventory_state,
|
runtime_inventory::get_runtime_inventory_state,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
|
telemetry::record_http_observability,
|
||||||
tracking::record_route_tracking_event_after_success,
|
tracking::record_route_tracking_event_after_success,
|
||||||
vector_engine_audio_generation::{
|
vector_engine_audio_generation::{
|
||||||
create_background_music_task, create_sound_effect_task,
|
create_background_music_task, create_sound_effect_task,
|
||||||
@@ -42,8 +44,6 @@ use crate::{
|
|||||||
|
|
||||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.merge(modules::admin::router(state.clone()))
|
.merge(modules::admin::router(state.clone()))
|
||||||
.merge(modules::health::router(state.clone()))
|
.merge(modules::health::router(state.clone()))
|
||||||
@@ -77,6 +77,11 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
state.clone(),
|
state.clone(),
|
||||||
require_creation_entry_route_enabled,
|
require_creation_entry_route_enabled,
|
||||||
))
|
))
|
||||||
|
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
limit_concurrent_requests,
|
||||||
|
))
|
||||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||||
.layer(middleware::from_fn(normalize_error_response))
|
.layer(middleware::from_fn(normalize_error_response))
|
||||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||||
@@ -86,47 +91,55 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
state.clone(),
|
state.clone(),
|
||||||
record_api_tracking_after_success,
|
record_api_tracking_after_success,
|
||||||
))
|
))
|
||||||
|
// HTTP 指标与请求完成日志放在 tracing span 内侧,日志事件可以继承当前 trace/span context。
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
record_http_observability,
|
||||||
|
))
|
||||||
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
|
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
TraceLayer::new_for_http()
|
||||||
.make_span_with(|request: &Request<Body>| {
|
.make_span_with(|request: &Request<Body>| {
|
||||||
let request_id =
|
let request_id =
|
||||||
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let route = crate::telemetry::observability_route(request.uri().path());
|
||||||
|
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
|
||||||
|
let span_name = format!("{} {}", request.method(), route);
|
||||||
|
|
||||||
info_span!(
|
info_span!(
|
||||||
"http.request",
|
"http.request",
|
||||||
|
otel.kind = "server",
|
||||||
|
otel.name = %span_name,
|
||||||
|
otel.status_code = tracing::field::Empty,
|
||||||
|
http.response.status_code = tracing::field::Empty,
|
||||||
method = %request.method(),
|
method = %request.method(),
|
||||||
uri = %request.uri(),
|
http.request.method = %request.method(),
|
||||||
|
http.route = %route,
|
||||||
|
url.scheme = %scheme,
|
||||||
|
url.path = %request.uri().path(),
|
||||||
request_id = %request_id,
|
request_id = %request_id,
|
||||||
|
status = tracing::field::Empty,
|
||||||
|
latency_ms = tracing::field::Empty,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||||
.on_response(
|
.on_response(
|
||||||
move |response: &axum::response::Response,
|
|response: &axum::response::Response,
|
||||||
latency: std::time::Duration,
|
latency: std::time::Duration,
|
||||||
span: &Span| {
|
span: &Span| {
|
||||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let slow_request = latency_ms >= slow_request_threshold_ms;
|
|
||||||
span.record("status", status);
|
span.record("status", status);
|
||||||
|
span.record("http.response.status_code", status);
|
||||||
|
span.record(
|
||||||
|
"otel.status_code",
|
||||||
|
if response.status().is_server_error() {
|
||||||
|
"ERROR"
|
||||||
|
} else {
|
||||||
|
"OK"
|
||||||
|
},
|
||||||
|
);
|
||||||
span.record("latency_ms", latency_ms);
|
span.record("latency_ms", latency_ms);
|
||||||
if slow_request {
|
|
||||||
warn!(
|
|
||||||
parent: span,
|
|
||||||
status,
|
|
||||||
latency_ms,
|
|
||||||
slow_request = true,
|
|
||||||
"http request completed slowly"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!(
|
|
||||||
parent: span,
|
|
||||||
status,
|
|
||||||
latency_ms,
|
|
||||||
slow_request = false,
|
|
||||||
"http request completed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.on_failure(
|
.on_failure(
|
||||||
|
|||||||
@@ -752,10 +752,14 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
|
use platform_auth::{
|
||||||
|
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||||
|
};
|
||||||
use reqwest::{Method, multipart};
|
use reqwest::{Method, multipart};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use shared_kernel::new_uuid_simple_string;
|
use shared_kernel::new_uuid_simple_string;
|
||||||
|
use time::OffsetDateTime;
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
|
||||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||||
@@ -873,13 +877,17 @@ mod tests {
|
|||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_router(AppState::new(config).expect("state should build"));
|
let state = AppState::new(config).expect("state should build");
|
||||||
|
let token =
|
||||||
|
seed_authenticated_token(&state, "13800138120", "sess_assets_direct_upload").await;
|
||||||
|
let app = build_router(state);
|
||||||
|
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
Request::builder()
|
Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
.uri("/api/assets/direct-upload-tickets")
|
.uri("/api/assets/direct-upload-tickets")
|
||||||
|
.header("authorization", format!("Bearer {token}"))
|
||||||
.header("content-type", "application/json")
|
.header("content-type", "application/json")
|
||||||
.header("x-request-id", "req-oss-ticket")
|
.header("x-request-id", "req-oss-ticket")
|
||||||
.header("x-genarrative-response-envelope", "1")
|
.header("x-genarrative-response-envelope", "1")
|
||||||
@@ -1693,6 +1701,33 @@ mod tests {
|
|||||||
Ok(fields)
|
Ok(fields)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn seed_authenticated_token(
|
||||||
|
state: &AppState,
|
||||||
|
phone_number: &str,
|
||||||
|
session_seed: &str,
|
||||||
|
) -> String {
|
||||||
|
let user = state
|
||||||
|
.seed_test_phone_user_with_password(phone_number, "secret123")
|
||||||
|
.await;
|
||||||
|
let claims = AccessTokenClaims::from_input(
|
||||||
|
AccessTokenClaimsInput {
|
||||||
|
user_id: user.id.clone(),
|
||||||
|
session_id: state.seed_test_refresh_session_for_user(&user, session_seed),
|
||||||
|
provider: AuthProvider::Password,
|
||||||
|
roles: vec!["user".to_string()],
|
||||||
|
token_version: user.token_version,
|
||||||
|
phone_verified: true,
|
||||||
|
binding_status: BindingStatus::Active,
|
||||||
|
display_name: Some(user.display_name.clone()),
|
||||||
|
},
|
||||||
|
state.auth_jwt_config(),
|
||||||
|
OffsetDateTime::now_utc(),
|
||||||
|
)
|
||||||
|
.expect("claims should build");
|
||||||
|
|
||||||
|
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||||
|
}
|
||||||
|
|
||||||
fn build_object_url(
|
fn build_object_url(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
object_key: &str,
|
object_key: &str,
|
||||||
|
|||||||
245
server-rs/crates/api-server/src/backpressure.rs
Normal file
245
server-rs/crates/api-server/src/backpressure.rs
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{HeaderValue, StatusCode, header::RETRY_AFTER},
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use tokio::sync::{OwnedSemaphorePermit, TryAcquireError};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
http_error::AppError,
|
||||||
|
request_context::RequestContext,
|
||||||
|
state::{AppState, HttpRequestPermitPool},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn limit_concurrent_requests(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
if should_bypass_backpressure(&request) {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(permit_pool) = state.http_request_permit_pool() else {
|
||||||
|
return next.run(request).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
match acquire_http_request_permit(permit_pool) {
|
||||||
|
Ok(permit) => hold_permit_until_response_body_dropped(next.run(request).await, permit),
|
||||||
|
Err(_) => reject_overloaded_request(&request),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire_http_request_permit(
|
||||||
|
permit_pool: Arc<HttpRequestPermitPool>,
|
||||||
|
) -> Result<HttpRequestPermitGuard, TryAcquireError> {
|
||||||
|
match permit_pool.clone().try_acquire_owned() {
|
||||||
|
Ok(permit) => {
|
||||||
|
crate::telemetry::update_http_request_permits_available(permit_pool.available_permits());
|
||||||
|
Ok(HttpRequestPermitGuard {
|
||||||
|
permit: Some(permit),
|
||||||
|
permit_pool,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
crate::telemetry::update_http_request_permits_available(permit_pool.available_permits());
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hold_permit_until_response_body_dropped(
|
||||||
|
response: Response,
|
||||||
|
permit: HttpRequestPermitGuard,
|
||||||
|
) -> Response {
|
||||||
|
response.map(|body| {
|
||||||
|
Body::new(body.map_frame(move |frame| {
|
||||||
|
let _permit_guard = &permit;
|
||||||
|
frame
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpRequestPermitGuard {
|
||||||
|
permit: Option<OwnedSemaphorePermit>,
|
||||||
|
permit_pool: Arc<HttpRequestPermitPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for HttpRequestPermitGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
drop(self.permit.take());
|
||||||
|
crate::telemetry::update_http_request_permits_available(self.permit_pool.available_permits());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_overloaded_request(request: &Request<Body>) -> Response {
|
||||||
|
let request_context = request.extensions().get::<RequestContext>().cloned();
|
||||||
|
let mut response = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
|
||||||
|
.with_message("服务繁忙,请稍后重试")
|
||||||
|
.into_response_with_context(request_context.as_ref());
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(RETRY_AFTER, HeaderValue::from_static("1"));
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_bypass_backpressure(request: &Request<Body>) -> bool {
|
||||||
|
request.uri().path() == "/healthz"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Body,
|
||||||
|
extract::Extension,
|
||||||
|
http::{Request, StatusCode, header::RETRY_AFTER},
|
||||||
|
middleware,
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use crate::{config::AppConfig, state::AppState};
|
||||||
|
|
||||||
|
use super::limit_concurrent_requests;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct HeldRequestGate {
|
||||||
|
entered: Arc<Notify>,
|
||||||
|
release: Arc<Notify>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn held_request(Extension(gate): Extension<HeldRequestGate>) -> &'static str {
|
||||||
|
gate.entered.notify_one();
|
||||||
|
gate.release.notified().await;
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fast_request() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_request(path: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.uri(path)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("test request should build")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_test_app(max_concurrent_requests: usize, gate: HeldRequestGate) -> Router {
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
config.max_concurrent_requests = Some(max_concurrent_requests);
|
||||||
|
let state = AppState::new(config).expect("state should build");
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/held", get(held_request))
|
||||||
|
.route("/fast", get(fast_request))
|
||||||
|
.route("/healthz", get(fast_request))
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
limit_concurrent_requests,
|
||||||
|
))
|
||||||
|
.layer(Extension(gate))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn returns_429_when_concurrency_permits_are_exhausted() {
|
||||||
|
let gate = HeldRequestGate {
|
||||||
|
entered: Arc::new(Notify::new()),
|
||||||
|
release: Arc::new(Notify::new()),
|
||||||
|
};
|
||||||
|
let app = build_test_app(1, gate.clone());
|
||||||
|
let entered = gate.entered.notified();
|
||||||
|
|
||||||
|
let held_response = tokio::spawn(app.clone().oneshot(test_request("/held")));
|
||||||
|
entered.await;
|
||||||
|
|
||||||
|
let rejected_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(test_request("/fast"))
|
||||||
|
.await
|
||||||
|
.expect("rejected request should complete");
|
||||||
|
assert_eq!(rejected_response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
assert_eq!(
|
||||||
|
rejected_response
|
||||||
|
.headers()
|
||||||
|
.get(RETRY_AFTER)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("1")
|
||||||
|
);
|
||||||
|
|
||||||
|
gate.release.notify_one();
|
||||||
|
let completed_response = held_response
|
||||||
|
.await
|
||||||
|
.expect("held request task should join")
|
||||||
|
.expect("held request should complete");
|
||||||
|
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn healthz_bypasses_concurrency_backpressure() {
|
||||||
|
let gate = HeldRequestGate {
|
||||||
|
entered: Arc::new(Notify::new()),
|
||||||
|
release: Arc::new(Notify::new()),
|
||||||
|
};
|
||||||
|
let app = build_test_app(1, gate.clone());
|
||||||
|
let entered = gate.entered.notified();
|
||||||
|
|
||||||
|
let held_response = tokio::spawn(app.clone().oneshot(test_request("/held")));
|
||||||
|
entered.await;
|
||||||
|
|
||||||
|
let health_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(test_request("/healthz"))
|
||||||
|
.await
|
||||||
|
.expect("healthz request should complete");
|
||||||
|
assert_eq!(health_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
gate.release.notify_one();
|
||||||
|
let completed_response = held_response
|
||||||
|
.await
|
||||||
|
.expect("held request task should join")
|
||||||
|
.expect("held request should complete");
|
||||||
|
assert_eq!(completed_response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn permit_is_held_until_response_body_is_dropped() {
|
||||||
|
let gate = HeldRequestGate {
|
||||||
|
entered: Arc::new(Notify::new()),
|
||||||
|
release: Arc::new(Notify::new()),
|
||||||
|
};
|
||||||
|
let app = build_test_app(1, gate);
|
||||||
|
|
||||||
|
let first_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(test_request("/fast"))
|
||||||
|
.await
|
||||||
|
.expect("first request should complete");
|
||||||
|
assert_eq!(first_response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let rejected_response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(test_request("/fast"))
|
||||||
|
.await
|
||||||
|
.expect("second request should complete");
|
||||||
|
assert_eq!(rejected_response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
|
||||||
|
drop(first_response);
|
||||||
|
|
||||||
|
let accepted_response = app
|
||||||
|
.oneshot(test_request("/fast"))
|
||||||
|
.await
|
||||||
|
.expect("third request should complete");
|
||||||
|
assert_eq!(accepted_response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,11 @@ pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000
|
|||||||
pub struct AppConfig {
|
pub struct AppConfig {
|
||||||
pub bind_host: String,
|
pub bind_host: String,
|
||||||
pub bind_port: u16,
|
pub bind_port: u16,
|
||||||
|
pub listen_backlog: i32,
|
||||||
|
pub worker_threads: Option<usize>,
|
||||||
|
pub max_concurrent_requests: Option<usize>,
|
||||||
pub log_filter: String,
|
pub log_filter: String,
|
||||||
|
pub otel_enabled: bool,
|
||||||
pub admin_username: Option<String>,
|
pub admin_username: Option<String>,
|
||||||
pub admin_password: Option<String>,
|
pub admin_password: Option<String>,
|
||||||
pub admin_token_ttl_seconds: u64,
|
pub admin_token_ttl_seconds: u64,
|
||||||
@@ -147,7 +151,11 @@ impl Default for AppConfig {
|
|||||||
Self {
|
Self {
|
||||||
bind_host: "127.0.0.1".to_string(),
|
bind_host: "127.0.0.1".to_string(),
|
||||||
bind_port: 3000,
|
bind_port: 3000,
|
||||||
|
listen_backlog: 1024,
|
||||||
|
worker_threads: None,
|
||||||
|
max_concurrent_requests: None,
|
||||||
log_filter: "info,tower_http=info".to_string(),
|
log_filter: "info,tower_http=info".to_string(),
|
||||||
|
otel_enabled: false,
|
||||||
admin_username: None,
|
admin_username: None,
|
||||||
admin_password: None,
|
admin_password: None,
|
||||||
admin_token_ttl_seconds: 4 * 60 * 60,
|
admin_token_ttl_seconds: 4 * 60 * 60,
|
||||||
@@ -164,11 +172,11 @@ impl Default for AppConfig {
|
|||||||
dev_password_entry_auto_register_enabled: false,
|
dev_password_entry_auto_register_enabled: false,
|
||||||
sms_auth_enabled: false,
|
sms_auth_enabled: false,
|
||||||
sms_auth_provider: "mock".to_string(),
|
sms_auth_provider: "mock".to_string(),
|
||||||
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
sms_endpoint: "dysmsapi.aliyuncs.com".to_string(),
|
||||||
sms_access_key_id: None,
|
sms_access_key_id: None,
|
||||||
sms_access_key_secret: None,
|
sms_access_key_secret: None,
|
||||||
sms_sign_name: "速通互联验证码".to_string(),
|
sms_sign_name: "北京亓盒网络科技".to_string(),
|
||||||
sms_template_code: "100001".to_string(),
|
sms_template_code: "SMS_506245486".to_string(),
|
||||||
sms_template_param_key: "code".to_string(),
|
sms_template_param_key: "code".to_string(),
|
||||||
sms_country_code: "86".to_string(),
|
sms_country_code: "86".to_string(),
|
||||||
sms_scheme_name: None,
|
sms_scheme_name: None,
|
||||||
@@ -301,6 +309,22 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
config.log_filter = log_filter;
|
config.log_filter = log_filter;
|
||||||
}
|
}
|
||||||
|
if let Some(listen_backlog) =
|
||||||
|
read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"])
|
||||||
|
{
|
||||||
|
config.listen_backlog = listen_backlog;
|
||||||
|
}
|
||||||
|
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
|
||||||
|
config.worker_threads = Some(worker_threads);
|
||||||
|
}
|
||||||
|
if let Some(max_concurrent_requests) =
|
||||||
|
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
|
||||||
|
{
|
||||||
|
config.max_concurrent_requests = Some(max_concurrent_requests);
|
||||||
|
}
|
||||||
|
if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) {
|
||||||
|
config.otel_enabled = otel_enabled;
|
||||||
|
}
|
||||||
|
|
||||||
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
|
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
|
||||||
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
|
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
|
||||||
@@ -881,6 +905,14 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_first_positive_i32_env(keys: &[&str]) -> Option<i32> {
|
||||||
|
keys.iter().find_map(|key| {
|
||||||
|
env::var(key)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| parse_positive_i32(&value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
|
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
|
||||||
keys.iter().find_map(|key| {
|
keys.iter().find_map(|key| {
|
||||||
env::var(key)
|
env::var(key)
|
||||||
@@ -946,6 +978,16 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(raw: &str) -> Option<bool> {
|
fn parse_bool(raw: &str) -> Option<bool> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
let raw = raw
|
||||||
|
.strip_prefix('"')
|
||||||
|
.and_then(|value| value.strip_suffix('"'))
|
||||||
|
.or_else(|| {
|
||||||
|
raw.strip_prefix('\'')
|
||||||
|
.and_then(|value| value.strip_suffix('\''))
|
||||||
|
})
|
||||||
|
.unwrap_or(raw);
|
||||||
|
|
||||||
match raw.trim().to_ascii_lowercase().as_str() {
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
"1" | "true" | "yes" | "on" => Some(true),
|
"1" | "true" | "yes" | "on" => Some(true),
|
||||||
"0" | "false" | "no" | "off" => Some(false),
|
"0" | "false" | "no" | "off" => Some(false),
|
||||||
@@ -971,6 +1013,15 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
|
|||||||
Some(value)
|
Some(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_positive_i32(raw: &str) -> Option<i32> {
|
||||||
|
let value = raw.trim().parse::<i32>().ok()?;
|
||||||
|
if value <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_u32(raw: &str) -> Option<u32> {
|
fn parse_u32(raw: &str) -> Option<u32> {
|
||||||
raw.trim().parse::<u32>().ok()
|
raw.trim().parse::<u32>().ok()
|
||||||
}
|
}
|
||||||
@@ -1012,7 +1063,9 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
|
use super::{
|
||||||
|
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
|
||||||
|
};
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
@@ -1035,13 +1088,44 @@ mod tests {
|
|||||||
config.dashscope_base_url,
|
config.dashscope_base_url,
|
||||||
"https://dashscope.aliyuncs.com/api/v1"
|
"https://dashscope.aliyuncs.com/api/v1"
|
||||||
);
|
);
|
||||||
assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com");
|
assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com");
|
||||||
|
assert_eq!(config.sms_sign_name, "北京亓盒网络科技");
|
||||||
|
assert_eq!(config.sms_template_code, "SMS_506245486");
|
||||||
|
assert_eq!(config.sms_template_param_key, "code");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.wechat_authorize_endpoint,
|
config.wechat_authorize_endpoint,
|
||||||
"https://open.weixin.qq.com/connect/qrconnect"
|
"https://open.weixin.qq.com/connect/qrconnect"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_bool_accepts_wrapped_quotes_from_shell_env() {
|
||||||
|
assert_eq!(parse_bool("\"true\""), Some(true));
|
||||||
|
assert_eq!(parse_bool("'true'"), Some(true));
|
||||||
|
assert_eq!(parse_bool("\"false\""), Some(false));
|
||||||
|
assert_eq!(parse_bool("'off'"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
||||||
|
let _guard = ENV_LOCK
|
||||||
|
.get_or_init(|| Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.expect("env lock should not poison");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("SMS_AUTH_ENABLED");
|
||||||
|
std::env::set_var("SMS_AUTH_ENABLED", "\"true\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert!(config.sms_auth_enabled);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("SMS_AUTH_ENABLED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_env_reads_non_public_models_and_urls() {
|
fn from_env_reads_non_public_models_and_urls() {
|
||||||
let _guard = ENV_LOCK
|
let _guard = ENV_LOCK
|
||||||
@@ -1151,6 +1235,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_reads_api_runtime_performance_settings() {
|
||||||
|
let _guard = ENV_LOCK
|
||||||
|
.get_or_init(|| Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.expect("env lock should not poison");
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG");
|
||||||
|
std::env::remove_var("GENARRATIVE_API_WORKER_THREADS");
|
||||||
|
std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS");
|
||||||
|
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||||
|
std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048");
|
||||||
|
std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6");
|
||||||
|
std::env::set_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS", "128");
|
||||||
|
std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert_eq!(config.listen_backlog, 2048);
|
||||||
|
assert_eq!(config.worker_threads, Some(6));
|
||||||
|
assert_eq!(config.max_concurrent_requests, Some(128));
|
||||||
|
assert!(config.otel_enabled);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG");
|
||||||
|
std::env::remove_var("GENARRATIVE_API_WORKER_THREADS");
|
||||||
|
std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS");
|
||||||
|
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_env_reads_wechat_pay_settings() {
|
fn from_env_reads_wechat_pay_settings() {
|
||||||
let _guard = ENV_LOCK
|
let _guard = ENV_LOCK
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ mod auth_payload;
|
|||||||
mod auth_public_user;
|
mod auth_public_user;
|
||||||
mod auth_session;
|
mod auth_session;
|
||||||
mod auth_sessions;
|
mod auth_sessions;
|
||||||
|
mod backpressure;
|
||||||
mod bark_battle;
|
mod bark_battle;
|
||||||
mod big_fish;
|
mod big_fish;
|
||||||
mod big_fish_agent_turn;
|
mod big_fish_agent_turn;
|
||||||
@@ -55,9 +56,11 @@ mod password_management;
|
|||||||
mod phone_auth;
|
mod phone_auth;
|
||||||
mod platform_errors;
|
mod platform_errors;
|
||||||
mod profile_identity;
|
mod profile_identity;
|
||||||
|
mod process_metrics;
|
||||||
mod prompt;
|
mod prompt;
|
||||||
mod puzzle;
|
mod puzzle;
|
||||||
mod puzzle_agent_turn;
|
mod puzzle_agent_turn;
|
||||||
|
mod puzzle_gallery_cache;
|
||||||
mod refresh_session;
|
mod refresh_session;
|
||||||
mod registration_reward;
|
mod registration_reward;
|
||||||
mod request_context;
|
mod request_context;
|
||||||
@@ -75,6 +78,7 @@ mod square_hole_agent_turn;
|
|||||||
mod state;
|
mod state;
|
||||||
mod story_battles;
|
mod story_battles;
|
||||||
mod story_sessions;
|
mod story_sessions;
|
||||||
|
mod telemetry;
|
||||||
mod tracking;
|
mod tracking;
|
||||||
mod vector_engine_audio_generation;
|
mod vector_engine_audio_generation;
|
||||||
mod visual_novel;
|
mod visual_novel;
|
||||||
@@ -85,8 +89,15 @@ mod wechat_provider;
|
|||||||
mod work_author;
|
mod work_author;
|
||||||
mod work_play_tracking;
|
mod work_play_tracking;
|
||||||
|
|
||||||
use shared_logging::init_tracing;
|
use shared_logging::{OtelConfig, init_tracing};
|
||||||
use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration};
|
use socket2::{Domain, Protocol, Socket, Type};
|
||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
env, fs, io,
|
||||||
|
net::{SocketAddr, TcpListener as StdTcpListener},
|
||||||
|
panic, thread,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
@@ -103,12 +114,18 @@ fn main() -> Result<(), io::Error> {
|
|||||||
.name("api-server-bootstrap".to_string())
|
.name("api-server-bootstrap".to_string())
|
||||||
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||||
.spawn(|| {
|
.spawn(|| {
|
||||||
TokioRuntimeBuilder::new_multi_thread()
|
load_local_env_files();
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
let mut runtime_builder = TokioRuntimeBuilder::new_multi_thread();
|
||||||
|
runtime_builder
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.thread_name("api-server-worker")
|
.thread_name("api-server-worker")
|
||||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES);
|
||||||
.build()?
|
if let Some(worker_threads) = config.worker_threads {
|
||||||
.block_on(run_server())
|
runtime_builder.worker_threads(worker_threads);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime_builder.build()?.block_on(run_server(config))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match server_thread.join() {
|
match server_thread.join() {
|
||||||
@@ -117,28 +134,52 @@ fn main() -> Result<(), io::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_server() -> Result<(), io::Error> {
|
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。
|
init_tracing(
|
||||||
// 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。
|
&config.log_filter,
|
||||||
load_local_env_files();
|
OtelConfig {
|
||||||
|
enabled: config.otel_enabled,
|
||||||
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
|
},
|
||||||
let config = AppConfig::from_env();
|
)?;
|
||||||
init_tracing(&config.log_filter)?;
|
process_metrics::register_process_metrics();
|
||||||
|
telemetry::register_http_runtime_metrics();
|
||||||
|
|
||||||
let bind_address = config.bind_socket_addr();
|
let bind_address = config.bind_socket_addr();
|
||||||
let listener = TcpListener::bind(bind_address).await?;
|
let listen_backlog = config.listen_backlog;
|
||||||
|
let worker_threads = config.worker_threads;
|
||||||
|
let otel_enabled = config.otel_enabled;
|
||||||
|
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||||
|
|
||||||
let state = restore_app_state_for_startup(config)
|
let state = restore_app_state_for_startup(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||||
|
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||||
let router = build_router(state);
|
let router = build_router(state);
|
||||||
|
|
||||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
info!(
|
||||||
|
%bind_address,
|
||||||
|
listen_backlog,
|
||||||
|
worker_threads = worker_threads.unwrap_or(0),
|
||||||
|
otel_enabled,
|
||||||
|
"api-server 已完成 tracing 初始化并开始监听"
|
||||||
|
);
|
||||||
|
|
||||||
axum::serve(listener, router).await
|
axum::serve(listener, router).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_tcp_listener(
|
||||||
|
bind_address: SocketAddr,
|
||||||
|
listen_backlog: i32,
|
||||||
|
) -> Result<TcpListener, io::Error> {
|
||||||
|
let domain = Domain::for_address(bind_address);
|
||||||
|
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
|
||||||
|
socket.set_reuse_address(true)?;
|
||||||
|
socket.set_nonblocking(true)?;
|
||||||
|
socket.bind(&bind_address.into())?;
|
||||||
|
socket.listen(listen_backlog)?;
|
||||||
|
TcpListener::from_std(StdTcpListener::from(socket))
|
||||||
|
}
|
||||||
|
|
||||||
async fn restore_app_state_for_startup(
|
async fn restore_app_state_for_startup(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
) -> Result<AppState, state::AppStateInitError> {
|
) -> Result<AppState, state::AppStateInitError> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) async fn submit_and_finalize_match3d_message(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: String,
|
||||||
|
payload: SendMatch3DAgentMessageRequest,
|
||||||
|
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||||
|
ensure_non_empty(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
&session_id,
|
||||||
|
"sessionId",
|
||||||
|
)?;
|
||||||
|
ensure_non_empty(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
&payload.client_message_id,
|
||||||
|
"clientMessageId",
|
||||||
|
)?;
|
||||||
|
ensure_non_empty(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
&payload.text,
|
||||||
|
"text",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let submitted = state
|
||||||
|
.spacetime_client()
|
||||||
|
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
owner_user_id: owner_user_id.to_string(),
|
||||||
|
user_message_id: payload.client_message_id.clone(),
|
||||||
|
user_message_text: payload.text.clone(),
|
||||||
|
submitted_at_micros: current_utc_micros(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
match3d_error_response(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
map_match3d_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let next_turn = submitted.current_turn.saturating_add(1);
|
||||||
|
let next_config = build_config_from_message(&submitted, &payload);
|
||||||
|
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
|
||||||
|
let progress_percent = resolve_progress_percent_for_turn(next_turn);
|
||||||
|
let stage = if progress_percent >= 100 {
|
||||||
|
"ReadyToCompile"
|
||||||
|
} else {
|
||||||
|
"Collecting"
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
|
||||||
|
session_id,
|
||||||
|
owner_user_id: owner_user_id.to_string(),
|
||||||
|
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
|
||||||
|
assistant_reply_text: Some(assistant_reply),
|
||||||
|
config_json: serialize_match3d_config(&next_config),
|
||||||
|
progress_percent,
|
||||||
|
stage,
|
||||||
|
updated_at_micros: current_utc_micros(),
|
||||||
|
error_message: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
match3d_error_response(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
map_match3d_client_error(error),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn load_match3d_agent_session_response_with_persisted_assets(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session: Match3DAgentSessionRecord,
|
||||||
|
) -> Match3DAgentSessionSnapshotResponse {
|
||||||
|
let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else {
|
||||||
|
return map_match3d_agent_session_response(session);
|
||||||
|
};
|
||||||
|
let assets =
|
||||||
|
get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await;
|
||||||
|
map_match3d_agent_session_response_with_assets(session, &assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_match3d_session_existing_profile_id(
|
||||||
|
session: &Match3DAgentSessionRecord,
|
||||||
|
) -> Option<String> {
|
||||||
|
session
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.map(|draft| draft.profile_id.trim())
|
||||||
|
.filter(|profile_id| !profile_id.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
session
|
||||||
|
.published_profile_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|profile_id| !profile_id.is_empty())
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn compile_match3d_draft_for_session(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
authenticated: &AuthenticatedAccessToken,
|
||||||
|
session_id: String,
|
||||||
|
game_name: Option<String>,
|
||||||
|
summary: Option<String>,
|
||||||
|
tags: Option<Vec<String>>,
|
||||||
|
cover_image_src: Option<String>,
|
||||||
|
generate_click_sound: Option<bool>,
|
||||||
|
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
|
||||||
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let initial_session = state
|
||||||
|
.spacetime_client()
|
||||||
|
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
match3d_error_response(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
map_match3d_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut config = resolve_config_or_default(initial_session.config.as_ref());
|
||||||
|
if let Some(generate_click_sound) = generate_click_sound {
|
||||||
|
config.generate_click_sound = generate_click_sound;
|
||||||
|
}
|
||||||
|
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
|
||||||
|
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
|
||||||
|
let has_complete_form_config = !config.theme_text.trim().is_empty()
|
||||||
|
&& config.clear_count > 0
|
||||||
|
&& (1..=10).contains(&config.difficulty);
|
||||||
|
if !has_complete_form_config
|
||||||
|
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
|
||||||
|
{
|
||||||
|
return Err(match3d_bad_request(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
"match3d 创作配置尚未确认完成",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let requested_game_name = normalize_optional_match3d_text(game_name);
|
||||||
|
let requested_summary = normalize_optional_match3d_text(summary);
|
||||||
|
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
|
||||||
|
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
|
||||||
|
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||||
|
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
|
||||||
|
let initial_game_name = requested_game_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
|
||||||
|
let initial_tags = requested_tags
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||||
|
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||||
|
execute_billable_match3d_draft_generation(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
billing_asset_id.as_str(),
|
||||||
|
async {
|
||||||
|
let mut session = upsert_match3d_draft_snapshot(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
authenticated,
|
||||||
|
session_id.clone(),
|
||||||
|
owner_user_id.clone(),
|
||||||
|
profile_id.clone(),
|
||||||
|
Some(initial_game_name),
|
||||||
|
requested_summary.clone().or_else(|| Some(String::new())),
|
||||||
|
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
|
||||||
|
requested_cover_image_src.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if session.draft.is_none() {
|
||||||
|
return Err(match3d_error_response(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
|
||||||
|
let resolved_game_name = requested_game_name
|
||||||
|
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
|
||||||
|
let resolved_summary = requested_summary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
|
||||||
|
let resolved_tags = match requested_tags {
|
||||||
|
Some(tags) => tags,
|
||||||
|
None => {
|
||||||
|
generate_match3d_work_tags_for_plan(
|
||||||
|
state,
|
||||||
|
resolved_game_name.as_str(),
|
||||||
|
config.theme_text.as_str(),
|
||||||
|
resolved_summary.as_str(),
|
||||||
|
&generated_work_metadata.metadata.tags,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
generated_work_metadata.metadata.tags = resolved_tags.clone();
|
||||||
|
session = upsert_match3d_draft_snapshot(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
authenticated,
|
||||||
|
session_id,
|
||||||
|
owner_user_id.clone(),
|
||||||
|
profile_id.clone(),
|
||||||
|
Some(resolved_game_name),
|
||||||
|
Some(resolved_summary),
|
||||||
|
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
|
||||||
|
requested_cover_image_src.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let existing_assets = get_match3d_existing_generated_item_assets(
|
||||||
|
state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let generated_item_assets = generate_match3d_item_assets(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
authenticated,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
session.session_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
&config,
|
||||||
|
generated_work_metadata.items,
|
||||||
|
existing_assets,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let generated_item_assets = ensure_match3d_background_asset(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
authenticated,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
session.session_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
&config,
|
||||||
|
generated_work_metadata.background_prompt.as_str(),
|
||||||
|
generated_item_assets,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let existing_cover_image_src = get_match3d_existing_cover_image_src(
|
||||||
|
state,
|
||||||
|
owner_user_id.as_str(),
|
||||||
|
profile_id.as_str(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let default_cover_image_src = requested_cover_image_src
|
||||||
|
.clone()
|
||||||
|
.or(existing_cover_image_src)
|
||||||
|
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
|
||||||
|
let next_session = upsert_match3d_draft_snapshot(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
authenticated,
|
||||||
|
session.session_id.clone(),
|
||||||
|
owner_user_id.clone(),
|
||||||
|
profile_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
default_cover_image_src,
|
||||||
|
None,
|
||||||
|
serialize_match3d_generated_item_assets(&generated_item_assets),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((next_session, generated_item_assets))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||||
|
async fn execute_billable_match3d_draft_generation<T, Fut>(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
owner_user_id: &str,
|
||||||
|
billing_asset_id: &str,
|
||||||
|
operation: Fut,
|
||||||
|
) -> Result<T, Response>
|
||||||
|
where
|
||||||
|
Fut: Future<Output = Result<T, Response>>,
|
||||||
|
{
|
||||||
|
let points_consumed = consume_match3d_draft_generation_points(
|
||||||
|
state,
|
||||||
|
request_context,
|
||||||
|
owner_user_id,
|
||||||
|
billing_asset_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match operation.await {
|
||||||
|
Ok(value) => Ok(value),
|
||||||
|
Err(response) => {
|
||||||
|
if points_consumed {
|
||||||
|
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume_match3d_draft_generation_points(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
owner_user_id: &str,
|
||||||
|
billing_asset_id: &str,
|
||||||
|
) -> Result<bool, Response> {
|
||||||
|
let ledger_id = format!(
|
||||||
|
"asset_operation_consume:{}:match3d_draft_generation:{}",
|
||||||
|
owner_user_id, billing_asset_id
|
||||||
|
);
|
||||||
|
match state
|
||||||
|
.spacetime_client()
|
||||||
|
.consume_profile_wallet_points(
|
||||||
|
owner_user_id.to_string(),
|
||||||
|
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||||
|
ledger_id,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
owner_user_id,
|
||||||
|
billing_asset_id,
|
||||||
|
error = %error,
|
||||||
|
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||||
|
);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Err(error) => Err(match3d_error_response(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
map_asset_operation_wallet_error(error),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refund_match3d_draft_generation_points(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
billing_asset_id: &str,
|
||||||
|
) {
|
||||||
|
let ledger_id = format!(
|
||||||
|
"asset_operation_refund:{}:match3d_draft_generation:{}",
|
||||||
|
owner_user_id, billing_asset_id
|
||||||
|
);
|
||||||
|
if let Err(error) = state
|
||||||
|
.spacetime_client()
|
||||||
|
.refund_profile_wallet_points(
|
||||||
|
owner_user_id.to_string(),
|
||||||
|
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||||
|
ledger_id,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
owner_user_id,
|
||||||
|
billing_asset_id,
|
||||||
|
error = %error,
|
||||||
|
"抓大鹅草稿生成失败后的泥点退款失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
|
||||||
|
session
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.map(|draft| draft.profile_id.trim())
|
||||||
|
.filter(|profile_id| !profile_id.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
session
|
||||||
|
.published_profile_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|profile_id| !profile_id.is_empty())
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) async fn upsert_match3d_draft_snapshot(
|
||||||
|
state: &AppState,
|
||||||
|
request_context: &RequestContext,
|
||||||
|
authenticated: &AuthenticatedAccessToken,
|
||||||
|
session_id: String,
|
||||||
|
owner_user_id: String,
|
||||||
|
profile_id: String,
|
||||||
|
game_name: Option<String>,
|
||||||
|
summary_text: Option<String>,
|
||||||
|
tags_json: Option<String>,
|
||||||
|
cover_image_src: Option<String>,
|
||||||
|
cover_asset_id: Option<String>,
|
||||||
|
generated_item_assets_json: Option<String>,
|
||||||
|
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.compile_match3d_draft(Match3DCompileDraftRecordInput {
|
||||||
|
session_id,
|
||||||
|
owner_user_id,
|
||||||
|
profile_id,
|
||||||
|
author_display_name: resolve_author_display_name(state, authenticated),
|
||||||
|
game_name,
|
||||||
|
summary_text,
|
||||||
|
tags_json,
|
||||||
|
cover_image_src,
|
||||||
|
cover_asset_id,
|
||||||
|
compiled_at_micros: current_utc_micros(),
|
||||||
|
generated_item_assets_json,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
match3d_error_response(
|
||||||
|
request_context,
|
||||||
|
MATCH3D_AGENT_PROVIDER,
|
||||||
|
map_match3d_client_error(error),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) fn build_config_from_create_request(
|
||||||
|
payload: &CreateMatch3DAgentSessionRequest,
|
||||||
|
) -> Match3DConfigJson {
|
||||||
|
Match3DConfigJson {
|
||||||
|
theme_text: payload
|
||||||
|
.theme_text
|
||||||
|
.as_deref()
|
||||||
|
.or(payload.seed_text.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(MATCH3D_DEFAULT_THEME)
|
||||||
|
.to_string(),
|
||||||
|
reference_image_src: payload.reference_image_src.clone(),
|
||||||
|
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
|
||||||
|
difficulty: payload
|
||||||
|
.difficulty
|
||||||
|
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
|
||||||
|
.clamp(1, 10),
|
||||||
|
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
|
||||||
|
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
|
||||||
|
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
|
||||||
|
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_config_from_message(
|
||||||
|
session: &Match3DAgentSessionRecord,
|
||||||
|
payload: &SendMatch3DAgentMessageRequest,
|
||||||
|
) -> Match3DConfigJson {
|
||||||
|
let current = resolve_config_or_default(session.config.as_ref());
|
||||||
|
let text = payload.text.trim();
|
||||||
|
let reference_image_src = payload
|
||||||
|
.reference_image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or(current.reference_image_src);
|
||||||
|
let quick_fill_requested =
|
||||||
|
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
|
||||||
|
|
||||||
|
let mut theme_text = current.theme_text;
|
||||||
|
let mut clear_count = current.clear_count.max(1);
|
||||||
|
let mut difficulty = current.difficulty.clamp(1, 10);
|
||||||
|
let asset_style_id = current.asset_style_id;
|
||||||
|
let asset_style_label = current.asset_style_label;
|
||||||
|
let asset_style_prompt = current.asset_style_prompt;
|
||||||
|
let generate_click_sound = current.generate_click_sound;
|
||||||
|
|
||||||
|
match session.current_turn {
|
||||||
|
0 => {
|
||||||
|
theme_text = if quick_fill_requested {
|
||||||
|
MATCH3D_DEFAULT_THEME.to_string()
|
||||||
|
} else {
|
||||||
|
parse_theme_answer(text).unwrap_or(theme_text)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
clear_count = if quick_fill_requested {
|
||||||
|
clear_count
|
||||||
|
} else {
|
||||||
|
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
|
||||||
|
.unwrap_or(clear_count)
|
||||||
|
}
|
||||||
|
.max(1);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
difficulty = if quick_fill_requested {
|
||||||
|
difficulty
|
||||||
|
} else {
|
||||||
|
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
|
||||||
|
}
|
||||||
|
.clamp(1, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Match3DConfigJson {
|
||||||
|
theme_text,
|
||||||
|
reference_image_src,
|
||||||
|
clear_count,
|
||||||
|
difficulty,
|
||||||
|
asset_style_id,
|
||||||
|
asset_style_label,
|
||||||
|
asset_style_prompt,
|
||||||
|
generate_click_sound,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
|
||||||
|
config
|
||||||
|
.map(|config| Match3DConfigJson {
|
||||||
|
theme_text: config.theme_text.clone(),
|
||||||
|
reference_image_src: config.reference_image_src.clone(),
|
||||||
|
clear_count: config.clear_count.max(1),
|
||||||
|
difficulty: config.difficulty.clamp(1, 10),
|
||||||
|
asset_style_id: config.asset_style_id.clone(),
|
||||||
|
asset_style_label: config.asset_style_label.clone(),
|
||||||
|
asset_style_prompt: config.asset_style_prompt.clone(),
|
||||||
|
generate_click_sound: config.generate_click_sound,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| Match3DConfigJson {
|
||||||
|
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||||
|
reference_image_src: None,
|
||||||
|
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||||
|
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||||
|
asset_style_id: None,
|
||||||
|
asset_style_label: None,
|
||||||
|
asset_style_prompt: None,
|
||||||
|
generate_click_sound: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
|
||||||
|
serde_json::to_string(config).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_seed_text(
|
||||||
|
payload: &CreateMatch3DAgentSessionRequest,
|
||||||
|
config: &Match3DConfigJson,
|
||||||
|
) -> String {
|
||||||
|
payload
|
||||||
|
.seed_text
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"{}题材,消除{}次,难度{}",
|
||||||
|
config.theme_text, config.clear_count, config.difficulty
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
|
||||||
|
format!(
|
||||||
|
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。",
|
||||||
|
config.theme_text,
|
||||||
|
config.clear_count,
|
||||||
|
config.clear_count.saturating_mul(3),
|
||||||
|
config.difficulty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
|
||||||
|
match current_turn {
|
||||||
|
0 => MATCH3D_QUESTION_THEME.to_string(),
|
||||||
|
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
|
||||||
|
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
|
||||||
|
_ => build_match3d_assistant_reply(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
|
||||||
|
match current_turn {
|
||||||
|
0 => 0,
|
||||||
|
1 => 33,
|
||||||
|
2 => 66,
|
||||||
|
_ => 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_theme_answer(text: &str) -> Option<String> {
|
||||||
|
for marker in ["题材", "主题"] {
|
||||||
|
if let Some((_, value)) = text.split_once(marker) {
|
||||||
|
let normalized = value
|
||||||
|
.trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace())
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_matches(['。', ',', ',', ';', ';'])
|
||||||
|
.to_string();
|
||||||
|
if !normalized.is_empty() {
|
||||||
|
return Some(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
|
||||||
|
for keyword in keywords {
|
||||||
|
if let Some(index) = text.find(keyword) {
|
||||||
|
let suffix = &text[index + keyword.len()..];
|
||||||
|
if let Some(value) = first_positive_integer(suffix) {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first_positive_integer(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_positive_integer(text: &str) -> Option<u32> {
|
||||||
|
let mut digits = String::new();
|
||||||
|
for ch in text.chars() {
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
digits.push(ch);
|
||||||
|
} else if !digits.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
digits.parse::<u32>().ok().filter(|value| *value > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||||
|
let mut result: Vec<String> = Vec::new();
|
||||||
|
for tag in tags {
|
||||||
|
let trimmed = normalize_match3d_tag(tag.as_str());
|
||||||
|
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
|
||||||
|
result.push(trimmed);
|
||||||
|
}
|
||||||
|
if result.len() >= 6 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
async fn generate_match3d_draft_plan(
|
||||||
|
state: &AppState,
|
||||||
|
config: &Match3DConfigJson,
|
||||||
|
) -> Match3DGeneratedDraftPlan {
|
||||||
|
let Some(llm_client) = state
|
||||||
|
.creative_agent_gpt5_client()
|
||||||
|
.or_else(|| state.llm_client())
|
||||||
|
else {
|
||||||
|
return fallback_match3d_draft_plan(config);
|
||||||
|
};
|
||||||
|
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
|
||||||
|
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
|
||||||
|
let generated_item_count = resolve_match3d_generated_item_count(config);
|
||||||
|
let user_prompt = format!(
|
||||||
|
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。",
|
||||||
|
config.theme_text, gameplay_item_count, generated_item_count
|
||||||
|
);
|
||||||
|
let response = llm_client
|
||||||
|
.request_text(
|
||||||
|
LlmTextRequest::new(vec![
|
||||||
|
LlmMessage::system(system_prompt),
|
||||||
|
LlmMessage::user(user_prompt),
|
||||||
|
])
|
||||||
|
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||||
|
.with_responses_api(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
|
||||||
|
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = MATCH3D_AGENT_PROVIDER,
|
||||||
|
theme_text = config.theme_text.as_str(),
|
||||||
|
error = %error,
|
||||||
|
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
|
||||||
|
);
|
||||||
|
fallback_match3d_draft_plan(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn parse_match3d_draft_plan(
|
||||||
|
raw: &str,
|
||||||
|
config: &Match3DConfigJson,
|
||||||
|
) -> Option<Match3DGeneratedDraftPlan> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
let json_text = if let Some(start) = raw.find('{')
|
||||||
|
&& let Some(end) = raw.rfind('}')
|
||||||
|
&& end > start
|
||||||
|
{
|
||||||
|
&raw[start..=end]
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
|
let value = serde_json::from_str::<Value>(json_text).ok()?;
|
||||||
|
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
|
||||||
|
if game_name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let tags = value
|
||||||
|
.get("tags")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let fallback = fallback_match3d_draft_plan(config);
|
||||||
|
let summary = value
|
||||||
|
.get("summary")
|
||||||
|
.or_else(|| value.get("description"))
|
||||||
|
.or_else(|| value.get("workSummary"))
|
||||||
|
.or_else(|| value.get("work_summary"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(normalize_match3d_work_summary)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(fallback.metadata.summary);
|
||||||
|
let items = value
|
||||||
|
.get("items")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let name =
|
||||||
|
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let item_size = item
|
||||||
|
.get("itemSize")
|
||||||
|
.or_else(|| item.get("item_size"))
|
||||||
|
.or_else(|| item.get("size"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(normalize_match3d_item_size)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| infer_match3d_item_size(&name));
|
||||||
|
let sound_prompt = item
|
||||||
|
.get("soundPrompt")
|
||||||
|
.or_else(|| item.get("sound_prompt"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(normalize_match3d_audio_prompt)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
|
||||||
|
Some(Match3DGeneratedItemPlan {
|
||||||
|
name,
|
||||||
|
item_size,
|
||||||
|
sound_prompt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let background_prompt = value
|
||||||
|
.get("backgroundPrompt")
|
||||||
|
.or_else(|| value.get("background_prompt"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(normalize_match3d_background_prompt)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(fallback.background_prompt);
|
||||||
|
|
||||||
|
Some(Match3DGeneratedDraftPlan {
|
||||||
|
metadata: Match3DGeneratedWorkMetadata {
|
||||||
|
game_name,
|
||||||
|
summary,
|
||||||
|
tags: normalize_match3d_tag_candidates(tags),
|
||||||
|
},
|
||||||
|
items: normalize_match3d_item_plan(config, items),
|
||||||
|
background_prompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
|
||||||
|
let config = Match3DConfigJson {
|
||||||
|
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||||
|
reference_image_src: None,
|
||||||
|
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||||
|
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||||
|
asset_style_id: None,
|
||||||
|
asset_style_label: None,
|
||||||
|
asset_style_prompt: None,
|
||||||
|
generate_click_sound: false,
|
||||||
|
};
|
||||||
|
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_match3d_game_name(raw: &str) -> String {
|
||||||
|
raw.trim()
|
||||||
|
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_control())
|
||||||
|
.take(16)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_match3d_work_summary(raw: &str) -> String {
|
||||||
|
raw.trim()
|
||||||
|
.trim_matches(['"', '\'', '“', '”'])
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_control())
|
||||||
|
.take(80)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
|
||||||
|
let theme = theme_text.trim();
|
||||||
|
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
|
||||||
|
Match3DGeneratedWorkMetadata {
|
||||||
|
game_name: format!("{normalized_theme}抓大鹅"),
|
||||||
|
summary: normalize_match3d_work_summary(
|
||||||
|
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
|
||||||
|
),
|
||||||
|
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
|
||||||
|
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||||
|
let items = fallback_match3d_item_names(config.theme_text.as_str())
|
||||||
|
.into_iter()
|
||||||
|
.take(resolve_match3d_generated_item_count(config))
|
||||||
|
.map(|name| Match3DGeneratedItemPlan {
|
||||||
|
item_size: infer_match3d_item_size(&name),
|
||||||
|
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Match3DGeneratedDraftPlan {
|
||||||
|
background_prompt: build_fallback_match3d_background_prompt(config),
|
||||||
|
metadata,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
|
|||||||
) -> Match3DAgentSessionSnapshotResponse {
|
) -> Match3DAgentSessionSnapshotResponse {
|
||||||
let mut response = map_match3d_agent_session_response(session);
|
let mut response = map_match3d_agent_session_response(session);
|
||||||
if let Some(draft) = response.draft.as_mut() {
|
if let Some(draft) = response.draft.as_mut() {
|
||||||
|
if generated_item_assets.is_empty() {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
draft.generated_item_assets = generated_item_assets
|
draft.generated_item_assets = generated_item_assets
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
|
|||||||
pub(super) fn map_match3d_draft_response(
|
pub(super) fn map_match3d_draft_response(
|
||||||
draft: Match3DResultDraftRecord,
|
draft: Match3DResultDraftRecord,
|
||||||
) -> Match3DResultDraftResponse {
|
) -> Match3DResultDraftResponse {
|
||||||
Match3DResultDraftResponse {
|
// 中文注释:session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
|
||||||
|
let generated_item_assets = parse_match3d_generated_item_assets(
|
||||||
|
draft.generated_item_assets_json.as_deref(),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(Match3DGeneratedItemAsset::from)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
|
||||||
|
let mut response = Match3DResultDraftResponse {
|
||||||
profile_id: draft.profile_id,
|
profile_id: draft.profile_id,
|
||||||
game_name: draft.game_name,
|
game_name: draft.game_name,
|
||||||
theme_text: draft.theme_text,
|
theme_text: draft.theme_text,
|
||||||
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
|
|||||||
background_image_src: None,
|
background_image_src: None,
|
||||||
background_image_object_key: None,
|
background_image_object_key: None,
|
||||||
generated_background_asset: None,
|
generated_background_asset: None,
|
||||||
generated_item_assets: Vec::new(),
|
generated_item_assets: generated_item_assets
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(map_match3d_generated_item_asset_for_agent)
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if response
|
||||||
|
.cover_image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
|
||||||
}
|
}
|
||||||
|
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn map_match3d_generated_item_asset_for_agent(
|
pub(super) fn map_match3d_generated_item_asset_for_agent(
|
||||||
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
|
|||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn match3d_text_present(value: Option<&String>) -> bool {
|
||||||
|
value.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||||
|
match3d_text_present(asset.image_src.as_ref())
|
||||||
|
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||||
|
|| asset.image_views.iter().any(|view| {
|
||||||
|
match3d_text_present(view.image_src.as_ref())
|
||||||
|
|| match3d_text_present(view.image_object_key.as_ref())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||||
|
match3d_text_present(asset.image_src.as_ref())
|
||||||
|
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||||
|
|| match3d_text_present(asset.container_image_src.as_ref())
|
||||||
|
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_match3d_work_generation_status(
|
||||||
|
item: &Match3DWorkProfileRecord,
|
||||||
|
assets: &[Match3DGeneratedItemAssetJson],
|
||||||
|
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if item.publication_status.eq_ignore_ascii_case("published") {
|
||||||
|
return Some("ready".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if assets.is_empty()
|
||||||
|
|| !assets.iter().any(match3d_item_asset_has_image)
|
||||||
|
|| !background_asset.is_some_and(match3d_background_asset_has_image)
|
||||||
|
{
|
||||||
|
return Some("generating".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Some("ready".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn map_match3d_message_response(
|
pub(super) fn map_match3d_message_response(
|
||||||
message: Match3DAgentMessageRecord,
|
message: Match3DAgentMessageRecord,
|
||||||
) -> Match3DAgentMessageResponse {
|
) -> Match3DAgentMessageResponse {
|
||||||
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
|
|||||||
let generated_item_asset_json =
|
let generated_item_asset_json =
|
||||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||||
|
let generation_status = resolve_match3d_work_generation_status(
|
||||||
|
&item,
|
||||||
|
&generated_item_asset_json,
|
||||||
|
background_asset.as_ref(),
|
||||||
|
);
|
||||||
let generated_background_asset = background_asset
|
let generated_background_asset = background_asset
|
||||||
.clone()
|
.clone()
|
||||||
.map(map_match3d_background_asset_for_work);
|
.map(map_match3d_background_asset_for_work);
|
||||||
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
|
|||||||
updated_at: item.updated_at,
|
updated_at: item.updated_at,
|
||||||
published_at: item.published_at,
|
published_at: item.published_at,
|
||||||
publish_ready: item.publish_ready,
|
publish_ready: item.publish_ready,
|
||||||
|
generation_status,
|
||||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||||
background_image_src: background_asset
|
background_image_src: background_asset
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
pub(super) fn normalize_match3d_run_status(value: &str) -> &str {
|
||||||
|
match value {
|
||||||
|
"Running" => "running",
|
||||||
|
"Won" => "won",
|
||||||
|
"Failed" => "failed",
|
||||||
|
"Stopped" => "stopped",
|
||||||
|
_ => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_match3d_item_state(value: &str) -> &str {
|
||||||
|
match value {
|
||||||
|
"InBoard" => "in_board",
|
||||||
|
"InTray" => "in_tray",
|
||||||
|
"Cleared" => "cleared",
|
||||||
|
_ => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str {
|
||||||
|
match value {
|
||||||
|
"TimeUp" => "time_up",
|
||||||
|
"TrayFull" => "tray_full",
|
||||||
|
_ => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str {
|
||||||
|
match value {
|
||||||
|
"RejectedNotClickable" => "item_not_clickable",
|
||||||
|
"RejectedAlreadyMoved" => "item_not_in_board",
|
||||||
|
"RejectedTrayFull" => "tray_full",
|
||||||
|
"VersionConflict" => "snapshot_version_mismatch",
|
||||||
|
"RunFinished" => "run_not_active",
|
||||||
|
_ => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) async fn generate_match3d_material_sheet(
|
||||||
|
state: &AppState,
|
||||||
|
config: &Match3DConfigJson,
|
||||||
|
item_names: &[String],
|
||||||
|
) -> Result<Match3DMaterialSheet, AppError> {
|
||||||
|
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||||
|
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||||
|
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||||
|
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||||
|
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
prompt.as_str(),
|
||||||
|
negative_prompt.as_str(),
|
||||||
|
"抓大鹅素材图生成失败",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Match3DMaterialSheet {
|
||||||
|
task_id: generated.task_id,
|
||||||
|
image,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_match3d_vector_engine_gemini_image_settings(
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||||
|
let base_url = state
|
||||||
|
.config
|
||||||
|
.vector_engine_base_url
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('/');
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_key = state
|
||||||
|
.config
|
||||||
|
.vector_engine_api_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||||
|
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||||
|
) -> Result<reqwest::Client, AppError> {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||||
|
prompt: &str,
|
||||||
|
negative_prompt: &str,
|
||||||
|
failure_context: &str,
|
||||||
|
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||||
|
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||||
|
prompt,
|
||||||
|
negative_prompt,
|
||||||
|
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||||
|
);
|
||||||
|
let response = http_client
|
||||||
|
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||||
|
settings,
|
||||||
|
))
|
||||||
|
.query(&[("key", settings.api_key.as_str())])
|
||||||
|
.header(header::ACCEPT, "application/json")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||||
|
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
let response_text = response.text().await.map_err(|error| {
|
||||||
|
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||||
|
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||||
|
status,
|
||||||
|
response_text.as_str(),
|
||||||
|
failure_context,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = parse_match3d_json_payload(
|
||||||
|
response_text.as_str(),
|
||||||
|
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||||
|
"vector-engine-gemini",
|
||||||
|
)?;
|
||||||
|
let image_urls = extract_match3d_image_urls(&payload);
|
||||||
|
if !image_urls.is_empty() {
|
||||||
|
return download_match3d_images_from_urls(
|
||||||
|
http_client,
|
||||||
|
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||||
|
image_urls,
|
||||||
|
1,
|
||||||
|
"vector-engine-gemini",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let b64_images = extract_match3d_b64_images(&payload);
|
||||||
|
if !b64_images.is_empty() {
|
||||||
|
return Ok(match3d_images_from_base64(
|
||||||
|
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||||
|
b64_images,
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||||
|
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||||
|
prompt: &str,
|
||||||
|
negative_prompt: &str,
|
||||||
|
aspect_ratio: &str,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{
|
||||||
|
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseModalities": ["TEXT", "IMAGE"],
|
||||||
|
"imageConfig": {
|
||||||
|
"aspectRatio": aspect_ratio,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
|
||||||
|
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||||
|
) -> String {
|
||||||
|
let base_url = settings.base_url.trim_end_matches("/v1");
|
||||||
|
format!(
|
||||||
|
"{}/v1beta/models/{}:generateContent",
|
||||||
|
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||||
|
let prompt = prompt.trim();
|
||||||
|
let negative_prompt = negative_prompt.trim();
|
||||||
|
if negative_prompt.is_empty() {
|
||||||
|
return prompt.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{prompt}\n避免:{negative_prompt}")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_match3d_images_from_urls(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
task_id: String,
|
||||||
|
image_urls: Vec<String>,
|
||||||
|
candidate_count: u32,
|
||||||
|
provider: &str,
|
||||||
|
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||||
|
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||||
|
for image_url in image_urls
|
||||||
|
.into_iter()
|
||||||
|
.take(candidate_count.clamp(1, 4) as usize)
|
||||||
|
{
|
||||||
|
images
|
||||||
|
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||||
|
}
|
||||||
|
Ok(OpenAiGeneratedImages {
|
||||||
|
task_id,
|
||||||
|
actual_prompt: None,
|
||||||
|
images,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_match3d_remote_image(
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
image_url: &str,
|
||||||
|
provider: &str,
|
||||||
|
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||||
|
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let status = response.status();
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or("image/png")
|
||||||
|
.to_string();
|
||||||
|
let body = response.bytes().await.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": "下载抓大鹅生成图片失败",
|
||||||
|
"status": status.as_u16(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||||
|
Ok(DownloadedOpenAiImage {
|
||||||
|
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||||
|
mime_type,
|
||||||
|
bytes: body.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match3d_images_from_base64(
|
||||||
|
task_id: String,
|
||||||
|
b64_images: Vec<String>,
|
||||||
|
candidate_count: u32,
|
||||||
|
) -> OpenAiGeneratedImages {
|
||||||
|
let images = b64_images
|
||||||
|
.into_iter()
|
||||||
|
.take(candidate_count.clamp(1, 4) as usize)
|
||||||
|
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||||
|
.collect();
|
||||||
|
OpenAiGeneratedImages {
|
||||||
|
task_id,
|
||||||
|
actual_prompt: None,
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||||
|
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||||
|
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||||
|
Some(DownloadedOpenAiImage {
|
||||||
|
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||||
|
mime_type,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_match3d_json_payload(
|
||||||
|
raw_text: &str,
|
||||||
|
failure_context: &str,
|
||||||
|
provider: &str,
|
||||||
|
) -> Result<Value, AppError> {
|
||||||
|
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": provider,
|
||||||
|
"message": format!("{failure_context}:{error}"),
|
||||||
|
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||||
|
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||||
|
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||||
|
let mut deduped = Vec::new();
|
||||||
|
for url in urls {
|
||||||
|
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||||
|
deduped.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||||
|
let mut values = Vec::new();
|
||||||
|
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||||
|
collect_match3d_inline_image_data(payload, &mut values);
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
|
||||||
|
match payload {
|
||||||
|
Value::Array(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
collect_match3d_inline_image_data(entry, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
for key in ["inlineData", "inline_data"] {
|
||||||
|
if let Some(Value::Object(inline_data)) = object.get(key) {
|
||||||
|
let mime_type = inline_data
|
||||||
|
.get("mimeType")
|
||||||
|
.or_else(|| inline_data.get("mime_type"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("image/png")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(data) = inline_data
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
results.push(data.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for nested_value in object.values() {
|
||||||
|
collect_match3d_inline_image_data(nested_value, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||||
|
let mut results = Vec::new();
|
||||||
|
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||||
|
results.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||||
|
match payload {
|
||||||
|
Value::Array(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
collect_match3d_strings_by_key(entry, target_key, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
for (key, nested_value) in object {
|
||||||
|
if key == target_key {
|
||||||
|
match nested_value {
|
||||||
|
Value::String(text) => {
|
||||||
|
let text = text.trim();
|
||||||
|
if !text.is_empty() {
|
||||||
|
results.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
if let Some(text) = entry
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
results.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect_match3d_strings_by_key(nested_value, target_key, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"message": message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||||
|
upstream_status: reqwest::StatusCode,
|
||||||
|
raw_text: &str,
|
||||||
|
fallback_message: &str,
|
||||||
|
) -> AppError {
|
||||||
|
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||||
|
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||||
|
tracing::warn!(
|
||||||
|
provider = "vector-engine-gemini",
|
||||||
|
upstream_status = upstream_status.as_u16(),
|
||||||
|
message = %message,
|
||||||
|
raw_excerpt = %raw_excerpt,
|
||||||
|
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||||
|
);
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "vector-engine-gemini",
|
||||||
|
"upstreamStatus": upstream_status.as_u16(),
|
||||||
|
"message": message,
|
||||||
|
"rawExcerpt": raw_excerpt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||||
|
let trimmed = raw_text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return fallback_message.to_string();
|
||||||
|
}
|
||||||
|
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||||
|
for key in ["message", "code"] {
|
||||||
|
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||||
|
return if key == "message" {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
format!("{fallback_message}({value})")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||||
|
raw_text.chars().take(max_chars).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||||
|
let mime_type = content_type
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or("image/png");
|
||||||
|
match mime_type {
|
||||||
|
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||||
|
mime_type.to_string()
|
||||||
|
}
|
||||||
|
_ => "image/png".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||||
|
match mime_type {
|
||||||
|
"image/png" => "png",
|
||||||
|
"image/webp" => "webp",
|
||||||
|
"image/gif" => "gif",
|
||||||
|
"image/jpeg" | "image/jpg" => "jpg",
|
||||||
|
_ => "png",
|
||||||
|
}
|
||||||
|
}
|
||||||
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
File diff suppressed because it is too large
Load Diff
306
server-rs/crates/api-server/src/process_metrics.rs
Normal file
306
server-rs/crates/api-server/src/process_metrics.rs
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use opentelemetry::global;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
// 进程指标只描述 api-server 自身,不携带请求、用户或作品维度,避免 OTLP 指标高基数膨胀。
|
||||||
|
pub(crate) fn register_process_metrics() {
|
||||||
|
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||||
|
REGISTERED.get_or_init(register_process_metrics_once);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_process_metrics_once() {
|
||||||
|
let meter = global::meter("genarrative-api");
|
||||||
|
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("process.memory.usage")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("api-server process physical memory usage")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
observer.observe(to_i64(snapshot.rss_bytes), &[]);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("process.memory.virtual")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("api-server committed virtual memory")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(virtual_bytes) = snapshot.virtual_bytes {
|
||||||
|
observer.observe(to_i64(virtual_bytes), &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("genarrative.process.memory.private")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("api-server private memory for local diagnostics")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(private_bytes) = snapshot.private_bytes {
|
||||||
|
observer.observe(to_i64(private_bytes), &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("process.thread.count")
|
||||||
|
.with_unit("{thread}")
|
||||||
|
.with_description("api-server process thread count")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
observer.observe(to_i64(snapshot.thread_count), &[]);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("process.windows.handle.count")
|
||||||
|
.with_unit("{handle}")
|
||||||
|
.with_description("api-server process handle count on Windows")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(handle_count) = snapshot.windows_handle_count {
|
||||||
|
observer.observe(to_i64(handle_count), &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("process.unix.file_descriptor.count")
|
||||||
|
.with_unit("{file_descriptor}")
|
||||||
|
.with_description("api-server process file descriptor count on Unix")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(fd_count) = snapshot.unix_fd_count {
|
||||||
|
observer.observe(to_i64(fd_count), &[]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_i64(value: u64) -> i64 {
|
||||||
|
value.min(i64::MAX as u64) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct ProcessMetricsSnapshot {
|
||||||
|
rss_bytes: u64,
|
||||||
|
private_bytes: Option<u64>,
|
||||||
|
virtual_bytes: Option<u64>,
|
||||||
|
thread_count: u64,
|
||||||
|
windows_handle_count: Option<u64>,
|
||||||
|
unix_fd_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessMetricsSnapshot {
|
||||||
|
fn collect() -> Option<Self> {
|
||||||
|
collect_process_metrics()
|
||||||
|
.inspect_err(|error| {
|
||||||
|
warn!(%error, "采集 api-server 进程内存指标失败");
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
System::{
|
||||||
|
ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX},
|
||||||
|
Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = unsafe { GetCurrentProcess() };
|
||||||
|
let mut counters = PROCESS_MEMORY_COUNTERS_EX {
|
||||||
|
cb: std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let ok = unsafe {
|
||||||
|
GetProcessMemoryInfo(
|
||||||
|
handle,
|
||||||
|
std::ptr::addr_of_mut!(counters).cast(),
|
||||||
|
counters.cb,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
return Err("GetProcessMemoryInfo returned false".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut handle_count = 0_u32;
|
||||||
|
let handle_count = if unsafe { GetProcessHandleCount(handle, &mut handle_count) } == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(u64::from(handle_count))
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ProcessMetricsSnapshot {
|
||||||
|
rss_bytes: counters.WorkingSetSize as u64,
|
||||||
|
private_bytes: Some(counters.PrivateUsage as u64),
|
||||||
|
virtual_bytes: Some(counters.PrivateUsage as u64),
|
||||||
|
thread_count: u64::from(unsafe { GetCurrentProcessId() }.thread_count()?),
|
||||||
|
windows_handle_count: handle_count,
|
||||||
|
unix_fd_count: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
trait WindowsProcessThreadCount {
|
||||||
|
fn thread_count(self) -> Result<u32, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl WindowsProcessThreadCount for u32 {
|
||||||
|
fn thread_count(self) -> Result<u32, String> {
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
|
||||||
|
System::Diagnostics::ToolHelp::{
|
||||||
|
CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next,
|
||||||
|
TH32CS_SNAPPROCESS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||||
|
if snapshot == INVALID_HANDLE_VALUE {
|
||||||
|
return Err("CreateToolhelp32Snapshot returned INVALID_HANDLE_VALUE".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entry = PROCESSENTRY32 {
|
||||||
|
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut found = None;
|
||||||
|
let mut ok = unsafe { Process32First(snapshot, &mut entry) };
|
||||||
|
while ok != 0 {
|
||||||
|
if entry.th32ProcessID == self {
|
||||||
|
found = Some(entry.cntThreads);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ok = unsafe { Process32Next(snapshot, &mut entry) };
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
found.ok_or_else(|| format!("process {self} not found in ToolHelp snapshot"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
|
||||||
|
let status = std::fs::read_to_string("/proc/self/status")
|
||||||
|
.map_err(|error| format!("read /proc/self/status failed: {error}"))?;
|
||||||
|
let statm = std::fs::read_to_string("/proc/self/statm")
|
||||||
|
.map_err(|error| format!("read /proc/self/statm failed: {error}"))?;
|
||||||
|
let page_size = linux_page_size_bytes()?;
|
||||||
|
|
||||||
|
let rss_bytes = parse_status_kb(&status, "VmRSS:")
|
||||||
|
.map(|value| value * 1024)
|
||||||
|
.or_else(|| parse_statm_pages(&statm, 1).map(|value| value * page_size))
|
||||||
|
.ok_or_else(|| "missing VmRSS/statm resident field".to_string())?;
|
||||||
|
let virtual_bytes = parse_status_kb(&status, "VmSize:")
|
||||||
|
.map(|value| value * 1024)
|
||||||
|
.or_else(|| parse_statm_pages(&statm, 0).map(|value| value * page_size))
|
||||||
|
.ok_or_else(|| "missing VmSize/statm size field".to_string())?;
|
||||||
|
let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024);
|
||||||
|
let thread_count = parse_status_u64(&status, "Threads:")
|
||||||
|
.ok_or_else(|| "missing Threads field".to_string())?;
|
||||||
|
|
||||||
|
Ok(ProcessMetricsSnapshot {
|
||||||
|
rss_bytes,
|
||||||
|
private_bytes,
|
||||||
|
virtual_bytes: Some(virtual_bytes),
|
||||||
|
thread_count,
|
||||||
|
windows_handle_count: None,
|
||||||
|
unix_fd_count: linux_fd_count(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_page_size_bytes() -> Result<u64, String> {
|
||||||
|
let output = std::process::Command::new("getconf")
|
||||||
|
.arg("PAGESIZE")
|
||||||
|
.output()
|
||||||
|
.map_err(|error| format!("getconf PAGESIZE failed: {error}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!("getconf PAGESIZE exited with {}", output.status));
|
||||||
|
}
|
||||||
|
let text = String::from_utf8(output.stdout)
|
||||||
|
.map_err(|error| format!("getconf PAGESIZE output is not utf8: {error}"))?;
|
||||||
|
text.trim()
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|error| format!("parse PAGESIZE failed: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_fd_count() -> Option<u64> {
|
||||||
|
let entries = std::fs::read_dir("/proc/self/fd").ok()?;
|
||||||
|
Some(entries.filter_map(Result::ok).count() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn parse_status_kb(status: &str, key: &str) -> Option<u64> {
|
||||||
|
parse_status_u64(status, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn parse_status_u64(status: &str, key: &str) -> Option<u64> {
|
||||||
|
status.lines().find_map(|line| {
|
||||||
|
let rest = line.strip_prefix(key)?.trim();
|
||||||
|
rest.split_whitespace().next()?.parse::<u64>().ok()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn parse_statm_pages(statm: &str, index: usize) -> Option<u64> {
|
||||||
|
statm
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(index)?
|
||||||
|
.parse::<u64>()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
|
||||||
|
Err("process metrics are only implemented for Windows and Linux".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use super::{parse_statm_pages, parse_status_kb, parse_status_u64};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn parses_linux_proc_status_memory_fields() {
|
||||||
|
let status = "Name:\tapi-server\nVmSize:\t 123456 kB\nVmRSS:\t 7890 kB\nVmData:\t 3456 kB\nThreads:\t37\n";
|
||||||
|
|
||||||
|
assert_eq!(parse_status_kb(status, "VmRSS:"), Some(7890));
|
||||||
|
assert_eq!(parse_status_kb(status, "VmSize:"), Some(123456));
|
||||||
|
assert_eq!(parse_status_kb(status, "VmData:"), Some(3456));
|
||||||
|
assert_eq!(parse_status_u64(status, "Threads:"), Some(37));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn parses_linux_statm_pages() {
|
||||||
|
assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 0), Some(100));
|
||||||
|
assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 1), Some(20));
|
||||||
|
assert_eq!(parse_statm_pages("100 20", 7), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
1909
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
1909
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
File diff suppressed because it is too large
Load Diff
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||||||
|
if error.code() == "UPSTREAM_ERROR" {
|
||||||
|
let body_text = error.body_text();
|
||||||
|
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": format!("拼图图片生成失败:{body_text}"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||||||
|
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||||||
|
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn generate_puzzle_image_candidates(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
level_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
reference_image_src: Option<&str>,
|
||||||
|
use_reference_image_edit: bool,
|
||||||
|
image_model: Option<&str>,
|
||||||
|
candidate_count: u32,
|
||||||
|
candidate_start_index: usize,
|
||||||
|
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
||||||
|
let total_started_at = Instant::now();
|
||||||
|
let count = candidate_count.clamp(1, 1);
|
||||||
|
let resolved_model = resolve_puzzle_image_model(image_model);
|
||||||
|
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
||||||
|
let has_reference_image = has_puzzle_reference_image(reference_image_src);
|
||||||
|
let should_use_reference_image_edit =
|
||||||
|
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
|
||||||
|
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
|
||||||
|
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||||
|
should_use_reference_image_edit,
|
||||||
|
);
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
prompt_chars = prompt.chars().count(),
|
||||||
|
actual_prompt_chars = actual_prompt.chars().count(),
|
||||||
|
has_reference_image,
|
||||||
|
use_reference_image_edit = should_use_reference_image_edit,
|
||||||
|
"拼图图片生成请求已准备"
|
||||||
|
);
|
||||||
|
let reference_image_started_at = Instant::now();
|
||||||
|
let reference_image = match reference_image_src
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.filter(|_| should_use_reference_image_edit)
|
||||||
|
{
|
||||||
|
Some(source) => {
|
||||||
|
let resolved =
|
||||||
|
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
reference_mime = %resolved.mime_type,
|
||||||
|
reference_bytes = resolved.bytes_len,
|
||||||
|
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图参考图解析完成"
|
||||||
|
);
|
||||||
|
Some(resolved)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if !should_use_reference_image_edit {
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
has_reference_image,
|
||||||
|
use_reference_image_edit = should_use_reference_image_edit,
|
||||||
|
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图参考图解析跳过"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||||
|
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||||
|
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||||
|
let vector_engine_started_at = Instant::now();
|
||||||
|
let generated = if should_use_reference_image_edit {
|
||||||
|
let reference_image = reference_image.as_ref().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": "puzzle",
|
||||||
|
"field": "referenceImageSrc",
|
||||||
|
"message": "AI 重绘需要提供参考图。",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
actual_prompt.as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
count,
|
||||||
|
reference_image,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match edit_result {
|
||||||
|
Ok(generated) => Ok(generated),
|
||||||
|
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
reference_mime = %reference_image.mime_type,
|
||||||
|
reference_bytes = reference_image.bytes_len,
|
||||||
|
error = %error,
|
||||||
|
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||||||
|
);
|
||||||
|
create_puzzle_vector_engine_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
resolved_model,
|
||||||
|
actual_prompt.as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
count,
|
||||||
|
Some(reference_image),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
create_puzzle_vector_engine_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
resolved_model,
|
||||||
|
actual_prompt.as_str(),
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
count,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
generated_image_count = generated.images.len(),
|
||||||
|
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图 VectorEngine 生图与下载完成"
|
||||||
|
);
|
||||||
|
let mut items = Vec::with_capacity(generated.images.len());
|
||||||
|
|
||||||
|
for (index, image) in generated.images.into_iter().enumerate() {
|
||||||
|
let candidate_id = format!(
|
||||||
|
"{session_id}-candidate-{}",
|
||||||
|
candidate_start_index + index + 1
|
||||||
|
);
|
||||||
|
let downloaded_image = image.clone();
|
||||||
|
let persist_started_at = Instant::now();
|
||||||
|
let asset = persist_puzzle_generated_asset(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_id.as_str(),
|
||||||
|
generated.task_id.as_str(),
|
||||||
|
image,
|
||||||
|
current_utc_micros(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_id = %candidate_id,
|
||||||
|
image_bytes = downloaded_image.bytes.len(),
|
||||||
|
image_mime = %downloaded_image.mime_type,
|
||||||
|
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图生成图片已写入 OSS 与资产索引"
|
||||||
|
);
|
||||||
|
items.push(GeneratedPuzzleImageCandidate {
|
||||||
|
record: PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id,
|
||||||
|
image_src: asset.image_src,
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
actual_prompt: Some(actual_prompt.clone()),
|
||||||
|
source_type: resolved_model.candidate_source_type().to_string(),
|
||||||
|
// 单图生成结果总是直接成为当前正式图。
|
||||||
|
selected: index == 0,
|
||||||
|
},
|
||||||
|
downloaded_image,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
provider = resolved_model.provider_name(),
|
||||||
|
image_model = resolved_model.request_model_name(),
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
candidate_count = items.len(),
|
||||||
|
has_reference_image,
|
||||||
|
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
|
||||||
|
"拼图图片候选生成完成"
|
||||||
|
);
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||||
|
state: &AppState,
|
||||||
|
owner_user_id: &str,
|
||||||
|
session_id: &str,
|
||||||
|
level_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||||
|
let settings = require_openai_image_settings(state)?;
|
||||||
|
let http_client = build_openai_image_http_client(&settings)?;
|
||||||
|
let generated = create_openai_image_generation(
|
||||||
|
&http_client,
|
||||||
|
&settings,
|
||||||
|
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||||||
|
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
|
||||||
|
"9:16",
|
||||||
|
1,
|
||||||
|
&[],
|
||||||
|
"拼图 UI 背景图生成失败",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": VECTOR_ENGINE_PROVIDER,
|
||||||
|
"message": "拼图 UI 背景图生成失败:未返回图片",
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
persist_puzzle_ui_background_image(
|
||||||
|
state,
|
||||||
|
owner_user_id,
|
||||||
|
session_id,
|
||||||
|
level_name,
|
||||||
|
generated.task_id.as_str(),
|
||||||
|
image,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
|
||||||
|
level_name: &str,
|
||||||
|
prompt: &str,
|
||||||
|
) -> String {
|
||||||
|
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||||||
|
}
|
||||||
2009
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
2009
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -278,9 +278,73 @@ pub(super) fn map_puzzle_result_preview_finding_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
|
||||||
|
item.levels
|
||||||
|
.iter()
|
||||||
|
.map(|level| level.generation_status.trim())
|
||||||
|
.find(|status| *status == "generating")
|
||||||
|
.or_else(|| {
|
||||||
|
item.levels
|
||||||
|
.iter()
|
||||||
|
.map(|level| level.generation_status.trim())
|
||||||
|
.find(|status| *status == "ready")
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
item.levels
|
||||||
|
.iter()
|
||||||
|
.map(|level| level.generation_status.trim())
|
||||||
|
.find(|status| !status.is_empty())
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn map_puzzle_work_summary_response(
|
pub(super) fn map_puzzle_work_summary_response(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
item: PuzzleWorkProfileRecord,
|
item: PuzzleWorkProfileRecord,
|
||||||
|
) -> PuzzleWorkSummaryResponse {
|
||||||
|
let generation_status = resolve_puzzle_work_generation_status(&item);
|
||||||
|
let author = resolve_work_author_by_user_id(
|
||||||
|
state,
|
||||||
|
&item.owner_user_id,
|
||||||
|
Some(&item.author_display_name),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
PuzzleWorkSummaryResponse {
|
||||||
|
work_id: item.work_id,
|
||||||
|
profile_id: item.profile_id,
|
||||||
|
owner_user_id: item.owner_user_id,
|
||||||
|
source_session_id: item.source_session_id,
|
||||||
|
author_display_name: author.display_name,
|
||||||
|
work_title: item.work_title,
|
||||||
|
work_description: item.work_description,
|
||||||
|
level_name: item.level_name,
|
||||||
|
summary: item.summary,
|
||||||
|
theme_tags: item.theme_tags,
|
||||||
|
cover_image_src: item.cover_image_src,
|
||||||
|
cover_asset_id: item.cover_asset_id,
|
||||||
|
publication_status: item.publication_status,
|
||||||
|
updated_at: item.updated_at,
|
||||||
|
published_at: item.published_at,
|
||||||
|
play_count: item.play_count,
|
||||||
|
remix_count: item.remix_count,
|
||||||
|
like_count: item.like_count,
|
||||||
|
recent_play_count_7d: item.recent_play_count_7d,
|
||||||
|
point_incentive_total_half_points: item.point_incentive_total_half_points,
|
||||||
|
point_incentive_claimed_points: item.point_incentive_claimed_points,
|
||||||
|
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
|
||||||
|
point_incentive_claimable_points: item
|
||||||
|
.point_incentive_total_half_points
|
||||||
|
.saturating_div(2)
|
||||||
|
.saturating_sub(item.point_incentive_claimed_points),
|
||||||
|
publish_ready: item.publish_ready,
|
||||||
|
generation_status,
|
||||||
|
levels: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn map_puzzle_gallery_card_response(
|
||||||
|
state: &AppState,
|
||||||
|
item: PuzzleGalleryCardRecord,
|
||||||
) -> PuzzleWorkSummaryResponse {
|
) -> PuzzleWorkSummaryResponse {
|
||||||
let author = resolve_work_author_by_user_id(
|
let author = resolve_work_author_by_user_id(
|
||||||
state,
|
state,
|
||||||
@@ -316,6 +380,7 @@ pub(super) fn map_puzzle_work_summary_response(
|
|||||||
.saturating_div(2)
|
.saturating_div(2)
|
||||||
.saturating_sub(item.point_incentive_claimed_points),
|
.saturating_sub(item.point_incentive_claimed_points),
|
||||||
publish_ready: item.publish_ready,
|
publish_ready: item.publish_ready,
|
||||||
|
generation_status: item.generation_status,
|
||||||
levels: Vec::new(),
|
levels: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
880
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
880
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_generated_image_size_is_square_1_1() {
|
||||||
|
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
|
||||||
|
assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
|
||||||
|
let body = build_puzzle_vector_engine_image_request_body(
|
||||||
|
PuzzleImageModel::Gemini31FlashPreview,
|
||||||
|
"一只猫在雨夜灯牌下回头。",
|
||||||
|
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||||
|
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||||
|
4,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||||
|
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
|
||||||
|
assert_eq!(body["n"], 1);
|
||||||
|
assert!(body.get("official_fallback").is_none());
|
||||||
|
assert!(body.get("image").is_none());
|
||||||
|
assert!(
|
||||||
|
body["prompt"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("文字水印")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||||
|
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
image
|
||||||
|
.write_to(&mut cursor, ImageFormat::Png)
|
||||||
|
.expect("test image should encode");
|
||||||
|
let reference_image = PuzzleResolvedReferenceImage {
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
bytes_len: cursor.get_ref().len(),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
let images = body["image"]
|
||||||
|
.as_array()
|
||||||
|
.expect("fallback generation should include reference image array");
|
||||||
|
assert_eq!(images.len(), 1);
|
||||||
|
assert!(
|
||||||
|
images[0]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.starts_with("data:image/png;base64,")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||||
|
let settings = PuzzleVectorEngineSettings {
|
||||||
|
base_url: "https://vector.example/v1".to_string(),
|
||||||
|
api_key: "test-key".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
puzzle_vector_engine_images_edit_url(&settings),
|
||||||
|
"https://vector.example/v1/images/edits"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
|
||||||
|
let images = puzzle_images_from_base64(
|
||||||
|
"edit-1".to_string(),
|
||||||
|
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(images.images.len(), 1);
|
||||||
|
assert_eq!(images.images[0].mime_type, "image/png");
|
||||||
|
assert_eq!(images.images[0].extension, "png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
|
||||||
|
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
|
||||||
|
|
||||||
|
assert!(prompt.contains("参考图作为第一优先级"));
|
||||||
|
assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围"));
|
||||||
|
assert!(prompt.contains("请生成雨夜猫街。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
|
||||||
|
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false);
|
||||||
|
|
||||||
|
assert_eq!(prompt, "请生成雨夜猫街。");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_reference_image_edit_requires_ai_redraw() {
|
||||||
|
assert!(!should_use_puzzle_reference_image_edit(None, true));
|
||||||
|
assert!(!should_use_puzzle_reference_image_edit(
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(should_use_puzzle_reference_image_edit(
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||||
|
let sources = collect_puzzle_reference_image_sources(
|
||||||
|
Some("data:image/png;base64,a"),
|
||||||
|
&[
|
||||||
|
"data:image/png;base64,a".to_string(),
|
||||||
|
"data:image/png;base64,b".to_string(),
|
||||||
|
"data:image/png;base64,c".to_string(),
|
||||||
|
"data:image/png;base64,d".to_string(),
|
||||||
|
"data:image/png;base64,e".to_string(),
|
||||||
|
"data:image/png;base64,f".to_string(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(sources.len(), 5);
|
||||||
|
assert_eq!(sources[0], "data:image/png;base64,a");
|
||||||
|
assert_eq!(sources[1], "data:image/png;base64,b");
|
||||||
|
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||||
|
let error = map_puzzle_vector_engine_request_error(
|
||||||
|
"创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||||
|
let error = map_puzzle_vector_engine_upstream_error(
|
||||||
|
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
|
||||||
|
let timeout_error = map_puzzle_vector_engine_upstream_error(
|
||||||
|
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
);
|
||||||
|
assert!(should_fallback_puzzle_reference_edit_to_generation(
|
||||||
|
&timeout_error
|
||||||
|
));
|
||||||
|
|
||||||
|
let auth_error = map_puzzle_vector_engine_upstream_error(
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED,
|
||||||
|
r#"{"error":{"message":"invalid api key"}}"#,
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
);
|
||||||
|
assert!(!should_fallback_puzzle_reference_edit_to_generation(
|
||||||
|
&auth_error
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||||
|
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||||
|
Ok(_) => panic!("invalid url should fail request build"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
let app_error = map_puzzle_vector_engine_reqwest_error(
|
||||||
|
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||||
|
"https://api.vectorengine.ai/v1/images/edits",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = app_error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
|
||||||
|
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||||
|
"VECTOR_ENGINE_API_KEY 未配置".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
|
||||||
|
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
|
||||||
|
"APIMart 图片生成密钥未配置".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let response = error.into_response();
|
||||||
|
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
|
let body = response.into_body();
|
||||||
|
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body bytes should read");
|
||||||
|
let payload: Value =
|
||||||
|
serde_json::from_slice(&bytes).expect("error response should be valid json");
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["details"]["provider"],
|
||||||
|
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload["error"]["details"]["message"],
|
||||||
|
Value::String("VectorEngine 图片生成密钥未配置".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||||
|
let levels_json = serde_json::to_string(&vec![json!({
|
||||||
|
"level_id": "puzzle-level-1",
|
||||||
|
"level_name": "雨夜猫街",
|
||||||
|
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||||
|
"candidates": [],
|
||||||
|
"selected_candidate_id": null,
|
||||||
|
"cover_image_src": null,
|
||||||
|
"cover_asset_id": null,
|
||||||
|
"generation_status": "idle",
|
||||||
|
})])
|
||||||
|
.expect("levels json");
|
||||||
|
let payload = ExecutePuzzleAgentActionRequest {
|
||||||
|
action: "generate_puzzle_images".to_string(),
|
||||||
|
prompt_text: None,
|
||||||
|
reference_image_src: None,
|
||||||
|
reference_image_srcs: Vec::new(),
|
||||||
|
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||||
|
ai_redraw: None,
|
||||||
|
candidate_count: Some(1),
|
||||||
|
candidate_id: None,
|
||||||
|
level_id: Some("puzzle-level-1".to_string()),
|
||||||
|
work_title: Some("暖灯猫街作品".to_string()),
|
||||||
|
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
|
||||||
|
picture_description: None,
|
||||||
|
level_name: None,
|
||||||
|
summary: Some("当前关卡画面。".to_string()),
|
||||||
|
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
|
||||||
|
levels_json: Some(levels_json.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||||
|
"puzzle-session-1",
|
||||||
|
&payload,
|
||||||
|
Some(levels_json.as_str()),
|
||||||
|
1_713_686_401_234_567,
|
||||||
|
)
|
||||||
|
.expect("fallback session");
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(session.stage, "ready_to_publish");
|
||||||
|
assert_eq!(draft.work_title, "暖灯猫街作品");
|
||||||
|
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
|
||||||
|
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
|
||||||
|
assert_eq!(
|
||||||
|
draft.levels[0].picture_description,
|
||||||
|
"一只猫在雨夜灯牌下回头。"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
|
||||||
|
Some("雨夜猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
|
||||||
|
Some("暖灯猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
|
||||||
|
Some("雨夜猫街".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
|
||||||
|
let naming = parse_puzzle_level_naming_from_text(
|
||||||
|
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
|
||||||
|
)
|
||||||
|
.expect("naming should parse");
|
||||||
|
|
||||||
|
assert_eq!(naming.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
naming.work_description.as_deref(),
|
||||||
|
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
|
||||||
|
);
|
||||||
|
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
|
||||||
|
assert!(naming.work_tags.contains(&"雨夜".to_string()));
|
||||||
|
assert!(naming.work_tags.contains(&"猫咪".to_string()));
|
||||||
|
assert!(naming.work_tags.contains(&"灯牌".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
naming.ui_background_prompt.as_deref(),
|
||||||
|
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
|
||||||
|
let naming = parse_puzzle_level_naming_from_text(
|
||||||
|
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景,中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印,保留暖色灯光"}"#,
|
||||||
|
)
|
||||||
|
.expect("naming should parse");
|
||||||
|
let prompt = naming
|
||||||
|
.ui_background_prompt
|
||||||
|
.as_deref()
|
||||||
|
.expect("prompt should parse");
|
||||||
|
|
||||||
|
assert!(!prompt.contains("拼图槽"));
|
||||||
|
assert!(!prompt.contains("棋盘"));
|
||||||
|
assert!(!prompt.contains("HUD"));
|
||||||
|
assert!(!prompt.contains("按钮"));
|
||||||
|
assert!(!prompt.contains("文字"));
|
||||||
|
assert!(!prompt.contains("水印"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
|
||||||
|
assert_eq!(
|
||||||
|
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
|
||||||
|
"雨夜猫街"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
|
||||||
|
"奇境初见"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_name_image_data_url_downsizes_generated_image() {
|
||||||
|
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||||
|
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||||
|
image
|
||||||
|
.write_to(&mut cursor, ImageFormat::Png)
|
||||||
|
.expect("test image should encode");
|
||||||
|
let downloaded = PuzzleDownloadedImage {
|
||||||
|
extension: "png".to_string(),
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
bytes: cursor.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data_url =
|
||||||
|
build_puzzle_level_name_image_data_url(&downloaded).expect("data url should be generated");
|
||||||
|
|
||||||
|
assert!(data_url.starts_with("data:image/png;base64,"));
|
||||||
|
assert!(data_url.len() > "data:image/png;base64,".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||||
|
let levels_json = serde_json::to_string(&vec![json!({
|
||||||
|
"level_id": "puzzle-level-1",
|
||||||
|
"level_name": "猫画面",
|
||||||
|
"picture_description": "一只猫在雨夜灯牌下回头。",
|
||||||
|
"candidates": [],
|
||||||
|
"selected_candidate_id": null,
|
||||||
|
"cover_image_src": null,
|
||||||
|
"cover_asset_id": null,
|
||||||
|
"generation_status": "idle",
|
||||||
|
})])
|
||||||
|
.expect("levels json");
|
||||||
|
let payload = ExecutePuzzleAgentActionRequest {
|
||||||
|
action: "generate_puzzle_images".to_string(),
|
||||||
|
prompt_text: None,
|
||||||
|
reference_image_src: None,
|
||||||
|
reference_image_srcs: Vec::new(),
|
||||||
|
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||||
|
ai_redraw: None,
|
||||||
|
candidate_count: Some(1),
|
||||||
|
candidate_id: None,
|
||||||
|
level_id: Some("puzzle-level-1".to_string()),
|
||||||
|
work_title: Some("猫画面".to_string()),
|
||||||
|
work_description: None,
|
||||||
|
picture_description: None,
|
||||||
|
level_name: None,
|
||||||
|
summary: None,
|
||||||
|
theme_tags: Some(vec![]),
|
||||||
|
levels_json: Some(levels_json.clone()),
|
||||||
|
};
|
||||||
|
let session = build_puzzle_session_snapshot_from_action_payload(
|
||||||
|
"puzzle-session-1",
|
||||||
|
&payload,
|
||||||
|
Some(levels_json.as_str()),
|
||||||
|
1_713_686_401_234_567,
|
||||||
|
)
|
||||||
|
.expect("fallback session");
|
||||||
|
|
||||||
|
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
"puzzle-level-1",
|
||||||
|
"雨夜猫街",
|
||||||
|
"猫画面",
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
let draft = renamed.draft.expect("draft");
|
||||||
|
assert_eq!(draft.level_name, "雨夜猫街");
|
||||||
|
assert_eq!(draft.work_title, "雨夜猫街");
|
||||||
|
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
|
||||||
|
let mut session = PuzzleAgentSessionRecord {
|
||||||
|
session_id: "puzzle-session-1".to_string(),
|
||||||
|
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
current_turn: 1,
|
||||||
|
progress_percent: 94,
|
||||||
|
stage: "ready_to_publish".to_string(),
|
||||||
|
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||||
|
draft: Some(test_puzzle_draft_record()),
|
||||||
|
messages: Vec::new(),
|
||||||
|
last_assistant_reply: None,
|
||||||
|
published_profile_id: None,
|
||||||
|
suggested_actions: Vec::new(),
|
||||||
|
result_preview: None,
|
||||||
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let draft = session.draft.as_mut().expect("draft");
|
||||||
|
draft.work_title = "猫画面".to_string();
|
||||||
|
draft.work_description = String::new();
|
||||||
|
draft.summary = String::new();
|
||||||
|
draft.theme_tags = Vec::new();
|
||||||
|
}
|
||||||
|
let metadata = PuzzleLevelNaming {
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
|
||||||
|
work_tags: vec![
|
||||||
|
"插画".to_string(),
|
||||||
|
"灯牌".to_string(),
|
||||||
|
"街角".to_string(),
|
||||||
|
"猫咪".to_string(),
|
||||||
|
"暖色".to_string(),
|
||||||
|
"雨夜".to_string(),
|
||||||
|
],
|
||||||
|
ui_background_prompt: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
&metadata,
|
||||||
|
"猫画面",
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(draft.work_title, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
draft.work_description,
|
||||||
|
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
|
||||||
|
);
|
||||||
|
assert_eq!(draft.summary, draft.work_description);
|
||||||
|
assert_eq!(draft.theme_tags, metadata.work_tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
|
||||||
|
let level = PuzzleDraftLevelResponse {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
ui_background_image_object_key: None,
|
||||||
|
background_music: Some(CreationAudioAsset {
|
||||||
|
task_id: "suno-task-1".to_string(),
|
||||||
|
provider: "vector-engine-suno".to_string(),
|
||||||
|
asset_object_id: Some("assetobj_1".to_string()),
|
||||||
|
asset_kind: Some("puzzle_background_music".to_string()),
|
||||||
|
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
|
||||||
|
prompt: Some("轻快拼图音乐".to_string()),
|
||||||
|
title: Some("雨夜猫街背景音乐".to_string()),
|
||||||
|
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
|
||||||
|
}),
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
let request_context = RequestContext::new(
|
||||||
|
"test-request".to_string(),
|
||||||
|
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||||
|
Duration::ZERO,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||||
|
.expect("levels should serialize");
|
||||||
|
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||||
|
assert_eq!(
|
||||||
|
payload[0]["background_music"]["audio_src"],
|
||||||
|
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
|
||||||
|
);
|
||||||
|
assert!(payload[0]["background_music"].get("audioSrc").is_none());
|
||||||
|
|
||||||
|
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||||
|
.expect("levels should map back into records");
|
||||||
|
let music = records[0]
|
||||||
|
.background_music
|
||||||
|
.as_ref()
|
||||||
|
.expect("background music should exist");
|
||||||
|
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
|
||||||
|
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
|
||||||
|
|
||||||
|
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.background_music
|
||||||
|
.as_ref()
|
||||||
|
.map(|asset| asset.audio_src.as_str()),
|
||||||
|
Some("/generated-puzzle-assets/audio.mp3")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||||
|
let level = PuzzleDraftLevelResponse {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
|
||||||
|
ui_background_image_src: Some(
|
||||||
|
"/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
),
|
||||||
|
ui_background_image_object_key: Some(
|
||||||
|
"generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
),
|
||||||
|
background_music: None,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||||
|
cover_asset_id: Some("asset-1".to_string()),
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
let request_context = RequestContext::new(
|
||||||
|
"test-request".to_string(),
|
||||||
|
"PUT /api/runtime/puzzle/works/test".to_string(),
|
||||||
|
Duration::ZERO,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
|
||||||
|
.expect("levels should serialize");
|
||||||
|
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
|
||||||
|
assert_eq!(
|
||||||
|
payload[0]["ui_background_prompt"],
|
||||||
|
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
|
||||||
|
);
|
||||||
|
assert!(payload[0].get("uiBackgroundPrompt").is_none());
|
||||||
|
|
||||||
|
let records = parse_puzzle_level_records_from_module_json(&levels_json)
|
||||||
|
.expect("levels should map back into records");
|
||||||
|
assert_eq!(
|
||||||
|
records[0].ui_background_image_src.as_deref(),
|
||||||
|
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = map_puzzle_draft_level_response(records[0].clone());
|
||||||
|
assert_eq!(
|
||||||
|
response.ui_background_image_object_key.as_deref(),
|
||||||
|
Some("generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||||
|
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
|
||||||
|
let level = PuzzleDraftLevelRecord {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
ui_background_image_object_key: None,
|
||||||
|
background_music: None,
|
||||||
|
candidates: vec![PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id: "candidate-1".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
|
||||||
|
asset_id: "asset-1".to_string(),
|
||||||
|
prompt: "雨夜猫街".to_string(),
|
||||||
|
actual_prompt: None,
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
selected_candidate_id: Some("candidate-1".to_string()),
|
||||||
|
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
|
||||||
|
cover_asset_id: Some("asset-1".to_string()),
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = map_puzzle_work_summary_response(
|
||||||
|
&state,
|
||||||
|
PuzzleWorkProfileRecord {
|
||||||
|
work_id: "puzzle-work-1".to_string(),
|
||||||
|
profile_id: "puzzle-profile-1".to_string(),
|
||||||
|
owner_user_id: "user-1".to_string(),
|
||||||
|
source_session_id: Some("puzzle-session-1".to_string()),
|
||||||
|
author_display_name: "玩家".to_string(),
|
||||||
|
work_title: "雨夜猫街".to_string(),
|
||||||
|
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
level_name: "雨夜猫街".to_string(),
|
||||||
|
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
theme_tags: vec!["猫".to_string()],
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
publication_status: "draft".to_string(),
|
||||||
|
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
|
||||||
|
published_at: None,
|
||||||
|
play_count: 0,
|
||||||
|
remix_count: 0,
|
||||||
|
like_count: 0,
|
||||||
|
recent_play_count_7d: 0,
|
||||||
|
point_incentive_total_half_points: 0,
|
||||||
|
point_incentive_claimed_points: 0,
|
||||||
|
publish_ready: false,
|
||||||
|
anchor_pack: test_puzzle_anchor_pack_record(),
|
||||||
|
levels: vec![level],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(response.levels.len(), 1);
|
||||||
|
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||||
|
assert_eq!(
|
||||||
|
response.levels[0].cover_image_src.as_deref(),
|
||||||
|
Some("/generated-puzzle-assets/session/cover.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response.levels[0].candidates[0].image_src,
|
||||||
|
"/generated-puzzle-assets/session/candidate-1.png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
|
||||||
|
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
|
||||||
|
|
||||||
|
assert!(prompt.contains("9:16"));
|
||||||
|
assert!(prompt.contains("纯背景图"));
|
||||||
|
assert!(prompt.contains("不得出现拼图槽"));
|
||||||
|
assert!(prompt.contains("默认拼图槽"));
|
||||||
|
assert!(prompt.contains("文字"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
|
||||||
|
let mut draft = test_puzzle_draft_record();
|
||||||
|
draft.work_title = "模板作品名".to_string();
|
||||||
|
draft.work_description = "模板作品描述".to_string();
|
||||||
|
let mut target_level = draft.levels[0].clone();
|
||||||
|
target_level.level_name = "雨夜猫街".to_string();
|
||||||
|
let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
|
||||||
|
target_level.ui_background_prompt = Some(ai_prompt.to_string());
|
||||||
|
|
||||||
|
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||||
|
|
||||||
|
assert_eq!(prompt, ai_prompt);
|
||||||
|
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let target_level = draft.levels[0].clone();
|
||||||
|
|
||||||
|
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
|
||||||
|
|
||||||
|
assert!(prompt.contains("雨夜猫街"));
|
||||||
|
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let generated = GeneratedPuzzleUiBackgroundResponse {
|
||||||
|
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
|
||||||
|
};
|
||||||
|
let mut levels = draft.levels.clone();
|
||||||
|
|
||||||
|
attach_puzzle_level_ui_background(
|
||||||
|
&mut levels,
|
||||||
|
"puzzle-level-1",
|
||||||
|
"雨夜猫街移动端拼图UI背景".to_string(),
|
||||||
|
generated,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].ui_background_prompt.as_deref(),
|
||||||
|
Some("雨夜猫街移动端拼图UI背景")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].ui_background_image_src.as_deref(),
|
||||||
|
Some("/generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].ui_background_image_object_key.as_deref(),
|
||||||
|
Some("generated-puzzle-assets/session/ui/background.png")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_initial_draft_assets_must_include_ui_background() {
|
||||||
|
let mut draft = test_puzzle_draft_record();
|
||||||
|
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||||
|
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
|
||||||
|
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
|
||||||
|
assert!(missing_all.body_text().contains("UI背景图"));
|
||||||
|
|
||||||
|
draft.levels[0].ui_background_image_src =
|
||||||
|
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
|
||||||
|
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
|
||||||
|
.expect("UI 背景存在时即可完成自动草稿资源检查");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
|
||||||
|
let item = PuzzleAnchorItemRecord {
|
||||||
|
key: "visualSubject".to_string(),
|
||||||
|
label: "画面".to_string(),
|
||||||
|
value: "雨夜猫街".to_string(),
|
||||||
|
status: "confirmed".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
PuzzleAnchorPackRecord {
|
||||||
|
theme_promise: item.clone(),
|
||||||
|
visual_subject: item.clone(),
|
||||||
|
visual_mood: item.clone(),
|
||||||
|
composition_hooks: item.clone(),
|
||||||
|
tags_and_forbidden: item,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
|
||||||
|
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||||
|
PuzzleResultDraftRecord {
|
||||||
|
work_title: "雨夜猫街".to_string(),
|
||||||
|
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
level_name: "猫画面".to_string(),
|
||||||
|
summary: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
theme_tags: vec![],
|
||||||
|
forbidden_directives: vec![],
|
||||||
|
creator_intent: None,
|
||||||
|
anchor_pack,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "idle".to_string(),
|
||||||
|
levels: vec![PuzzleDraftLevelRecord {
|
||||||
|
level_id: "puzzle-level-1".to_string(),
|
||||||
|
level_name: "猫画面".to_string(),
|
||||||
|
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
|
||||||
|
picture_reference: None,
|
||||||
|
ui_background_prompt: None,
|
||||||
|
ui_background_image_src: None,
|
||||||
|
ui_background_image_object_key: None,
|
||||||
|
background_music: None,
|
||||||
|
candidates: vec![],
|
||||||
|
selected_candidate_id: None,
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
generation_status: "idle".to_string(),
|
||||||
|
}],
|
||||||
|
form_draft: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
|
||||||
|
let draft = test_puzzle_draft_record();
|
||||||
|
let mut target_level = draft.levels[0].clone();
|
||||||
|
target_level.level_name = "雨夜猫街".to_string();
|
||||||
|
|
||||||
|
let levels = build_puzzle_levels_with_primary_update(
|
||||||
|
&draft,
|
||||||
|
&target_level,
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(levels[0].level_name, "雨夜猫街");
|
||||||
|
assert_eq!(
|
||||||
|
levels[0].picture_reference.as_deref(),
|
||||||
|
Some("data:image/png;base64,abcd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
|
||||||
|
let anchor_pack = test_puzzle_anchor_pack_record();
|
||||||
|
let session = PuzzleAgentSessionRecord {
|
||||||
|
session_id: "puzzle-session-1".to_string(),
|
||||||
|
seed_text: "雨夜猫街".to_string(),
|
||||||
|
current_turn: 1,
|
||||||
|
progress_percent: 0,
|
||||||
|
stage: "draft_ready".to_string(),
|
||||||
|
anchor_pack: anchor_pack.clone(),
|
||||||
|
draft: Some(test_puzzle_draft_record()),
|
||||||
|
messages: Vec::new(),
|
||||||
|
last_assistant_reply: None,
|
||||||
|
published_profile_id: None,
|
||||||
|
suggested_actions: Vec::new(),
|
||||||
|
result_preview: None,
|
||||||
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
let candidate = PuzzleGeneratedImageCandidateRecord {
|
||||||
|
candidate_id: "puzzle-session-1-candidate-1".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
|
||||||
|
asset_id: "puzzle-cover-1".to_string(),
|
||||||
|
prompt: "雨夜猫街".to_string(),
|
||||||
|
actual_prompt: Some("雨夜猫街".to_string()),
|
||||||
|
source_type: "generated:gpt-image-2".to_string(),
|
||||||
|
selected: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session = apply_generated_puzzle_candidates_to_session_snapshot(
|
||||||
|
session,
|
||||||
|
"puzzle-level-1",
|
||||||
|
vec![candidate],
|
||||||
|
Some("data:image/png;base64,abcd"),
|
||||||
|
1_713_686_401_234_568,
|
||||||
|
);
|
||||||
|
|
||||||
|
let draft = session.draft.expect("draft");
|
||||||
|
assert_eq!(
|
||||||
|
draft.levels[0].picture_reference.as_deref(),
|
||||||
|
Some("data:image/png;base64,abcd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
|
||||||
|
let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": "操作不合法",
|
||||||
|
}));
|
||||||
|
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": "spacetimedb",
|
||||||
|
"message": "泥点余额不足",
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
|
||||||
|
assert!(!should_sync_puzzle_freeze_boundary(
|
||||||
|
&invalid_operation,
|
||||||
|
false
|
||||||
|
));
|
||||||
|
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
|
||||||
|
}
|
||||||
1273
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
1273
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
File diff suppressed because it is too large
Load Diff
208
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
208
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
use std::{
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::response::Response;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use shared_contracts::{
|
||||||
|
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
|
||||||
|
puzzle_works::PuzzleWorkSummaryResponse,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
sync::{Mutex, MutexGuard, RwLock},
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext};
|
||||||
|
|
||||||
|
const PUZZLE_GALLERY_PRIMARY_ITEM_COUNT: usize = 10;
|
||||||
|
const PUZZLE_GALLERY_PREVIEW_REF_COUNT: usize = 10;
|
||||||
|
const PUZZLE_GALLERY_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||||
|
const PUZZLE_GALLERY_CACHE_MAX_IDLE: Duration = Duration::from_secs(300);
|
||||||
|
const PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PuzzleGalleryCache {
|
||||||
|
inner: Arc<RwLock<Option<PuzzleGalleryCacheEntry>>>,
|
||||||
|
rebuild_lock: Arc<Mutex<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct PuzzleGalleryCacheEntry {
|
||||||
|
data_json: Bytes,
|
||||||
|
built_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PuzzleGalleryCachedResponse {
|
||||||
|
data_json: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PuzzleGalleryCachedResponse {
|
||||||
|
pub fn data_json_len(&self) -> usize {
|
||||||
|
self.data_json.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PuzzleGalleryCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Arc::new(RwLock::new(None)),
|
||||||
|
rebuild_lock: Arc::new(Mutex::new(())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acquire_rebuild_guard(&self) -> MutexGuard<'_, ()> {
|
||||||
|
self.rebuild_lock.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_fresh_response(&self) -> Option<PuzzleGalleryCachedResponse> {
|
||||||
|
let guard = self.inner.read().await;
|
||||||
|
let entry = guard.as_ref()?;
|
||||||
|
let now = Instant::now();
|
||||||
|
if now.duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_TTL {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PuzzleGalleryCachedResponse {
|
||||||
|
data_json: entry.data_json.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn store_response(
|
||||||
|
&self,
|
||||||
|
response: PuzzleGalleryResponse,
|
||||||
|
) -> Result<PuzzleGalleryCachedResponse, serde_json::Error> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let cached = PuzzleGalleryCachedResponse {
|
||||||
|
data_json: Bytes::from(serde_json::to_vec(&response)?),
|
||||||
|
};
|
||||||
|
*self.inner.write().await = Some(PuzzleGalleryCacheEntry {
|
||||||
|
data_json: cached.data_json.clone(),
|
||||||
|
built_at: now,
|
||||||
|
});
|
||||||
|
Ok(cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_cleanup_task(&self) {
|
||||||
|
let cache = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = time::interval(PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
cache.cleanup_idle_entry().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_idle_entry(&self) {
|
||||||
|
let mut guard = self.inner.write().await;
|
||||||
|
if let Some(entry) = guard.as_ref()
|
||||||
|
&& Instant::now().duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_MAX_IDLE
|
||||||
|
{
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_puzzle_gallery_window_response(
|
||||||
|
items: Vec<PuzzleWorkSummaryResponse>,
|
||||||
|
) -> PuzzleGalleryResponse {
|
||||||
|
let total_count = items.len().min(u32::MAX as usize) as u32;
|
||||||
|
let preview_refs = items
|
||||||
|
.iter()
|
||||||
|
.skip(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT)
|
||||||
|
.take(PUZZLE_GALLERY_PREVIEW_REF_COUNT)
|
||||||
|
.map(|item| PuzzleGalleryWorkRefResponse {
|
||||||
|
work_id: item.work_id.clone(),
|
||||||
|
profile_id: item.profile_id.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let next_cursor = items
|
||||||
|
.get(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT)
|
||||||
|
.map(|item| item.profile_id.clone());
|
||||||
|
let has_more =
|
||||||
|
items.len() > PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT;
|
||||||
|
|
||||||
|
PuzzleGalleryResponse {
|
||||||
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.take(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT)
|
||||||
|
.collect(),
|
||||||
|
preview_refs,
|
||||||
|
has_more,
|
||||||
|
next_cursor,
|
||||||
|
total_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn puzzle_gallery_cached_json(
|
||||||
|
request_context: &RequestContext,
|
||||||
|
response: PuzzleGalleryCachedResponse,
|
||||||
|
) -> Response {
|
||||||
|
json_success_data_bytes_response(Some(request_context), response.data_json)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn build_summary(index: usize) -> PuzzleWorkSummaryResponse {
|
||||||
|
PuzzleWorkSummaryResponse {
|
||||||
|
work_id: format!("work-{index}"),
|
||||||
|
profile_id: format!("profile-{index}"),
|
||||||
|
owner_user_id: "user-1".to_string(),
|
||||||
|
source_session_id: None,
|
||||||
|
author_display_name: "作者".to_string(),
|
||||||
|
work_title: format!("作品 {index}"),
|
||||||
|
work_description: "描述".to_string(),
|
||||||
|
level_name: "第一关".to_string(),
|
||||||
|
summary: "摘要".to_string(),
|
||||||
|
theme_tags: Vec::new(),
|
||||||
|
cover_image_src: None,
|
||||||
|
cover_asset_id: None,
|
||||||
|
publication_status: "published".to_string(),
|
||||||
|
updated_at: "2026-05-01T00:00:00Z".to_string(),
|
||||||
|
published_at: None,
|
||||||
|
play_count: 0,
|
||||||
|
remix_count: 0,
|
||||||
|
like_count: 0,
|
||||||
|
recent_play_count_7d: 0,
|
||||||
|
point_incentive_total_half_points: 0,
|
||||||
|
point_incentive_claimed_points: 0,
|
||||||
|
point_incentive_total_points: 0.0,
|
||||||
|
point_incentive_claimable_points: 0,
|
||||||
|
publish_ready: true,
|
||||||
|
generation_status: Some("ready".to_string()),
|
||||||
|
levels: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_window_returns_primary_cards_preview_refs_and_cursor() {
|
||||||
|
let response =
|
||||||
|
build_puzzle_gallery_window_response((0..25).map(build_summary).collect::<Vec<_>>());
|
||||||
|
|
||||||
|
assert_eq!(response.total_count, 25);
|
||||||
|
assert_eq!(response.items.len(), 10);
|
||||||
|
assert_eq!(response.preview_refs.len(), 10);
|
||||||
|
assert_eq!(response.items[0].profile_id, "profile-0");
|
||||||
|
assert_eq!(response.items[9].profile_id, "profile-9");
|
||||||
|
assert_eq!(response.preview_refs[0].profile_id, "profile-10");
|
||||||
|
assert_eq!(response.preview_refs[9].profile_id, "profile-19");
|
||||||
|
assert!(response.has_more);
|
||||||
|
assert_eq!(response.next_cursor.as_deref(), Some("profile-20"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_window_handles_short_gallery_without_more_cursor() {
|
||||||
|
let response =
|
||||||
|
build_puzzle_gallery_window_response((0..8).map(build_summary).collect::<Vec<_>>());
|
||||||
|
|
||||||
|
assert_eq!(response.total_count, 8);
|
||||||
|
assert_eq!(response.items.len(), 8);
|
||||||
|
assert!(response.preview_refs.is_empty());
|
||||||
|
assert!(!response.has_more);
|
||||||
|
assert_eq!(response.next_cursor, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,20 +27,25 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
|||||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
|
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
|
||||||
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
|
||||||
use crate::wechat_provider::build_wechat_provider;
|
use crate::wechat_provider::build_wechat_provider;
|
||||||
|
|
||||||
const ADMIN_ROLE: &str = "admin";
|
const ADMIN_ROLE: &str = "admin";
|
||||||
|
|
||||||
|
pub type HttpRequestPermitPool = Semaphore;
|
||||||
|
|
||||||
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
|
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
|
http_request_permit_pool: Option<Arc<HttpRequestPermitPool>>,
|
||||||
auth_jwt_config: JwtConfig,
|
auth_jwt_config: JwtConfig,
|
||||||
admin_runtime: Option<AdminRuntime>,
|
admin_runtime: Option<AdminRuntime>,
|
||||||
refresh_cookie_config: RefreshCookieConfig,
|
refresh_cookie_config: RefreshCookieConfig,
|
||||||
@@ -60,6 +65,7 @@ pub struct AppState {
|
|||||||
#[cfg_attr(not(test), allow(dead_code))]
|
#[cfg_attr(not(test), allow(dead_code))]
|
||||||
ai_task_service: AiTaskService,
|
ai_task_service: AiTaskService,
|
||||||
spacetime_client: SpacetimeClient,
|
spacetime_client: SpacetimeClient,
|
||||||
|
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||||
llm_client: Option<LlmClient>,
|
llm_client: Option<LlmClient>,
|
||||||
creative_agent_gpt5_client: Option<LlmClient>,
|
creative_agent_gpt5_client: Option<LlmClient>,
|
||||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||||
@@ -192,9 +198,14 @@ impl AppState {
|
|||||||
});
|
});
|
||||||
let llm_client = build_llm_client(&config)?;
|
let llm_client = build_llm_client(&config)?;
|
||||||
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
||||||
|
let http_request_permit_pool = config
|
||||||
|
.max_concurrent_requests
|
||||||
|
.map(HttpRequestPermitPool::new)
|
||||||
|
.map(Arc::new);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
|
http_request_permit_pool,
|
||||||
auth_jwt_config,
|
auth_jwt_config,
|
||||||
admin_runtime,
|
admin_runtime,
|
||||||
refresh_cookie_config,
|
refresh_cookie_config,
|
||||||
@@ -214,6 +225,7 @@ impl AppState {
|
|||||||
wechat_pay_client,
|
wechat_pay_client,
|
||||||
ai_task_service,
|
ai_task_service,
|
||||||
spacetime_client,
|
spacetime_client,
|
||||||
|
puzzle_gallery_cache: PuzzleGalleryCache::new(),
|
||||||
llm_client,
|
llm_client,
|
||||||
creative_agent_gpt5_client,
|
creative_agent_gpt5_client,
|
||||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||||
@@ -235,6 +247,10 @@ impl AppState {
|
|||||||
&self.refresh_cookie_config
|
&self.refresh_cookie_config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn http_request_permit_pool(&self) -> Option<Arc<HttpRequestPermitPool>> {
|
||||||
|
self.http_request_permit_pool.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upsert_creation_entry_type_config(
|
pub async fn upsert_creation_entry_type_config(
|
||||||
&self,
|
&self,
|
||||||
input: module_runtime::CreationEntryTypeAdminUpsertInput,
|
input: module_runtime::CreationEntryTypeAdminUpsertInput,
|
||||||
@@ -464,6 +480,10 @@ impl AppState {
|
|||||||
&self.spacetime_client
|
&self.spacetime_client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||||
|
&self.puzzle_gallery_cache
|
||||||
|
}
|
||||||
|
|
||||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||||
self.llm_client.as_ref()
|
self.llm_client.as_ref()
|
||||||
}
|
}
|
||||||
|
|||||||
303
server-rs/crates/api-server/src/telemetry.rs
Normal file
303
server-rs/crates/api-server/src/telemetry.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, Request, Response},
|
||||||
|
middleware::Next,
|
||||||
|
};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use opentelemetry::{KeyValue, global, metrics::Counter};
|
||||||
|
use std::sync::{
|
||||||
|
Arc, OnceLock,
|
||||||
|
atomic::{AtomicI64, Ordering},
|
||||||
|
};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{request_context::resolve_request_id, state::AppState};
|
||||||
|
|
||||||
|
static HTTP_RESPONSE_BODY_IN_FLIGHT: AtomicI64 = AtomicI64::new(0);
|
||||||
|
static HTTP_REQUEST_PERMITS_AVAILABLE: OnceLock<Arc<AtomicI64>> = OnceLock::new();
|
||||||
|
|
||||||
|
// 集中维护 api-server HTTP 观测,避免在 handler 中散落高基数字段或重复创建 instrument。
|
||||||
|
pub async fn record_http_observability(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
request: Request<Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Response<Body> {
|
||||||
|
let method = request.method().as_str().to_string();
|
||||||
|
let route = observability_route(request.uri().path());
|
||||||
|
let scheme = resolve_request_scheme(request.headers());
|
||||||
|
let path = request.uri().path().to_string();
|
||||||
|
let request_id = resolve_request_id(&request).unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let base_labels = http_base_labels(method.clone(), route.clone());
|
||||||
|
let metrics = http_metrics();
|
||||||
|
metrics.in_flight.add(1, &base_labels);
|
||||||
|
let started_at = std::time::Instant::now();
|
||||||
|
|
||||||
|
let response = next.run(request).await;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let status_class = status_class(status);
|
||||||
|
let latency_ms = started_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
|
||||||
|
let slow_request = latency_ms >= state.config.slow_request_threshold_ms;
|
||||||
|
let labels = http_response_labels(base_labels, status);
|
||||||
|
metrics.requests.add(1, &labels);
|
||||||
|
metrics
|
||||||
|
.duration
|
||||||
|
.record(started_at.elapsed().as_secs_f64(), &labels);
|
||||||
|
metrics.in_flight.add(-1, &labels[..2]);
|
||||||
|
|
||||||
|
if slow_request {
|
||||||
|
warn!(
|
||||||
|
request_id = %request_id,
|
||||||
|
http.request.method = %method,
|
||||||
|
http.route = %route,
|
||||||
|
url.scheme = %scheme,
|
||||||
|
url.path = %path,
|
||||||
|
http.response.status_code = status,
|
||||||
|
status,
|
||||||
|
status_class,
|
||||||
|
latency_ms,
|
||||||
|
slow_request = true,
|
||||||
|
"http request completed slowly"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
request_id = %request_id,
|
||||||
|
http.request.method = %method,
|
||||||
|
http.route = %route,
|
||||||
|
url.scheme = %scheme,
|
||||||
|
url.path = %path,
|
||||||
|
http.response.status_code = status,
|
||||||
|
status,
|
||||||
|
status_class,
|
||||||
|
latency_ms,
|
||||||
|
slow_request = false,
|
||||||
|
"http request completed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
track_response_body_in_flight(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_http_request_permits_available(available: usize) {
|
||||||
|
let gauge = HTTP_REQUEST_PERMITS_AVAILABLE.get_or_init(|| {
|
||||||
|
let gauge = Arc::new(AtomicI64::new(0));
|
||||||
|
register_http_request_permits_available_metric(gauge.clone());
|
||||||
|
gauge
|
||||||
|
});
|
||||||
|
gauge.store(available.min(i64::MAX as usize) as i64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_puzzle_gallery_cache_hit() {
|
||||||
|
puzzle_gallery_cache_metrics().hits.add(1, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_puzzle_gallery_cache_miss() {
|
||||||
|
puzzle_gallery_cache_metrics().misses.add(1, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_puzzle_gallery_cache_rebuild(duration: std::time::Duration, data_bytes: usize) {
|
||||||
|
let metrics = puzzle_gallery_cache_metrics();
|
||||||
|
metrics.rebuilds.add(1, &[]);
|
||||||
|
metrics
|
||||||
|
.rebuild_duration
|
||||||
|
.record(duration.as_secs_f64(), &[]);
|
||||||
|
metrics
|
||||||
|
.data_json_bytes
|
||||||
|
.record(data_bytes.min(u64::MAX as usize) as u64, &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_response_body_in_flight(response: Response<Body>) -> Response<Body> {
|
||||||
|
response.map(|body| {
|
||||||
|
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let guard = ResponseBodyInFlightGuard;
|
||||||
|
Body::new(body.map_frame(move |frame| {
|
||||||
|
let _guard = &guard;
|
||||||
|
frame
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HttpMetrics {
|
||||||
|
requests: Counter<u64>,
|
||||||
|
in_flight: opentelemetry::metrics::UpDownCounter<i64>,
|
||||||
|
duration: opentelemetry::metrics::Histogram<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PuzzleGalleryCacheMetrics {
|
||||||
|
hits: Counter<u64>,
|
||||||
|
misses: Counter<u64>,
|
||||||
|
rebuilds: Counter<u64>,
|
||||||
|
rebuild_duration: opentelemetry::metrics::Histogram<f64>,
|
||||||
|
data_json_bytes: opentelemetry::metrics::Histogram<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResponseBodyInFlightGuard;
|
||||||
|
|
||||||
|
impl Drop for ResponseBodyInFlightGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_metrics() -> &'static HttpMetrics {
|
||||||
|
static METRICS: std::sync::OnceLock<HttpMetrics> = std::sync::OnceLock::new();
|
||||||
|
METRICS.get_or_init(|| {
|
||||||
|
let meter = global::meter("genarrative-api");
|
||||||
|
HttpMetrics {
|
||||||
|
requests: meter
|
||||||
|
.u64_counter("genarrative.http.server.requests")
|
||||||
|
.with_description("HTTP request count grouped by route and status class")
|
||||||
|
.build(),
|
||||||
|
in_flight: meter
|
||||||
|
.i64_up_down_counter("http.server.active_requests")
|
||||||
|
.with_unit("{request}")
|
||||||
|
.with_description("Number of active HTTP server requests")
|
||||||
|
.build(),
|
||||||
|
duration: meter
|
||||||
|
.f64_histogram("http.server.request.duration")
|
||||||
|
.with_unit("s")
|
||||||
|
.with_description("Duration of HTTP server requests")
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics {
|
||||||
|
static METRICS: std::sync::OnceLock<PuzzleGalleryCacheMetrics> = std::sync::OnceLock::new();
|
||||||
|
METRICS.get_or_init(|| {
|
||||||
|
let meter = global::meter("genarrative-api");
|
||||||
|
PuzzleGalleryCacheMetrics {
|
||||||
|
hits: meter
|
||||||
|
.u64_counter("genarrative.puzzle_gallery.cache.hits")
|
||||||
|
.with_description("Puzzle gallery response cache hits")
|
||||||
|
.build(),
|
||||||
|
misses: meter
|
||||||
|
.u64_counter("genarrative.puzzle_gallery.cache.misses")
|
||||||
|
.with_description("Puzzle gallery response cache misses")
|
||||||
|
.build(),
|
||||||
|
rebuilds: meter
|
||||||
|
.u64_counter("genarrative.puzzle_gallery.cache.rebuilds")
|
||||||
|
.with_description("Puzzle gallery response cache rebuild count")
|
||||||
|
.build(),
|
||||||
|
rebuild_duration: meter
|
||||||
|
.f64_histogram("genarrative.puzzle_gallery.cache.rebuild.duration")
|
||||||
|
.with_unit("s")
|
||||||
|
.with_description("Puzzle gallery response cache rebuild duration")
|
||||||
|
.build(),
|
||||||
|
data_json_bytes: meter
|
||||||
|
.u64_histogram("genarrative.puzzle_gallery.cache.data_json_bytes")
|
||||||
|
.with_unit("By")
|
||||||
|
.with_description("Serialized puzzle gallery data JSON size")
|
||||||
|
.build(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_http_request_permits_available_metric(gauge: Arc<AtomicI64>) {
|
||||||
|
let meter = global::meter("genarrative-api");
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("genarrative.http.server.request_permits.available")
|
||||||
|
.with_unit("{permit}")
|
||||||
|
.with_description("Available api-server HTTP backpressure permits")
|
||||||
|
.with_callback(move |observer| {
|
||||||
|
observer.observe(gauge.load(Ordering::Relaxed), &[]);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn register_http_runtime_metrics() {
|
||||||
|
static REGISTERED: OnceLock<()> = OnceLock::new();
|
||||||
|
REGISTERED.get_or_init(|| {
|
||||||
|
let meter = global::meter("genarrative-api");
|
||||||
|
meter
|
||||||
|
.i64_observable_up_down_counter("genarrative.http.server.response_bodies.in_flight")
|
||||||
|
.with_unit("{response}")
|
||||||
|
.with_description("HTTP response bodies still owned by Axum/Hyper")
|
||||||
|
.with_callback(|observer| {
|
||||||
|
observer.observe(HTTP_RESPONSE_BODY_IN_FLIGHT.load(Ordering::Relaxed), &[]);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_base_labels(method: String, route: String) -> Vec<KeyValue> {
|
||||||
|
vec![
|
||||||
|
KeyValue::new("http.request.method", method),
|
||||||
|
KeyValue::new("http.route", route),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_response_labels(mut labels: Vec<KeyValue>, status: u16) -> Vec<KeyValue> {
|
||||||
|
labels.push(KeyValue::new("status_class", status_class(status)));
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_class(status: u16) -> &'static str {
|
||||||
|
match status {
|
||||||
|
100..=199 => "1xx",
|
||||||
|
200..=299 => "2xx",
|
||||||
|
300..=399 => "3xx",
|
||||||
|
400..=499 => "4xx",
|
||||||
|
500..=599 => "5xx",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn observability_route(path: &str) -> String {
|
||||||
|
if path.starts_with("/api/runtime/puzzle/gallery") {
|
||||||
|
"/api/runtime/puzzle/gallery".to_string()
|
||||||
|
} else if path.starts_with("/api/runtime/custom-world-gallery") {
|
||||||
|
"/api/runtime/custom-world-gallery".to_string()
|
||||||
|
} else if path.starts_with("/admin/api/") {
|
||||||
|
"/admin/api/*".to_string()
|
||||||
|
} else if path.starts_with("/api/") {
|
||||||
|
"/api/*".to_string()
|
||||||
|
} else {
|
||||||
|
"other".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_request_scheme(headers: &HeaderMap) -> String {
|
||||||
|
headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.split(',').next())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("http")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::http::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
|
use super::{observability_route, resolve_request_scheme};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observability_route_keeps_metrics_labels_low_cardinality() {
|
||||||
|
assert_eq!(
|
||||||
|
observability_route("/api/runtime/puzzle/gallery?cursor=abc"),
|
||||||
|
"/api/runtime/puzzle/gallery"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observability_route("/api/runtime/puzzle/runs/run-123/history"),
|
||||||
|
"/api/*"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
observability_route("/admin/api/debug/http"),
|
||||||
|
"/admin/api/*"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_request_scheme_uses_forwarded_proto_first_value() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-forwarded-proto",
|
||||||
|
HeaderValue::from_static("https, http"),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(resolve_request_scheme(&headers), "https");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ platform-auth = { workspace = true }
|
|||||||
shared-kernel = { workspace = true }
|
shared-kernel = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
time = { workspace = true, features = ["formatting", "parsing"] }
|
time = { workspace = true, features = ["formatting", "parsing"] }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password,
|
SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use shared_kernel::{
|
use shared_kernel::{
|
||||||
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
||||||
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
||||||
@@ -77,6 +78,7 @@ struct StoredRefreshSession {
|
|||||||
struct StoredPhoneCode {
|
struct StoredPhoneCode {
|
||||||
phone_number: String,
|
phone_number: String,
|
||||||
scene: PhoneAuthScene,
|
scene: PhoneAuthScene,
|
||||||
|
verify_code_hash: String,
|
||||||
expires_at: String,
|
expires_at: String,
|
||||||
last_sent_at: String,
|
last_sent_at: String,
|
||||||
failed_attempts: u32,
|
failed_attempts: u32,
|
||||||
@@ -117,6 +119,7 @@ pub struct AuthUserService {
|
|||||||
pub struct PhoneAuthService {
|
pub struct PhoneAuthService {
|
||||||
store: InMemoryAuthStore,
|
store: InMemoryAuthStore,
|
||||||
sms_provider: SmsAuthProvider,
|
sms_provider: SmsAuthProvider,
|
||||||
|
verify_code_salt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -431,6 +434,7 @@ impl PhoneAuthService {
|
|||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
sms_provider,
|
sms_provider,
|
||||||
|
verify_code_salt: new_uuid_simple_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +446,7 @@ impl PhoneAuthService {
|
|||||||
let scene = input.scene.clone();
|
let scene = input.scene.clone();
|
||||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||||
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
|
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
|
||||||
|
let verify_code = self.generate_phone_verify_code();
|
||||||
info!(
|
info!(
|
||||||
scene = scene.as_str(),
|
scene = scene.as_str(),
|
||||||
provider = self.sms_provider.kind().as_str(),
|
provider = self.sms_provider.kind().as_str(),
|
||||||
@@ -457,12 +462,19 @@ impl PhoneAuthService {
|
|||||||
let expires_at = format_rfc3339(expires_at).map_err(|message| {
|
let expires_at = format_rfc3339(expires_at).map_err(|message| {
|
||||||
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
|
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
|
||||||
})?;
|
})?;
|
||||||
|
let verify_code_hash = hash_phone_verify_code(
|
||||||
|
&self.verify_code_salt,
|
||||||
|
&normalized_phone.e164,
|
||||||
|
&scene,
|
||||||
|
&verify_code,
|
||||||
|
);
|
||||||
|
|
||||||
let provider_result = self
|
let provider_result = self
|
||||||
.sms_provider
|
.sms_provider
|
||||||
.send_code(SmsSendCodeRequest {
|
.send_code(SmsSendCodeRequest {
|
||||||
national_phone_number,
|
national_phone_number,
|
||||||
scene: input.scene.as_str().to_string(),
|
scene: input.scene.as_str().to_string(),
|
||||||
|
verify_code,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(map_sms_provider_error_to_phone_error)?;
|
.map_err(map_sms_provider_error_to_phone_error)?;
|
||||||
@@ -488,6 +500,7 @@ impl PhoneAuthService {
|
|||||||
StoredPhoneCode {
|
StoredPhoneCode {
|
||||||
phone_number: normalized_phone.e164.clone(),
|
phone_number: normalized_phone.e164.clone(),
|
||||||
scene,
|
scene,
|
||||||
|
verify_code_hash,
|
||||||
expires_at,
|
expires_at,
|
||||||
last_sent_at: format_rfc3339(now).map_err(|message| {
|
last_sent_at: format_rfc3339(now).map_err(|message| {
|
||||||
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
||||||
@@ -516,28 +529,12 @@ impl PhoneAuthService {
|
|||||||
) -> Result<PhoneLoginResult, PhoneAuthError> {
|
) -> Result<PhoneLoginResult, PhoneAuthError> {
|
||||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||||
verify_sms_code_format(&input.verify_code)?;
|
verify_sms_code_format(&input.verify_code)?;
|
||||||
let provider_out_id = self.store.assert_phone_code_active(
|
let provider_out_id = self.verify_phone_code(
|
||||||
&normalized_phone.e164,
|
&normalized_phone.e164,
|
||||||
&PhoneAuthScene::Login,
|
&PhoneAuthScene::Login,
|
||||||
|
&input.verify_code,
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
match self
|
|
||||||
.sms_provider
|
|
||||||
.verify_code(SmsVerifyCodeRequest {
|
|
||||||
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
|
||||||
verify_code: input.verify_code.trim().to_string(),
|
|
||||||
provider_out_id: provider_out_id.clone(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => self
|
|
||||||
.store
|
|
||||||
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?,
|
|
||||||
Err(SmsProviderError::InvalidVerifyCode) => self
|
|
||||||
.store
|
|
||||||
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?,
|
|
||||||
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(user) = self
|
if let Some(user) = self
|
||||||
.store
|
.store
|
||||||
@@ -582,30 +579,12 @@ impl PhoneAuthService {
|
|||||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||||
verify_sms_code_format(&input.verify_code)?;
|
verify_sms_code_format(&input.verify_code)?;
|
||||||
validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?;
|
validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?;
|
||||||
let provider_out_id = self.store.assert_phone_code_active(
|
let provider_out_id = self.verify_phone_code(
|
||||||
&normalized_phone.e164,
|
&normalized_phone.e164,
|
||||||
&PhoneAuthScene::ResetPassword,
|
&PhoneAuthScene::ResetPassword,
|
||||||
|
&input.verify_code,
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
match self
|
|
||||||
.sms_provider
|
|
||||||
.verify_code(SmsVerifyCodeRequest {
|
|
||||||
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
|
||||||
verify_code: input.verify_code.trim().to_string(),
|
|
||||||
provider_out_id: provider_out_id.clone(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => self.store.consume_phone_code_success(
|
|
||||||
&normalized_phone.e164,
|
|
||||||
&PhoneAuthScene::ResetPassword,
|
|
||||||
)?,
|
|
||||||
Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure(
|
|
||||||
&normalized_phone.e164,
|
|
||||||
&PhoneAuthScene::ResetPassword,
|
|
||||||
)?,
|
|
||||||
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
|
|
||||||
}
|
|
||||||
|
|
||||||
self.store
|
self.store
|
||||||
.find_by_phone_number(&normalized_phone.e164)?
|
.find_by_phone_number(&normalized_phone.e164)?
|
||||||
@@ -632,28 +611,12 @@ impl PhoneAuthService {
|
|||||||
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
||||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||||
verify_sms_code_format(&input.verify_code)?;
|
verify_sms_code_format(&input.verify_code)?;
|
||||||
let provider_out_id = self.store.assert_phone_code_active(
|
self.verify_phone_code(
|
||||||
&normalized_phone.e164,
|
&normalized_phone.e164,
|
||||||
&PhoneAuthScene::BindPhone,
|
&PhoneAuthScene::BindPhone,
|
||||||
|
&input.verify_code,
|
||||||
now,
|
now,
|
||||||
)?;
|
)?;
|
||||||
match self
|
|
||||||
.sms_provider
|
|
||||||
.verify_code(SmsVerifyCodeRequest {
|
|
||||||
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
|
|
||||||
verify_code: input.verify_code.trim().to_string(),
|
|
||||||
provider_out_id,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => self
|
|
||||||
.store
|
|
||||||
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
|
|
||||||
Err(SmsProviderError::InvalidVerifyCode) => self
|
|
||||||
.store
|
|
||||||
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
|
|
||||||
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_user = self
|
let current_user = self
|
||||||
.store
|
.store
|
||||||
@@ -677,6 +640,35 @@ impl PhoneAuthService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn verify_phone_code(
|
||||||
|
&self,
|
||||||
|
phone_number: &str,
|
||||||
|
scene: &PhoneAuthScene,
|
||||||
|
verify_code: &str,
|
||||||
|
now: OffsetDateTime,
|
||||||
|
) -> Result<Option<String>, PhoneAuthError> {
|
||||||
|
let stored = self.store.get_active_phone_code(phone_number, scene, now)?;
|
||||||
|
let expected_hash =
|
||||||
|
hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code);
|
||||||
|
if stored.verify_code_hash != expected_hash {
|
||||||
|
self.store.consume_phone_code_failure(phone_number, scene)?;
|
||||||
|
return Err(PhoneAuthError::InvalidVerifyCode);
|
||||||
|
}
|
||||||
|
self.store.consume_phone_code_success(phone_number, scene)?;
|
||||||
|
Ok(stored.provider_out_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_phone_verify_code(&self) -> String {
|
||||||
|
match self.sms_provider.kind() {
|
||||||
|
SmsAuthProviderKind::Mock => self
|
||||||
|
.sms_provider
|
||||||
|
.mock_verify_code()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| "123456".to_string()),
|
||||||
|
SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn bind_wechat_verified_phone(
|
pub async fn bind_wechat_verified_phone(
|
||||||
&self,
|
&self,
|
||||||
input: BindWechatVerifiedPhoneInput,
|
input: BindWechatVerifiedPhoneInput,
|
||||||
@@ -1518,12 +1510,12 @@ impl InMemoryAuthStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_phone_code_active(
|
fn get_active_phone_code(
|
||||||
&self,
|
&self,
|
||||||
phone_number: &str,
|
phone_number: &str,
|
||||||
scene: &PhoneAuthScene,
|
scene: &PhoneAuthScene,
|
||||||
now: OffsetDateTime,
|
now: OffsetDateTime,
|
||||||
) -> Result<Option<String>, PhoneAuthError> {
|
) -> Result<StoredPhoneCode, PhoneAuthError> {
|
||||||
let mut state = self
|
let mut state = self
|
||||||
.inner
|
.inner
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1543,7 +1535,7 @@ impl InMemoryAuthStore {
|
|||||||
state.phone_codes_by_key.remove(&key);
|
state.phone_codes_by_key.remove(&key);
|
||||||
return Err(PhoneAuthError::VerifyCodeExpired);
|
return Err(PhoneAuthError::VerifyCodeExpired);
|
||||||
}
|
}
|
||||||
Ok(stored.provider_out_id)
|
Ok(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn consume_phone_code_success(
|
fn consume_phone_code_success(
|
||||||
@@ -2139,6 +2131,36 @@ fn build_random_password_seed() -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_random_phone_verify_code() -> String {
|
||||||
|
let digest = Sha256::digest(new_uuid_simple_string().as_bytes());
|
||||||
|
let mut digits = digest
|
||||||
|
.iter()
|
||||||
|
.take(SMS_CODE_LENGTH)
|
||||||
|
.map(|byte| char::from(b'0' + (*byte % 10)))
|
||||||
|
.collect::<String>();
|
||||||
|
while digits.len() < SMS_CODE_LENGTH {
|
||||||
|
digits.push('0');
|
||||||
|
}
|
||||||
|
digits
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_phone_verify_code(
|
||||||
|
salt: &str,
|
||||||
|
phone_number: &str,
|
||||||
|
scene: &PhoneAuthScene,
|
||||||
|
verify_code: &str,
|
||||||
|
) -> String {
|
||||||
|
let content = format!(
|
||||||
|
"{}:{}:{}:{}",
|
||||||
|
salt,
|
||||||
|
phone_number.trim(),
|
||||||
|
scene.as_str(),
|
||||||
|
verify_code.trim()
|
||||||
|
);
|
||||||
|
let digest = Sha256::digest(content.as_bytes());
|
||||||
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
|
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
|
||||||
format_shared_rfc3339(value)
|
format_shared_rfc3339(value)
|
||||||
}
|
}
|
||||||
@@ -2655,6 +2677,14 @@ mod tests {
|
|||||||
assert!(bind_result.await.is_ok());
|
assert!(bind_result.await.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn random_phone_verify_code_is_six_digits() {
|
||||||
|
let code = generate_random_phone_verify_code();
|
||||||
|
|
||||||
|
assert_eq!(code.len(), SMS_CODE_LENGTH);
|
||||||
|
assert!(code.chars().all(|character| character.is_ascii_digit()));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn phone_login_expires_code_after_too_many_wrong_attempts() {
|
async fn phone_login_expires_code_after_too_many_wrong_attempts() {
|
||||||
let service = build_phone_service(build_store());
|
let service = build_phone_service(build_store());
|
||||||
|
|||||||
1
server-rs/crates/module-bark-battle/src/application.rs
Normal file
1
server-rs/crates/module-bark-battle/src/application.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 中文注释:汪汪声浪领域应用服务预留落位,当前规则仍集中在 domain/scoring。
|
||||||
1
server-rs/crates/module-bark-battle/src/commands.rs
Normal file
1
server-rs/crates/module-bark-battle/src/commands.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 中文注释:汪汪声浪命令归一化预留落位,当前无独立命令构造。
|
||||||
1
server-rs/crates/module-bark-battle/src/errors.rs
Normal file
1
server-rs/crates/module-bark-battle/src/errors.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 中文注释:汪汪声浪领域错误预留落位,当前复用调用方错误文本。
|
||||||
1
server-rs/crates/module-bark-battle/src/events.rs
Normal file
1
server-rs/crates/module-bark-battle/src/events.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 中文注释:汪汪声浪领域事件预留落位,当前不导出独立事件类型。
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
mod application;
|
||||||
|
mod commands;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
|
mod errors;
|
||||||
|
mod events;
|
||||||
pub mod scoring;
|
pub mod scoring;
|
||||||
|
|
||||||
pub use domain::*;
|
pub use domain::*;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub struct BigFishWorkRemixInput {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct BigFishWorksProcedureResult {
|
pub struct BigFishWorksProcedureResult {
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
pub items_json: Option<String>,
|
pub items: Vec<BigFishWorkSummarySnapshot>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +188,9 @@ pub struct BigFishInputSubmitInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct BigFishRunProcedureResult {
|
pub struct BigFishRunProcedureResult {
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
pub run_json: Option<String>,
|
pub run: Option<BigFishRuntimeSnapshot>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
1
server-rs/crates/module-creative-agent/src/events.rs
Normal file
1
server-rs/crates/module-creative-agent/src/events.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
//! 中文注释:创意 Agent 领域事件预留落位,当前流程不导出独立事件类型。
|
||||||
@@ -2,6 +2,7 @@ mod application;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod errors;
|
mod errors;
|
||||||
|
mod events;
|
||||||
|
|
||||||
pub use application::*;
|
pub use application::*;
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user