diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..422a9ea0
--- /dev/null
+++ b/.dockerignore
@@ -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
diff --git a/.env.local b/.env.local
index 34b87a66..311781f6 100644
--- a/.env.local
+++ b/.env.local
@@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d"
SMS_AUTH_ENABLED="true"
SMS_AUTH_PROVIDER="aliyun"
-ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9"
-ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD"
-ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com"
-ALIYUN_SMS_SIGN_NAME="速通互联验证码"
-ALIYUN_SMS_TEMPLATE_CODE="100001"
+ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com"
+ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技"
+ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486"
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"
@@ -70,3 +59,9 @@ GENARRATIVE_SPACETIME_TOKEN=""
GENARRATIVE_ADMIN_USERNAME=admin
GENARRATIVE_ADMIN_PASSWORD=123456
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
diff --git a/.gitignore b/.gitignore
index 6f27c449..11a83c89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ temp*build*/
.worktrees/
.env.secrets.local
spacetime.local.json
+deploy/container/api-server.env
# Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json
diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 6f747f92..62d53d03 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -24,6 +24,117 @@
- 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单;点击生成草稿进入结果页;结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime,发布成功后再进入正式 runtime。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 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-19 tracking outbox 改为 rotate 后异步 flush
+
+- 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。
+- 决策:`api-server` tracking outbox 达到 `BATCH_SIZE` 时立即封存当前 active 文件并切新 active,sealed 文件交给后台 worker 异步 flush;`FLUSH_INTERVAL_MS` 只做长时间未满批的兜底封存;`MAX_BYTES` 只做磁盘保护阈值;成功后删除 sealed,失败保留重试,坏文件隔离为 `corrupt-*`。
+- 影响范围:`api-server` tracking outbox、埋点文档、压测口径和后续排障记忆。
+- 验证方式:HTTP route 请求在 SpacetimeDB 短暂不可用时仍可返回;恢复后 sealed 文件会被批量写入并清理。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
+
+## 2026-05-19 OTLP 默认开启但日志本地输出保留
+
+- 背景:生产和容器环境需要默认把 OTLP 接到本机 Collector,但压测或排障时也要能显式关闭。
+- 决策:生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;生产 endpoint 用 `http://127.0.0.1:4318`,容器 endpoint 用 `http://otelcol:4318`;`OTEL_EXPORTER_OTLP_ENDPOINT` 只填 Collector HTTP base endpoint,不填 gRPC `4317` 或 Rider 端口;本地日志、Nginx 日志和 `GENARRATIVE_API_LOG` / `RUST_LOG` 仍保留。
+- 影响范围:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。
+- 验证方式:检查 env 模板默认值与端点口径;压测若要关闭 OTLP,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/run-otelcol.mjs`。
+
+## 2026-05-19 容器 collector 可切 Grafana Cloud
+
+- 背景:容器隔离压测时除了本地 debug exporter,还需要临时把 traces / metrics / logs 转发到 Grafana Cloud 做可视化验证。
+- 决策:`deploy/container/docker-compose.loadtest.yml` 里的 `otelcol` 支持通过 `GENARRATIVE_CONTAINER_OTELCOL_CONFIG=./otelcol.grafana.yaml` 切换配置;`deploy/container/otelcol.grafana.yaml` 同时保留 debug exporter,并通过 `GRAFANA_CLOUD_OTLP_ENDPOINT` 和 `GRAFANA_CLOUD_BASIC_AUTH_HEADER` 转发到 Grafana Cloud。
+- 影响范围:`deploy/container/docker-compose.loadtest.yml`、`deploy/container/otelcol.grafana.yaml`、`deploy/container/README.md`。
+- 验证方式:容器 `otelcol` 启动日志应能看到 OTLP receiver ready,debug exporter 仍可输出本地链路;Grafana Cloud 转发凭据只通过当前 shell 环境变量传入,不写入 Git。
+- 关联文档:`deploy/container/README.md`、`scripts/loadtest/README.md`。
+
+## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径
+
+- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
+- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
+- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。
+- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
+- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
+- 影响范围:`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-18 生产 provision 改为构建机准备工具包再上传安装
+
+- 背景:目标 release 服务器无法访问 GitHub,之前的 server provision 默认仍假设 `spacetime` 和 `otelcol-contrib` 已经存在于目标机本地路径,和真实运维条件不符。
+- 决策:Jenkins 新增 `Prepare Provision Tools` 阶段,在 `linux && genarrative-build` 构建机执行 `scripts/prepare-server-provision-tools.sh`,通过官方 SpacetimeDB 安装入口和 OpenTelemetry release 包生成 `provision-tools/`,再用 `stash/unstash` 带到 release 部署 agent;`scripts/jenkins-server-provision.sh` 只从工作区工具包复制安装,不再要求目标机自己下载或预装二进制。
+- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-server-provision.sh`、生产运维文档。
+- 验证方式:Jenkins 构建机可完成工具包准备,release 部署 agent 只消费工作区文件;目标机不再依赖 GitHub 外网下载。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## 2026-05-19 otelcol-contrib 改为 Jenkins 手动上传归档再解包
+
+- 背景:Genarrative-Server-Provision 中 `otelcol-contrib` 的构建机下载步骤耗时较长,且本机已经提前准备好安装包。
+- 决策:`jenkins/Jenkinsfile.production-server-provision` 新增 `OTELCOL_CONTRIB_ARCHIVE` 手动上传参数,默认要求上传 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`;`scripts/prepare-server-provision-tools.sh` 优先从上传归档解包生成 `provision-tools/otelcol-contrib`,不再默认联网下载 OpenTelemetry release 包。
+- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+- 验证方式:Jenkins 日志应显示使用手动上传的 otelcol 包,`MANIFEST.txt` 记录 source 为 manual archive;当 `ENABLE_OTELCOL=false` 时可以跳过 collector 工具包准备。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## 2026-05-19 公开 gallery 入口发布限流以快拒绝保护后端
+
+- 背景:容器 2C / 2G 压测中,公开作品列表在约 5000 HTTP req/s 目标下可以保持 200 请求低延迟,但 SpacetimeDB 内存会随 api-server 重连和高压请求累积到容器上限附近。
+- 决策:发布配置采用公开 gallery list 专用入口限流:Nginx `genarrative_gallery_rps rate=5000r/s`、`burst=4096`、gallery list `limit_conn=320`;api-server 对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`,公开详情维持更低的 `GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`。超过容量时接受明确 `429`,不继续扩大入口并发。
+- 影响范围:`deploy/nginx/` 发布模板、`deploy/env/api-server.env.example`、`deploy/container/` 隔离压测模板和生产运维文档。
+- 验证方式:容器连续 10 轮不重启 SpacetimeDB 压测,`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,总计 `0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;同时观察 SpacetimeDB 内存高水位,后续优化先处理连接 / 订阅 / tracking 下游状态。
+- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`deploy/container/README.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`。
+
+- 背景:压测与运行观测需要把 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 创作页图像输入统一封装为图像组件
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
@@ -141,7 +252,8 @@
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
-- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
+- 决策:拼图 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 映射、拼图流程技术文档。
- 验证方式:执行 `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`。
@@ -516,6 +628,14 @@
- 验证方式:生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
- 关联文档:`PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
+## 2026-05-19 release server provision 需预装 Nginx Brotli 动态模块
+
+- 背景:release 服务器的 Nginx 站点配置已经预留 Brotli 指令占位,但当前 provision 流程只装了基础构建依赖,没有把 Ubuntu apt 下的 brotli 动态模块一起装上,导致 release 机器即使模板支持也可能无法启用 Brotli。
+- 决策:`scripts/jenkins-server-provision.sh` 在 apt 系统上额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,然后继续用会先 `include /etc/nginx/modules-enabled/*.conf` 的临时 `nginx -t` 配置做能力探测;非 apt 系统仍只做探测不强制安装。不要用 `nginx -V` 判断该动态模块是否可用。
+- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、`deploy/nginx/README.md`、release 服务器 Nginx 初始化。
+- 验证方式:server provision 跑过后,目标机应同时具备 Brotli 模块包与 `nginx -t` 可接受的 brotli 指令;再由 Nginx 模板启用对应指令。
+- 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
## 个人任务与埋点首版边界冻结
- 背景:“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路,需要避免范围泛化。
@@ -523,3 +643,11 @@
- 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。
- 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/` 和 `docs/tracking/`。
- 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`、`RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。
+
+## 普通 route tracking 先写本机 outbox 再批量入库
+
+- 背景:公开作品列表压测中,成功响应后的全局 route tracking 会逐条调用 SpacetimeDB,导致数据库内存和事务压力先到边界。
+- 决策:普通 HTTP route tracking 先写入 `api-server` 本机 NDJSON outbox,后台按数量或时间阈值批量调用 SpacetimeDB;`daily_login`、`work_play_start`、支付、任务领奖、钱包等关键事件保持同步直写。
+- 默认阈值:每批 500 条或 1 秒 flush 一次;outbox 磁盘上限 256 MiB,超过后丢弃低价值 route 事件并记录指标 / 日志。
+- 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。
+- 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。
diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md
index 49bff2ef..f1268ce4 100644
--- a/.hermes/shared-memory/development-workflow.md
+++ b/.hermes/shared-memory/development-workflow.md
@@ -195,6 +195,13 @@ npm run check:server-rs-ddd
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.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 串联。
+
## 前端相关默认验证
前端修改后,应根据修改范围选择:
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index 13539b48..656466b7 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -22,6 +22,22 @@
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。
+## OTLP 端点只填 Collector HTTP base endpoint
+
+- 现象:生产或容器 env 里把 `OTEL_EXPORTER_OTLP_ENDPOINT` 填成 `4317`、Rider 端口或别的非 HTTP base endpoint 后,api-server 发不出 OTLP,或者链路被错误转发。
+- 原因:api-server 当前走 OTLP HTTP,不是 gRPC;Collector 才是接收和转发边界。
+- 处理:生产模板用 `http://127.0.0.1:4318`,容器模板用 `http://otelcol:4318`;需要关闭时显式设 `GENARRATIVE_OTEL_ENABLED=false`,不要通过改 endpoint 绕开 Collector 语义。
+- 验证:检查 env 模板和运行态配置都指向 Collector HTTP base endpoint,日志仍通过 `journalctl` / 文件日志保留。
+- 关联:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## tracking outbox 到批量阈值后先封存再异步 flush
+
+- 现象:route tracking 高峰时如果主请求线程要等 SpacetimeDB 批量入库,接口延迟会被 outbox 写入链路拖长。
+- 原因:outbox 的职责是把普通 HTTP route tracking 从请求线程切走,不能把 flush 结果回写成同步阻塞。
+- 处理:达到 `BATCH_SIZE` 立即封存 active 文件并切新 active,`FLUSH_INTERVAL_MS` 只做兜底封存,后台 worker 异步 flush sealed 文件;成功删文件,失败保留重试,坏文件隔离为 `corrupt-*`,`MAX_BYTES` 只做磁盘保护。
+- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。
+- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
## 汪汪声浪入口不要再回到独立配置阶段
- 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。
@@ -44,7 +60,7 @@
- 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。
- 处理:`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` 覆盖封面提示词与参考图链路。
-- 关联:`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 兼容
@@ -83,6 +99,62 @@
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md`、`npm run check:encoding`。
+## SpacetimeDB 运行态查询不要绕过已有索引或用 procedure JSON 回传
+
+- 现象:运行态接口看起来只查当前用户、作品或任务,却在 `spacetime-module` 中使用 `ctx.db.
().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` 作为 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`。
+
+## 容器高 VU 下 `/healthz` RSS 尖峰先查 Axum state 深拷贝
+
+- 现象:容器 Linux release `api-server` 打 `/healthz`,500 HTTP req/s、`PREALLOCATED_VUS=100` 只跑 1 秒也能把 RSS 推到约 1 GiB;同样问题与作品列表、SpacetimeDB procedure、业务 cache 和请求日志等级无关。
+- 原因:`AppState` 曾直接 `#[derive(Clone)]` 大结构体,里面包含配置、SpacetimeDB client、平台服务、认证服务和多组 cache。Axum/Hyper 会在 router/service/connection 路径频繁 clone state,高并发 keepalive 下会放大为状态深拷贝高水位。
+- 处理:`server-rs/crates/api-server/src/state.rs` 的 `AppState` 必须保持 `Arc` 浅拷贝壳;新增共享状态字段时放入 `AppStateInner`,不要把外层改回大结构体 clone。
+- 验证:用容器内 k6 直连 `api-server:8082/healthz`,500 HTTP req/s、`PREALLOCATED_VUS=100`、30 秒压测后采样 `/proc/$pid/status`、`/proc/$pid/smaps_rollup` 和 cgroup `memory.current/memory.peak`。2026-05-18 修复后结果为 `15001` 请求、`http_req_failed=0`、`dropped_iterations=0`,RSS 约 18 MiB -> 52 MiB,cgroup peak 约 47 MiB。
+- 关联:`server-rs/crates/api-server/src/state.rs`、`deploy/container/README.md`、`deploy/container/api-server.Dockerfile`。
+
+## Gallery 压测延迟升高先查入口过量放行和 TTL 边界刷新
+
+- 现象:公开作品列表在 500-1000 HTTP req/s 附近可能吞吐没有明显提升,但 p95 变高、VU 上升,甚至出现排队和 dropped iterations。
+- 原因:Nginx、Axum 和缓存刷新边界如果同时允许过多请求进入,压力会先堆在连接、service 和 cache rebuild 周围;这类延迟不等同于数据库连接池不足。
+- 处理:Nginx 按 endpoint 使用 `limit_req` 快拒绝,api-server 按 `default/gallery/detail/admin` 分组 semaphore 快拒绝;拼图广场 TTL 过期时已有缓存先返回 stale 响应,只允许一个后台 refresh 任务重建,冷启动无缓存时才同步构建。
+- 验证:OTLP 看 `genarrative.http.server.request_permits.available{pool=...}`、`genarrative.puzzle_gallery.cache.stale_hits`、`refreshes_started`、`refreshes_failed`,Nginx access log 看 `request_time` 与 `upstream_response_time` 是否同步收敛;超过容量时应明确 429,而不是长时间排队或新增 502。
+- 关联:`deploy/nginx/genarrative.conf`、`deploy/container/nginx.conf`、`server-rs/crates/api-server/src/backpressure.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.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` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
@@ -390,6 +462,14 @@
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
- 关联:`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
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
@@ -410,8 +490,8 @@
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 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`。
-- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。
+- 处理:`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 变量仍然最高优先级;`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`。
## OSS 密钥键名不要把字母 O 写成数字 0
@@ -440,28 +520,28 @@
## 本地短信登录页签突然消失
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
-- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类:
+- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
- 单独 `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 是旧前端进程,应清理旧进程后重启。
-- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。
-- 关联:`scripts/dev-utils.mjs`、`scripts/dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。
+- 处理:当前口径是登录弹窗永远展示 `短信登录` 与 `密码登录` 两个核心入口;`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 目标。
+- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。
+- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。
## 本地短信收不到验证码先查 provider
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
-- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。
-- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。
-- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。
-- 关联:`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`。
+- 原因:本地 `.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_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` 重启会清掉未校验的本地验证码。
+- 验证:分别请求浏览器域名和 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`。
+- 关联:`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 语义
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `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`。
- 验证:`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`。
@@ -656,6 +736,14 @@
- 验证:扫描 `jenkins/Jenkinsfile.production-database-export` 与 `jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES`、`CHUNK_SIZE`、`SERVER_BACKUP_DIRECTORY`、`SMOKE_HEALTH_URL` 等可选参数不再裸读。
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`。
+## Jenkins 二次 checkout 后脚本执行位会被 Git 还原
+
+- 现象:`Genarrative-Server-Provision` 已在 shell 块前面对脚本执行 `chmod +x`,但进入 `Prepare Provision Tools` 后仍报 `scripts/prepare-server-provision-tools.sh: Permission denied` / `exit code 126`。
+- 原因:该阶段会先运行 `scripts/jenkins-checkout-source.sh`,脚本内部执行 `git reset --hard HEAD` 和 `git clean -fd`,会把前面临时 `chmod` 的执行位还原为 Git 记录的 mode;若被直接执行的脚本在仓库里是 `100644`,二次 checkout 后仍不可执行。
+- 处理:需要直接以 `scripts/*.sh` 方式执行的 Jenkins 脚本应提交为 Git `100755`;如果只想临时授权,必须放在 `scripts/jenkins-checkout-source.sh` 完成之后。
+- 验证:运行 `git ls-files --stage scripts/prepare-server-provision-tools.sh`,确认 mode 为 `100755`;重新跑 `Genarrative-Server-Provision` 时应进入工具下载/打包日志,而不是停在 `Permission denied`。
+- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-checkout-source.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
## 个人任务 scope 不得扩成 work/site/module
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
@@ -776,6 +864,22 @@
- 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`。
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
+## 容器公开列表压测不要靠继续抬并发吃满 CPU
+
+- 现象:2C / 2G 容器压测公开 gallery list 时,`api-server` CPU 仍有余量,看起来像可以继续提高 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS` 或 Nginx `limit_conn`。
+- 原因:当前瓶颈不是 Tokio worker 线程数。`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery` 成功响应后会走全局 route tracking,继续向 SpacetimeDB 写 `record_tracking_event_and_return`;入口并发从 320 抬到 336 / 352 时,SpacetimeDB 内存先逼近 `896m` 容器上限,200 请求 p95 变差,429 比例没有改善。
+- 处理:2C / 2G 容器模拟里公开 gallery list 暂以 `limit_conn=320`、`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 作为稳定上限。若要继续提升吞吐,优先减少高频公开 GET 的 tracking 写入、做采样或改成批量/异步聚合;不要单纯放大入口并发。
+- 验证:宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=1000` 等价约 2000 HTTP req/s;320 档无 dropped iterations、无 5xx、无 OOM,200 请求 `request_time p95` 约 0.292s。336 / 352 档 p95 升到约 0.31s / 0.32s,SpacetimeDB 内存尾部可到约 `880MiB / 896MiB`。
+- 关联:`deploy/container/nginx.conf`、`deploy/container/api-server.env.example`、`deploy/container/README.md`、`server-rs/crates/api-server/src/tracking.rs`。
+
+## tracking outbox 成功入库后删除 sealed 文件
+
+- 现象:普通 route tracking 改为本机 outbox 后,容易误以为入库成功只需要清空文件内容。
+- 原因:清空文件会扩大崩溃窗口,进程在 truncate 和确认之间异常退出时可能丢失未确认事件。
+- 处理:当前 active NDJSON 达到数量或时间阈值后原子 rename 为 sealed 文件;后台批量 flush sealed 文件,SpacetimeDB 返回成功后直接删除该文件,失败则保留文件等待重试。sealed 文件如果出现无法解析的坏行,重命名为 `corrupt-*` 隔离并记录指标,避免阻塞后续批量入库。该路径是至少一次投递,重复事件由 `tracking_event.event_id` 幂等跳过。
+- 验证:模拟 SpacetimeDB 不可用时 sealed 文件保留;恢复后批量 procedure 成功,sealed 文件消失,`tracking_event` 与 `tracking_daily_stat` 均更新。
+- 关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`。
+
## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
@@ -798,7 +902,7 @@
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
- 处理:统一使用 `物品种类` 口径:轻松 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`。
-- 关联:`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素材` 当编号剥掉
@@ -888,6 +992,22 @@
- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。
- 关联:`src/services/puzzle-works/puzzleHistoryAsset.ts`、`src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx`、`docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`。
+## 拼图历史图关闭 AI 重绘不要强制 Data URL
+
+- 现象:拼图创作页从历史生成图片中选择主图,再关闭 AI 重绘生成草稿时,后端报“上传图必须是图片 Data URL”。
+- 原因:历史图 `imageSrc` 是 `/generated-puzzle-assets/...` 私有兼容路径;AI 重绘开启时后端参考图分支会解析该路径,但关闭 AI 重绘的“直用上传图”分支旧实现只调用 `parse_puzzle_image_data_url`。
+- 处理:关闭 AI 重绘时也复用拼图参考图解析入口,允许 Data URL 与 `/generated-*` 历史路径统一转成 `PuzzleDownloadedImage` 后持久化;前端不需要下载历史图再转 base64。
+- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml`、`npm run dev:api-server` 后检查 `/healthz`。
+- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx`。
+
+## 拼图结果页局部生图不要污染草稿生成态
+
+- 现象:拼图草稿已经生成完成后,在结果页重新生成 UI 背景或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;UI 背景生成中还会禁用“新增关卡”和关卡图生成。
+- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。
+- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页 UI 背景和关卡图走 background action,不设置全局 busy,UI 背景只禁用自己的按钮;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。
+- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。
+- 关联:`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/mappers.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。
+
## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH
- 现象:`Genarrative-Database-Import` 或 `Genarrative-Database-Export` 运行到迁移脚本时,`bash` 报 `node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。
@@ -895,3 +1015,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` 时尽早报错。
- 验证:重新跑 `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`。
+
+## 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//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`。
diff --git a/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md b/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md
new file mode 100644
index 00000000..943d90e3
--- /dev/null
+++ b/.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md
@@ -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 比例。
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index f6906f2e..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-# 默认忽略的文件
-/shelf/
-/workspace.xml
-# 基于编辑器的 HTTP 客户端请求
-/httpRequests/
-# 已忽略包含查询文件的默认文件夹
-/queries/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index cf8f80f7..00000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-mod.rs
\ No newline at end of file
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
deleted file mode 100644
index 932f7d1b..00000000
--- a/.idea/codeStyles/Project.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
deleted file mode 100644
index 79ee123c..00000000
--- a/.idea/codeStyles/codeStyleConfig.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/editor.xml b/.idea/editor.xml
deleted file mode 100644
index ead1d8a3..00000000
--- a/.idea/editor.xml
+++ /dev/null
@@ -1,248 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
deleted file mode 100644
index 03d9549e..00000000
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 315bbf8a..00000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/prettier.xml b/.idea/prettier.xml
deleted file mode 100644
index b0c1c68f..00000000
--- a/.idea/prettier.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1ddf..00000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/deploy/container/README.md b/deploy/container/README.md
new file mode 100644
index 00000000..b9338457
--- /dev/null
+++ b/deploy/container/README.md
@@ -0,0 +1,183 @@
+# Genarrative 容器化压测与隔离部署方案
+
+本目录只服务本机或预发的容器化模拟压测,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。生产服务器仍以 `deploy/systemd/`、`deploy/nginx/`、`scripts/jenkins-*.sh` 和 `scripts/deploy/production-api-deploy.sh` 为准。
+
+## 拓扑
+
+```text
+Docker Compose
+├─ spacetimedb :3101,独立数据卷,供 api-server 连接
+├─ nginx :80 -> api-server:8082,负责静态站点、/admin/、/api/ 反代、upstream timing log、连接限制
+├─ api-server :8082,Linux release 构建,连接 compose 内 SpacetimeDB
+├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
+└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
+```
+
+当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
+容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
+Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
+生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
+
+默认 host 端口:
+
+- `http://127.0.0.1:13101`:容器 SpacetimeDB。
+- `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_SPACETIME_PORT="13102"
+$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 下默认通过 `http://spacetimedb:3101` 连接 compose 内 SpacetimeDB;宿主机只负责用 CLI 发布模块:
+
+```env
+GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
+GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest
+GENARRATIVE_SPACETIME_TOKEN=
+```
+
+宿主机发布模块时,先用 CLI 向 `http://127.0.0.1:13101` 发布到 `genarrative-loadtest`,再启动 `npm run container:up`。
+
+Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `http://127.0.0.1:13101`;容器内部服务之间统一走 `http://spacetimedb:3101`。
+
+## 构建工具链
+
+`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.2.0` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
+
+## 启动与验证
+
+```bash
+npm run container:config
+npm run container:build
+npm run container:up -- spacetimedb
+spacetime publish genarrative-loadtest --server http://127.0.0.1:13101 --module-path server-rs/crates/spacetime-module --yes --build-options="--debug"
+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
+```
+
+容器内 `api-server` 资源上限与 Nginx 连接模型已经按 `genarrative-release` 的 2C / 2G / `nofile=4096` / `worker_connections=768` 收口;如果你要改成别的机器,就先重新采样再改这里。
+
+SpacetimeDB 容器默认只提供运行时,不自动发布模块。首次启动或清理 `spacetime-data` 卷后,先只启动 `spacetimedb` 服务,再发布模块:
+
+```bash
+npm run container:up -- spacetimedb
+spacetime publish genarrative-loadtest --server http://127.0.0.1:13101 --module-path server-rs/crates/spacetime-module --yes --build-options="--debug"
+```
+
+发布完成后再执行 `npm run container:up` 和 `npm run container:k6`。如果 `deploy/container/api-server.env` 里的 `GENARRATIVE_SPACETIME_DATABASE` 改成了别的库名,发布命令里的库名也要同步修改。
+
+如果要压 1000 HTTP req/s,把 `PEAK_RPS` 调到 `500`;如果要压 5000 HTTP req/s,把 `PEAK_RPS` 调到 `2500`,并同时提高 `PREALLOCATED_VUS` / `MAX_VUS`,观察是否先被带宽、Nginx `limit_conn` / `limit_req` 或 api-server 分组背压限制。当前容器 Nginx 对公开 gallery list 使用 `genarrative_gallery_rps`,公开详情和普通 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`;api-server 侧对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS` 和 `GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。
+
+2026-05-19 的 2C / 2G 容器压测结论:公开 gallery list 的 `limit_conn=320`、`limit_req rate=5000r/s burst=4096` 与 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 是当前发布口径。用宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=2500` 等价于约 5000 HTTP req/s 的两接口组合压测;连续 10 轮不重启 SpacetimeDB 的平均实际吞吐约 `4219 HTTP req/s`,总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`。该档会让 SpacetimeDB 内存从约 `366MiB` 累积到约 `885MiB / 896MiB`,下游内存先到危险区。当前不要为了降低“剩余 CPU”继续抬公开列表并发;下一步应减少成功列表请求后的 SpacetimeDB tracking 写入或优化下游连接 / 订阅状态,而不是放大入口并发。
+
+### 内存采样
+
+排查 API 容器内存时,优先对比压测前后的 `/proc/$pid/smaps_rollup` 和 cgroup 当前/峰值,不把 Windows 任务管理器总占用当成单进程结论:
+
+```bash
+docker exec genarrative-container-loadtest-api-server-1 sh -c 'pid=$(pidof api-server); grep VmRSS /proc/$pid/status; grep RssAnon /proc/$pid/status; cat /proc/$pid/smaps_rollup | grep Anonymous; echo cgroup_current=$(cat /sys/fs/cgroup/memory.current); echo cgroup_peak=$(cat /sys/fs/cgroup/memory.peak)'
+```
+
+`/healthz` 也能复现的内存尖峰应先按连接层、service clone 或 allocator 高水位排查,不要直接归因到 SpacetimeDB procedure、作品列表 cache 或业务 DTO。2026-05-18 验证:`AppState` 改为 `Arc` 浅拷贝后,容器内直连 `api-server:8082/healthz` 的 500 HTTP req/s、`PREALLOCATED_VUS=100`、30 秒压测完成 `15001` 次请求,`http_req_failed=0`、`dropped_iterations=0`,API 进程 RSS 从约 18 MiB 升至约 52 MiB,cgroup 峰值约 47 MiB,未再出现 1 GiB 级尖峰。
+
+## 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 模板。
+
+容器内需要临时转发到 Grafana Cloud 时,切换 Collector 配置并从当前 shell 传入 Grafana Cloud 凭据;真实 token 不写入仓库文件:
+
+```powershell
+$env:GENARRATIVE_CONTAINER_OTELCOL_CONFIG="./otelcol.grafana.yaml"
+$env:GRAFANA_CLOUD_OTLP_ENDPOINT="https://..."
+$env:GRAFANA_CLOUD_BASIC_AUTH_HEADER="Basic ..."
+npm run container:up
+npm run container:logs -- otelcol
+```
+
+`deploy/container/otelcol.grafana.yaml` 会同时保留本地 debug exporter,并通过 `otlphttp/grafana` 把 traces / metrics / logs 发到 Grafana Cloud。
+
+## 隔离边界
+
+- 不改生产 systemd 单元。
+- 不改 Jenkins 发布主流程。
+- 不要求真实 HTTPS 证书。
+- 不把真实 `.env`、`.env.local`、`.env.secrets.local` 或 `deploy/container/api-server.env` 放入 Docker build context。
+- 不在容器镜像里内置 SpacetimeDB 数据或 token。
diff --git a/deploy/container/api-server.Dockerfile b/deploy/container/api-server.Dockerfile
new file mode 100644
index 00000000..1a0c1eaa
--- /dev/null
+++ b/deploy/container/api-server.Dockerfile
@@ -0,0 +1,51 @@
+FROM rust:1.93-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 /var/lib/genarrative/tracking-outbox && \
+ 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 \
+ GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
+
+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 scripts/vite-cli.mjs scripts/admin-web-build.mjs ./scripts/
+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
diff --git a/deploy/container/api-server.env.example b/deploy/container/api-server.env.example
new file mode 100644
index 00000000..a3e0dd33
--- /dev/null
+++ b/deploy/container/api-server.env.example
@@ -0,0 +1,42 @@
+# 复制为 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_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
+GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
+GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16
+GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
+GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
+GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
+GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
+GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
+
+GENARRATIVE_OTEL_ENABLED=true
+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
+
+# 默认连接 compose 内部 SpacetimeDB;宿主机发布模块使用 127.0.0.1:13101。
+GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb: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=
diff --git a/deploy/container/docker-compose.loadtest.yml b/deploy/container/docker-compose.loadtest.yml
new file mode 100644
index 00000000..afac4962
--- /dev/null
+++ b/deploy/container/docker-compose.loadtest.yml
@@ -0,0 +1,147 @@
+name: genarrative-container-loadtest
+
+services:
+ spacetimedb:
+ image: clockworklabs/spacetime:v2.2.0
+ user: root
+ command:
+ [
+ "start",
+ "--listen-addr",
+ "0.0.0.0:3101",
+ "--data-dir",
+ "/var/lib/spacetimedb",
+ "--page_pool_max_size",
+ "402653184",
+ "--non-interactive",
+ ]
+ cpus: "1.0"
+ mem_limit: 896m
+ ports:
+ - "${GENARRATIVE_CONTAINER_SPACETIME_PORT:-13101}:3101"
+ volumes:
+ - spacetime-data:/var/lib/spacetimedb
+ ulimits:
+ nofile:
+ soft: 4096
+ hard: 4096
+ healthcheck:
+ test:
+ [
+ "CMD-SHELL",
+ "spacetime server ping http://127.0.0.1:3101 >/dev/null 2>&1",
+ ]
+ interval: 10s
+ timeout: 5s
+ retries: 12
+ start_period: 20s
+
+ api-server:
+ build:
+ context: ../..
+ dockerfile: deploy/container/api-server.Dockerfile
+ target: api-runtime
+ cpus: "2.0"
+ mem_limit: 1g
+ 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
+ - api-tracking-outbox:/var/lib/genarrative/tracking-outbox
+ ulimits:
+ nofile:
+ soft: 4096
+ hard: 4096
+ depends_on:
+ spacetimedb:
+ condition: service_healthy
+ 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
+ cpus: "0.5"
+ mem_limit: 128m
+ depends_on:
+ api-server:
+ condition: service_healthy
+ spacetimedb:
+ condition: service_healthy
+ ports:
+ - "${GENARRATIVE_CONTAINER_HTTP_PORT:-18080}:80"
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ volumes:
+ - nginx-logs:/var/log/nginx
+ ulimits:
+ nofile:
+ soft: 4096
+ hard: 4096
+ 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.151.0
+ command: ["--config=/etc/otelcol/config.yaml"]
+ cpus: "0.25"
+ mem_limit: 128m
+ environment:
+ GRAFANA_CLOUD_OTLP_ENDPOINT: ${GRAFANA_CLOUD_OTLP_ENDPOINT:-}
+ GRAFANA_CLOUD_BASIC_AUTH_HEADER: ${GRAFANA_CLOUD_BASIC_AUTH_HEADER:-}
+ HOSTNAME: ${HOSTNAME:-genarrative-container-loadtest}
+ volumes:
+ - ${GENARRATIVE_CONTAINER_OTELCOL_CONFIG:-./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"]
+ cpus: "1.0"
+ mem_limit: 512m
+ 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:
+ spacetime-data:
+ api-auth-store:
+ api-tracking-outbox:
+ nginx-logs:
diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf
new file mode 100644
index 00000000..2799af16
--- /dev/null
+++ b/deploy/container/nginx.conf
@@ -0,0 +1,220 @@
+worker_processes auto;
+
+events {
+ worker_connections 768;
+}
+
+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;
+ limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s;
+ limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
+ limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
+
+ 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;
+ limit_req_status 429;
+ limit_req_log_level warn;
+
+ root /srv/genarrative/web;
+ index index.html;
+
+ location ^~ /admin/api/ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 64;
+ limit_req zone=genarrative_admin_rps burst=16 nodelay;
+
+ 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/runtime/puzzle/gallery {
+ default_type application/json;
+ limit_conn genarrative_api_conn 320;
+ limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
+
+ 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 = /api/runtime/custom-world-gallery {
+ default_type application/json;
+ limit_conn genarrative_api_conn 320;
+ limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
+
+ 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 ~ ^/api/runtime/puzzle/gallery/[^/]+$ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 32;
+ limit_req zone=genarrative_api_rps burst=32 nodelay;
+
+ 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 ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 32;
+ limit_req zone=genarrative_api_rps burst=32 nodelay;
+
+ 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 ~ ^/api(?:/|$) {
+ default_type application/json;
+ limit_conn genarrative_api_conn 64;
+ limit_req zone=genarrative_api_rps burst=64 nodelay;
+
+ 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://spacetimedb: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://spacetimedb: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;
+ }
+ }
+}
diff --git a/deploy/container/otelcol.grafana.yaml b/deploy/container/otelcol.grafana.yaml
new file mode 100644
index 00000000..ae0af6f4
--- /dev/null
+++ b/deploy/container/otelcol.grafana.yaml
@@ -0,0 +1,36 @@
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 0.0.0.0:4317
+ http:
+ endpoint: 0.0.0.0:4318
+
+processors:
+ batch:
+ timeout: 5s
+ send_batch_size: 512
+ send_batch_max_size: 1024
+
+exporters:
+ debug:
+ verbosity: basic
+ otlp_http/grafana:
+ endpoint: ${env:GRAFANA_CLOUD_OTLP_ENDPOINT}
+ headers:
+ Authorization: ${env:GRAFANA_CLOUD_BASIC_AUTH_HEADER}
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug, otlp_http/grafana]
+ metrics:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug, otlp_http/grafana]
+ logs:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug, otlp_http/grafana]
diff --git a/deploy/container/otelcol.yaml b/deploy/container/otelcol.yaml
new file mode 100644
index 00000000..f86d0155
--- /dev/null
+++ b/deploy/container/otelcol.yaml
@@ -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]
diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example
index 7420d6c9..c7a85bee 100644
--- a/deploy/env/api-server.env.example
+++ b/deploy/env/api-server.env.example
@@ -5,6 +5,21 @@ GENARRATIVE_ENV=production
GENARRATIVE_API_HOST=127.0.0.1
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_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
+GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
+GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16
+GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
+GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
+GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
+GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
+GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
+GENARRATIVE_OTEL_ENABLED=true
+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_PASSWORD=
@@ -79,9 +94,9 @@ SMS_AUTH_ENABLED=false
SMS_AUTH_PROVIDER=aliyun
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
-ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
-ALIYUN_SMS_SIGN_NAME=
-ALIYUN_SMS_TEMPLATE_CODE=
+ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
+ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
+ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_COUNTRY_CODE=86
diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md
index 817a5a85..2dfa2110 100644
--- a/deploy/nginx/README.md
+++ b/deploy/nginx/README.md
@@ -11,13 +11,18 @@
## Brotli
- Brotli 只在目标服务器 Nginx 接受 brotli 指令时开启。
+- Ubuntu / apt 系统的 `Genarrative-Server-Provision` 会安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`;非 apt 系统暂不自动安装,仍按下面的能力探测结果决定是否启用。
- Provision 脚本通过临时配置执行 `nginx -t` 做能力探测;探测配置会先 `include /etc/nginx/modules-enabled/*.conf`,避免 Ubuntu 动态模块已安装但测试配置未加载模块导致误判。可用时把模板中的 `# __GENARRATIVE_BROTLI_DIRECTIVES__` 替换为 brotli 指令,不可用时保留注释说明。
- 不要直接在静态模板里无条件写 `brotli on;`,否则没有 brotli 模块的服务器会 `nginx -t` 失败并回滚。
-- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,可能只出现在 `nginx -T` 的 `load_module` 配置里。
+- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,不会出现在普通编译参数里。应检查包、`/etc/nginx/modules-enabled/` 的 `load_module` 配置,或用包含 `include /etc/nginx/modules-enabled/*.conf` 的临时配置执行 `nginx -t`。
## 验证
```bash
+dpkg -l 'libnginx-mod-http-brotli-*'
+ls -l /etc/nginx/modules-enabled/*brotli*
+nginx -T 2>/dev/null | grep -Ei 'brotli|load_module'
+
curl -sSI -H 'Accept-Encoding: gzip' \
http:///api/runtime/puzzle/gallery \
| grep -iE 'content-encoding|vary|content-type|content-length'
diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf
index 824a8f5a..63234e30 100644
--- a/deploy/nginx/genarrative-dev-http.conf
+++ b/deploy/nginx/genarrative-dev-http.conf
@@ -1,9 +1,32 @@
# 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。
# 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。
# 生产 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;
+limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s;
+limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
+limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
+
server {
listen 80;
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;
+ limit_req_status 429;
+ limit_req_log_level warn;
gzip on;
gzip_vary on;
@@ -29,13 +52,16 @@ server {
location ^~ /admin/api/ {
default_type application/json;
+ limit_conn genarrative_api_conn 64;
+ limit_req zone=genarrative_admin_rps burst=16 nodelay;
if ($genarrative_maintenance) {
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_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;
@@ -65,20 +91,119 @@ server {
try_files $uri =404;
}
- # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
- location ~ ^/api(?:/|$) {
+ location = /api/runtime/puzzle/gallery {
default_type application/json;
+ limit_conn genarrative_api_conn 320;
+ limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
if ($genarrative_maintenance) {
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_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 = /api/runtime/custom-world-gallery {
+ default_type application/json;
+ limit_conn genarrative_api_conn 320;
+ limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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 ~ ^/api/runtime/puzzle/gallery/[^/]+$ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 32;
+ limit_req zone=genarrative_api_rps burst=32 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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 ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 32;
+ limit_req zone=genarrative_api_rps burst=32 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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;
+ }
+
+ # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
+ location ~ ^/api(?:/|$) {
+ default_type application/json;
+ limit_conn genarrative_api_conn 64;
+ limit_req zone=genarrative_api_rps burst=64 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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;
diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf
index 06a3bf86..023a96f8 100644
--- a/deploy/nginx/genarrative.conf
+++ b/deploy/nginx/genarrative.conf
@@ -1,7 +1,30 @@
# 生产域名需要在部署前替换为真实域名,并由 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;
+limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s;
+limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
+limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
+
server {
listen 80;
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;
+ limit_req_status 429;
+ limit_req_log_level warn;
location /.well-known/acme-challenge/ {
root /var/www/html;
@@ -15,6 +38,12 @@ server {
server {
listen 443 ssl http2;
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;
+ limit_req_status 429;
+ limit_req_log_level warn;
gzip on;
gzip_vary on;
@@ -43,13 +72,16 @@ server {
location ^~ /admin/api/ {
default_type application/json;
+ limit_conn genarrative_api_conn 64;
+ limit_req zone=genarrative_admin_rps burst=16 nodelay;
if ($genarrative_maintenance) {
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_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;
@@ -79,20 +111,119 @@ server {
try_files $uri =404;
}
- # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
- location ~ ^/api(?:/|$) {
+ location = /api/runtime/puzzle/gallery {
default_type application/json;
+ limit_conn genarrative_api_conn 320;
+ limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
if ($genarrative_maintenance) {
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_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 = /api/runtime/custom-world-gallery {
+ default_type application/json;
+ limit_conn genarrative_api_conn 320;
+ limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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 ~ ^/api/runtime/puzzle/gallery/[^/]+$ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 32;
+ limit_req zone=genarrative_api_rps burst=32 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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 ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ {
+ default_type application/json;
+ limit_conn genarrative_api_conn 32;
+ limit_req zone=genarrative_api_rps burst=32 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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;
+ }
+
+ # 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
+ location ~ ^/api(?:/|$) {
+ default_type application/json;
+ limit_conn genarrative_api_conn 64;
+ limit_req zone=genarrative_api_rps burst=64 nodelay;
+
+ if ($genarrative_maintenance) {
+ return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
+ }
+
+ 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;
diff --git a/deploy/otelcol/genarrative-debug.yaml b/deploy/otelcol/genarrative-debug.yaml
new file mode 100644
index 00000000..216a591b
--- /dev/null
+++ b/deploy/otelcol/genarrative-debug.yaml
@@ -0,0 +1,23 @@
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: 127.0.0.1:4317
+ http:
+ endpoint: 127.0.0.1:4318
+
+exporters:
+ debug:
+ verbosity: normal
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ exporters: [debug]
+ metrics:
+ receivers: [otlp]
+ exporters: [debug]
+ logs:
+ receivers: [otlp]
+ exporters: [debug]
diff --git a/deploy/systemd/genarrative-api.service b/deploy/systemd/genarrative-api.service
index 1a22b75d..bba53a79 100644
--- a/deploy/systemd/genarrative-api.service
+++ b/deploy/systemd/genarrative-api.service
@@ -15,6 +15,8 @@ Restart=always
RestartSec=5
KillSignal=SIGINT
TimeoutStopSec=30
+LimitNOFILE=65535
+TasksMax=2048
# api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。
NoNewPrivileges=true
diff --git a/deploy/systemd/otelcol-contrib.service b/deploy/systemd/otelcol-contrib.service
new file mode 100644
index 00000000..ad891f02
--- /dev/null
+++ b/deploy/systemd/otelcol-contrib.service
@@ -0,0 +1,22 @@
+[Unit]
+Description=Genarrative OpenTelemetry Collector Contrib
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=otelcol
+Group=otelcol
+WorkingDirectory=/etc/otelcol
+ExecStart=/usr/local/bin/otelcol-contrib --config=/etc/otelcol/genarrative-debug.yaml
+Restart=always
+RestartSec=5
+LimitNOFILE=65535
+
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+ReadWritePaths=/etc/otelcol /var/log/genarrative
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index f65a7b87..d6a26702 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -40,6 +40,12 @@ server-rs + Axum + SpacetimeDB
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 路由分组
路由树由 `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。
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
+5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。
+
+拼图 `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 规则:
@@ -84,14 +117,19 @@ npm run check:server-rs-ddd
## 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(...)]`。
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option>` 加 `#[default(None::>)]`,业务层归一为空数组。
-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` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json`、`levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
+9. 修改后运行:
```bash
npm run spacetime:generate
+npm run check:spacetime-runtime-access
npm run check:spacetime-schema
npm run check:server-rs-ddd
```
@@ -222,7 +260,7 @@ npm run check:server-rs-ddd
### `battle_state`
- Rust 结构体:`BattleState`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `big_fish_agent_message`
@@ -238,6 +276,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`BigFishCreationSession`
- 源码:`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`
@@ -249,10 +288,17 @@ npm run check:server-rs-ddd
- Rust 结构体:`BigFishRuntimeRun`
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
+### SpacetimeDB view:`big_fish_gallery_view`
+
+- Rust view:`big_fish_gallery_view`
+- 返回类型:`Vec`
+- 源码:`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`
- Rust 结构体:`ChapterProgression`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `creation_entry_config`
@@ -267,37 +313,38 @@ npm run check:server-rs-ddd
### `custom_world_agent_message`
- 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`
- 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`
- 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`
- 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`
- 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`
- Rust 结构体:`CustomWorldProfile`
-- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `custom_world_session`
- 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`
@@ -312,7 +359,7 @@ npm run check:server-rs-ddd
### `inventory_slot`
- Rust 结构体:`InventorySlot`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `match3d_agent_message`
@@ -334,15 +381,22 @@ npm run check:server-rs-ddd
- Rust 结构体:`Match3DWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
+### SpacetimeDB view:`match_3_d_gallery_view`
+
+- Rust view:`match3d_gallery_view`
+- 返回类型:`Vec`
+- 源码:`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`
- Rust 结构体:`NpcState`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `player_progression`
- Rust 结构体:`PlayerProgression`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `profile_dashboard_state`
@@ -460,15 +514,64 @@ npm run check:server-rs-ddd
- Rust 结构体:`PuzzleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
+### SpacetimeDB view:`puzzle_gallery_view`
+
+- Rust view:`puzzle_gallery_view`
+- 返回类型:`Vec`
+- 源码:`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`
+- 源码:`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`
- Rust 结构体:`QuestLog`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `quest_record`
- Rust 结构体:`QuestRecord`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `refresh_session`
@@ -505,30 +608,39 @@ npm run check:server-rs-ddd
- Rust 结构体:`SquareHoleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs`
+### SpacetimeDB view:`square_hole_gallery_view`
+
+- Rust view:`square_hole_gallery_view`
+- 返回类型:`Vec`
+- 源码:`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`
- Rust 结构体:`StoryEvent`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `story_session`
- Rust 结构体:`StorySession`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `tracking_daily_stat`
- Rust 结构体:`TrackingDailyStat`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
+- 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。
### `tracking_event`
- Rust 结构体:`TrackingEvent`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
+- 写入:关键业务埋点同步调用单条 procedure;普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`。outbox 到达批量阈值时先封存 active 文件并切新 active,后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。
### `treasure_record`
- Rust 结构体:`TreasureRecord`
-- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
+- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `user_account`
@@ -569,3 +681,10 @@ npm run check:server-rs-ddd
- Rust 结构体:`VisualNovelWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
+
+### SpacetimeDB view:`visual_novel_gallery_view`
+
+- Rust view:`visual_novel_gallery_view`
+- 返回类型:`Vec`
+- 源码:`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 路径处理。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 6cc9b533..8bc5ec79 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -79,6 +79,8 @@ npm run lint
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
@@ -147,8 +149,53 @@ Nginx 负责站点和反向代理
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,不写入文档示例。
+`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。
+
+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 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `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` 核对。
+- Server provision 不在目标机下载 SpacetimeDB 或 `otelcol-contrib`。Jenkins 的 `Prepare Provision Tools` 阶段在 `linux && genarrative-build` 构建机执行 `scripts/prepare-server-provision-tools.sh`:SpacetimeDB 仍通过官方安装入口 `https://install.spacetimedb.com` 准备;`otelcol-contrib` 默认要求在 Jenkins 参数 `OTELCOL_CONTRIB_ARCHIVE` 手动上传 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,再从上传包解出 `provision-tools/otelcol-contrib`。最终工具包通过 `stash/unstash` 上传到 release 部署 agent。目标机上的 `scripts/jenkins-server-provision.sh` 只从该工作区工具包安装 `/stdb/spacetime`、`/stdb/bin/current/*` 和 `/usr/local/bin/otelcol-contrib`。注意 `scripts/jenkins-checkout-source.sh` 会执行 `git reset --hard` / `git clean`,因此被直接执行的新增脚本必须以 Git `100755` 模式提交,或在二次 checkout 之后再补 `chmod +x`。
+- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
+- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
+- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
+- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
+- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
+
+容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额:
+
+```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 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧则通过 Jenkins 构建机准备的 `provision-tools/otelcol-contrib` 安装本机 `otelcol-contrib.service`,真实库名、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 文件日志仍保留:
+
+- 生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;压测、排障或短期要关闭 OTLP 时,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。
+- Collector 使用官方 `otelcol-contrib`,安装与启用仍由 `ENABLE_OTELCOL` / provision 控制,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter,用 `npm run otel:rider` 转发到 Rider,再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。
+- api-server 发送 OTLP HTTP 时,生产模板使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`,容器模板使用 `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318`。
+- `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.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
+- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
+- 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`
@@ -164,9 +211,33 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- `WECHAT_*`
- `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`。高频 HTTP route tracking 不直接阻塞请求链路:`api-server` 将普通 route tracking 先写入本机 tracking outbox,再由后台 worker 按数量或时间阈值批量写入 SpacetimeDB;`daily_login`、作品游玩 `work_play_start`、付费、任务领奖和钱包相关关键事件继续同步直写数据库,避免用户任务进度、游玩统计或支付状态出现可感知延迟。任务配置、进度、领奖、钱包流水分别写入:
- `profile_task_config`
- `profile_task_progress`
@@ -175,6 +246,18 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
+tracking outbox 默认配置:
+
+```env
+GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
+GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
+GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
+GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
+GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
+```
+
+outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
+
常用检查思路:
```sql
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index 61e12cea..c14aa004 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -8,13 +8,17 @@
当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。
+`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
+
## 草稿与作品架
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
-5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
+5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
+6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
+7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
## 拼图
@@ -27,9 +31,17 @@
当前口径:
- 图像输入复用 `CreativeImageInputPanel`。
-- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
-- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。
+- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化。
+- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。
+- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 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 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度。
+- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
+- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
@@ -51,24 +63,24 @@
难度映射:
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
-| --- | ---: | ---: | ---: | ---: |
-| 轻松 | 8 | 2 | 24 | 3 |
-| 标准 | 12 | 4 | 36 | 9 |
-| 进阶 | 16 | 6 | 48 | 15 |
-| 硬核 | 21 | 8 | 63 | 21 |
+| ---- | ---------: | ---------: | -------: | -------: |
+| 轻松 | 8 | 2 | 24 | 3 |
+| 标准 | 12 | 4 | 36 | 9 |
+| 进阶 | 16 | 6 | 48 | 15 |
+| 硬核 | 21 | 8 | 63 | 21 |
当前素材生成流水线:
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
-2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。
+2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
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` 只兼容首张视角。
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
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 +93,16 @@
运行态当前口径:
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
-- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。
+- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击。
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
-- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
-- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
+- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
+- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]` 与 `top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
-- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。
+- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
+- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
@@ -172,3 +185,4 @@
3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。
4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。
5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。
+6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底;generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。
diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
index d04c7773..7b539e81 100644
--- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md
+++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
@@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。
+## 账户与登录
+
+1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。
+2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
+3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
+
## 账户与充值
1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。
diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision
index ee5d13d5..98757f54 100644
--- a/jenkins/Jenkinsfile.production-server-provision
+++ b/jenkins/Jenkinsfile.production-server-provision
@@ -22,7 +22,8 @@ pipeline {
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名')
string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world')
- string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径')
+ string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '构建机准备并上传到目标机工作区的工具包目录')
+ string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '构建机下载 SpacetimeDB 官方安装产物的根地址;目标机不访问该地址')
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
@@ -31,6 +32,9 @@ pipeline {
string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口')
choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-http,release 正式入口选 production-https')
booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务')
+ booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collector;api-server 模板默认开启 OTLP,如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false')
+ string(name: 'OTELCOL_VERSION', defaultValue: '0.151.0', description: 'otelcol-contrib 版本')
+ stashedFile 'OTELCOL_CONTRIB_ARCHIVE'
}
stages {
@@ -60,8 +64,30 @@ pipeline {
}
}
}
- if (!params.SPACETIME_BIN_SOURCE?.trim()) {
- error('SPACETIME_BIN_SOURCE 不能为空。')
+ if (!params.PROVISION_TOOLS_DIR?.trim()) {
+ error('PROVISION_TOOLS_DIR 不能为空。')
+ }
+ if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..')) {
+ error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}")
+ }
+ if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) {
+ error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}")
+ }
+ def otelcolArchiveFilename = env.OTELCOL_CONTRIB_ARCHIVE_FILENAME?.trim()
+ def expectedOtelcolArchiveFilename = "otelcol-contrib_${params.OTELCOL_VERSION.trim()}_linux_amd64.tar.gz"
+ if (params.ENABLE_OTELCOL) {
+ if (!otelcolArchiveFilename) {
+ error("ENABLE_OTELCOL=true 时必须在 OTELCOL_CONTRIB_ARCHIVE 上传 ${expectedOtelcolArchiveFilename}。")
+ }
+ if (otelcolArchiveFilename != expectedOtelcolArchiveFilename) {
+ error("OTELCOL_CONTRIB_ARCHIVE 文件名必须是 ${expectedOtelcolArchiveFilename},当前上传: ${otelcolArchiveFilename}")
+ }
+ }
+ if (!params.ENABLE_OTELCOL && otelcolArchiveFilename) {
+ echo "ENABLE_OTELCOL=false,已上传的 OTELCOL_CONTRIB_ARCHIVE 将不会被安装。"
+ }
+ if (!params.SPACETIME_DOWNLOAD_ROOT?.trim()) {
+ error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
}
def nginxMode = params.NGINX_CONFIG_MODE?.trim()
if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
@@ -77,6 +103,80 @@ pipeline {
}
}
+ stage('Prepare Provision Tools') {
+ agent {
+ label 'linux && genarrative-build'
+ }
+ steps {
+ script {
+ def checkoutFromRemote = { String remoteUrl ->
+ checkout([
+ $class: 'GitSCM',
+ branches: [[name: "*/${params.SOURCE_BRANCH}"]],
+ doGenerateSubmoduleConfigurations: false,
+ extensions: [
+ [$class: 'CleanBeforeCheckout'],
+ [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
+ ],
+ userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
+ ])
+ }
+ try {
+ checkoutFromRemote(env.GIT_REMOTE_URL)
+ env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
+ } catch (error) {
+ echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
+ checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
+ env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
+ }
+ }
+ sh '''
+ bash <<'BASH'
+ set -euo pipefail
+ chmod +x scripts/jenkins-checkout-source.sh
+ SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
+ COMMIT_HASH="${COMMIT_HASH:-}" \
+ GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
+ GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
+ SOURCE_COMMIT_FILE=".jenkins-source-commit" \
+ scripts/jenkins-checkout-source.sh
+ # jenkins-checkout-source.sh 会 reset/clean 到目标 commit,前面的临时 chmod 可能被 Git mode 还原;
+ # 直接执行脚本前在二次 checkout 之后再补执行位,避免 Linux agent 报 Permission denied。
+ chmod +x scripts/prepare-server-provision-tools.sh
+BASH
+ '''
+ script {
+ if (params.ENABLE_OTELCOL) {
+ echo "准备使用手动上传的 otelcol-contrib 包: ${env.OTELCOL_CONTRIB_ARCHIVE_FILENAME}"
+ sh 'bash -lc "rm -rf manual-provision-tool-upload && mkdir -p manual-provision-tool-upload"'
+ dir('manual-provision-tool-upload') {
+ unstash 'OTELCOL_CONTRIB_ARCHIVE'
+ }
+ env.OTELCOL_ARCHIVE_SOURCE = 'manual-provision-tool-upload/OTELCOL_CONTRIB_ARCHIVE'
+ } else {
+ env.OTELCOL_ARCHIVE_SOURCE = ''
+ }
+ }
+ sh '''
+ bash <<'BASH'
+ set -euo pipefail
+
+ PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
+ OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
+ PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
+ OTELCOL_ARCHIVE_SOURCE="${OTELCOL_ARCHIVE_SOURCE:-}" \
+ SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \
+ scripts/prepare-server-provision-tools.sh
+BASH
+ '''
+ script {
+ env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
+ echo "Provision 工具包已准备,源码 commit=${env.SOURCE_COMMIT}"
+ }
+ stash name: 'server-provision-tools', includes: "${params.PROVISION_TOOLS_DIR}/**", useDefaultExcludes: false
+ }
+ }
+
stage('Checkout Provision Files') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
@@ -109,7 +209,7 @@ pipeline {
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
- COMMIT_HASH="${COMMIT_HASH:-}" \
+ COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
@@ -124,10 +224,20 @@ BASH
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
+ unstash 'server-provision-tools'
sh '''
bash <<'BASH'
set -euo pipefail
+ if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
+ chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib"
+ fi
+ chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
+ "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \
+ "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone"
chmod +x scripts/jenkins-server-provision.sh
+ PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
+ SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
+ OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \
scripts/jenkins-server-provision.sh
BASH
'''
diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build
index 4ac2bfa3..9d4ead23 100644
--- a/jenkins/Jenkinsfile.production-stdb-module-build
+++ b/jenkins/Jenkinsfile.production-stdb-module-build
@@ -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 {
agent {
label 'windows'
@@ -45,23 +66,95 @@ pipeline {
],
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 {
+ 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.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
}
@@ -72,7 +165,7 @@ pipeline {
steps {
script {
def buildStep = {
- powershell '''
+ runWindowsPowerShell('stdb-build', '''
$ErrorActionPreference = 'Stop'
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
@@ -110,6 +203,7 @@ pipeline {
}
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
'''
+ )
}
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
withCredentials([
diff --git a/package-lock.json b/package-lock.json
index 9008c606..b30a634e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -72,6 +72,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^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",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -1528,7 +1528,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -1542,8 +1541,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
@@ -1606,8 +1604,7 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -1650,7 +1647,8 @@
"version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"node_modules/@types/chai-subset": {
"version": "1.3.6",
@@ -1696,6 +1694,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1705,6 +1704,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1796,6 +1796,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -2126,6 +2127,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2216,7 +2218,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -2338,6 +2339,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2629,7 +2631,6 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6"
}
@@ -2685,8 +2686,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/domexception": {
"version": "4.0.0",
@@ -2873,6 +2873,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3697,6 +3698,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
"dev": true,
+ "peer": true,
"dependencies": {
"abab": "^2.0.6",
"cssstyle": "^3.0.0",
@@ -4096,7 +4098,6 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -4435,6 +4436,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -4486,6 +4488,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4619,6 +4622,7 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4627,6 +4631,7 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5074,6 +5079,7 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true,
+ "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -5126,6 +5132,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5207,6 +5214,7 @@
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7027,6 +7035,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "peer": true,
"requires": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -7835,15 +7844,13 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
- "peer": true,
"requires": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -7854,8 +7861,7 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true,
- "peer": true
+ "dev": true
}
}
},
@@ -7891,8 +7897,7 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"@types/babel__core": {
"version": "7.20.5",
@@ -7935,7 +7940,8 @@
"version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"@types/chai-subset": {
"version": "1.3.6",
@@ -7978,6 +7984,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
+ "peer": true,
"requires": {
"csstype": "^3.2.2"
}
@@ -7987,6 +7994,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
+ "peer": true,
"requires": {}
},
"@types/semver": {
@@ -8053,6 +8061,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
+ "peer": true,
"requires": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@@ -8263,7 +8272,8 @@
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"acorn-jsx": {
"version": "5.3.2",
@@ -8326,7 +8336,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
- "peer": true,
"requires": {
"dequal": "^2.0.3"
}
@@ -8396,6 +8405,7 @@
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "peer": true,
"requires": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8605,8 +8615,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"detect-libc": {
"version": "2.1.2",
@@ -8646,8 +8655,7 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"domexception": {
"version": "4.0.0",
@@ -8782,6 +8790,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true,
+ "peer": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -9360,6 +9369,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
"dev": true,
+ "peer": true,
"requires": {
"abab": "^2.0.6",
"cssstyle": "^3.0.0",
@@ -9566,8 +9576,7 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
- "dev": true,
- "peer": true
+ "dev": true
},
"magic-string": {
"version": "0.30.21",
@@ -9813,7 +9822,8 @@
"picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
- "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "peer": true
},
"pkg-types": {
"version": "1.3.1",
@@ -9843,6 +9853,7 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "peer": true,
"requires": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9926,12 +9937,14 @@
"react": {
"version": "19.2.4",
"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": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "peer": true,
"requires": {
"scheduler": "^0.27.0"
}
@@ -10256,6 +10269,7 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true,
+ "peer": true,
"requires": {
"esbuild": "~0.27.0",
"fsevents": "~2.3.3",
@@ -10287,7 +10301,8 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"ufo": {
"version": "1.6.3",
@@ -10339,6 +10354,7 @@
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "peer": true,
"requires": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/package.json b/package.json
index 325149e9..4f65c0b6 100644
--- a/package.json
+++ b/package.json
@@ -10,11 +10,14 @@
"dev:web": "node scripts/dev.mjs web",
"dev:admin-web": "node scripts/dev.mjs admin-web",
"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:typecheck": "node scripts/admin-web-build.mjs typecheck",
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:generate": "node scripts/generate-spacetime-bindings.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",
"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",
@@ -29,7 +32,7 @@
"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: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:guardrails": "npm run lint:eslint",
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
@@ -42,6 +45,14 @@
"test:watch": "vitest",
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
"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:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts
index d1e4e4f8..f54ac624 100644
--- a/packages/shared/src/contracts/match3dWorks.ts
+++ b/packages/shared/src/contracts/match3dWorks.ts
@@ -5,6 +5,7 @@
import type { CreationAudioAsset } from './creationAudio';
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
+export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string;
export type Match3DGeneratedItemAssetStatus =
| 'pending'
@@ -163,6 +164,7 @@ export interface Match3DWorkSummary {
updatedAt: string;
publishedAt?: string | null;
publishReady: boolean;
+ generationStatus?: Match3DWorkGenerationStatus | null;
backgroundPrompt?: string | null;
backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null;
diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts
index 594d66ca..9e6d2cb3 100644
--- a/packages/shared/src/contracts/puzzleAgentActions.ts
+++ b/packages/shared/src/contracts/puzzleAgentActions.ts
@@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest =
imageModel?: string | null;
aiRedraw?: boolean;
candidateCount?: number;
+ shouldAutoNameLevel?: boolean;
workTitle?: string;
workDescription?: string;
summary?: string;
diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts
index 3bfd4a44..64678bb4 100644
--- a/packages/shared/src/contracts/puzzleWorkSummary.ts
+++ b/packages/shared/src/contracts/puzzleWorkSummary.ts
@@ -2,6 +2,7 @@ import type { JsonObject } from './common';
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
+export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus'];
export interface PuzzleWorkSummary {
workId: string;
@@ -28,6 +29,7 @@ export interface PuzzleWorkSummary {
pointIncentiveTotalPoints?: number;
pointIncentiveClaimablePoints?: number;
publishReady: boolean;
+ generationStatus?: PuzzleWorkGenerationStatus | null;
levels?: PuzzleDraftLevel[];
}
@@ -40,6 +42,19 @@ export interface PuzzleWorksResponse {
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 {
item: PuzzleWorkProfile;
}
diff --git a/scripts/check-api-server-env.mjs b/scripts/check-api-server-env.mjs
index 9a9932ef..212523cc 100644
--- a/scripts/check-api-server-env.mjs
+++ b/scripts/check-api-server-env.mjs
@@ -27,6 +27,10 @@ function printStatus(key, present) {
const env = mergeApiServerEnv(process.cwd(), process.env);
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] 拼图真实生成配置检查');
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
const present = hasValue(env[key]);
diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs
new file mode 100644
index 00000000..0931ef21
--- /dev/null
+++ b/scripts/check-spacetime-runtime-access.mjs
@@ -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/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 检查通过。');
diff --git a/scripts/container-compose.mjs b/scripts/container-compose.mjs
new file mode 100644
index 00000000..35a4bed9
--- /dev/null
+++ b/scripts/container-compose.mjs
@@ -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: -- [docker compose args]
+
+Commands:
+ container:init 生成 deploy/container/api-server.env
+ container:build 构建 api-server 容器镜像
+ container:up 后台启动 spacetimedb + api-server + nginx + otelcol
+ container:down 停止并清理容器
+ container:logs 查看容器日志
+ container:ps 查看容器状态
+ container:config 校验 compose 配置,传 -- --print 可展开完整配置
+ container:k6 在 compose 网络内运行 k6
+`);
+}
diff --git a/scripts/dev-utils.mjs b/scripts/dev-utils.mjs
index 39d12fcf..e3e24402 100644
--- a/scripts/dev-utils.mjs
+++ b/scripts/dev-utils.mjs
@@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs';
import {dirname, isAbsolute, resolve} from 'node:path';
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) {
return new Set(
@@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) {
}
const [, key, rawValue] = match;
- if (protectedKeys.has(key)) {
+ if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) {
continue;
}
diff --git a/scripts/dev-utils.test.ts b/scripts/dev-utils.test.ts
index a9f3b902..aeabfcee 100644
--- a/scripts/dev-utils.test.ts
+++ b/scripts/dev-utils.test.ts
@@ -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 变量不会遮蔽本地私密配置', () => {
withTempEnvFiles(
{
diff --git a/scripts/generate-spacetime-bindings.mjs b/scripts/generate-spacetime-bindings.mjs
index 27f391b4..6aaf5cef 100644
--- a/scripts/generate-spacetime-bindings.mjs
+++ b/scripts/generate-spacetime-bindings.mjs
@@ -21,6 +21,14 @@ const TARGETS = [
'src',
'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}`);
await replaceGeneratedDir(tempOutDir, target.outDir);
+ await moveGeneratedEntryFile(target);
}
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) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh
index 203518d4..b584b90b 100755
--- a/scripts/jenkins-server-provision.sh
+++ b/scripts/jenkins-server-provision.sh
@@ -1,6 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
+PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
+SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
+OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
+
+require_non_root_relative_path() {
+ local label="$1"
+ local path="$2"
+
+ if [[ -z "${path}" ]]; then
+ echo "[server-provision] ${label} 不能为空。" >&2
+ exit 1
+ fi
+ if [[ "${path}" == /* || "${path}" == *..* ]]; then
+ echo "[server-provision] ${label} 只能是工作区内的相对路径: ${path}" >&2
+ exit 1
+ fi
+}
+
require_path() {
local path="$1"
if [[ ! -e "${path}" ]]; then
@@ -63,6 +81,15 @@ install_build_dependencies() {
fi
}
+install_nginx_brotli_modules() {
+ echo "[server-provision] 安装 Nginx Brotli 动态模块依赖"
+ if command -v apt-get >/dev/null 2>&1; then
+ run_cmd apt-get install -y libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
+ else
+ echo "[server-provision] 当前系统未使用 apt,无法自动安装 Nginx Brotli 动态模块;将继续通过 nginx -t 能力探测决定是否启用 Brotli。"
+ fi
+}
+
install_sccache() {
for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
@@ -81,16 +108,16 @@ install_sccache() {
fi
echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。"
- if ! command -v cargo >/dev/null 2>&1; then
- echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
- exit 1
- fi
-
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ cargo install sccache --locked"
return
fi
+ if ! command -v cargo >/dev/null 2>&1; then
+ echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
+ exit 1
+ fi
+
cargo install sccache --locked
if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then
echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2
@@ -98,6 +125,42 @@ install_sccache() {
fi
}
+sync_otelcol_install() {
+ local target_bin="/usr/local/bin/otelcol-contrib"
+ local source_bin="${OTELCOL_BIN_SOURCE}"
+ local version="${OTELCOL_VERSION:-0.151.0}"
+ local resolved_source="${source_bin}"
+
+ if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then
+ echo "[server-provision] ENABLE_OTELCOL=${ENABLE_OTELCOL:-},跳过 otelcol-contrib 配置。"
+ return
+ fi
+
+ if command -v readlink >/dev/null 2>&1; then
+ resolved_source="$(readlink -f "${source_bin}" 2>/dev/null || echo "${source_bin}")"
+ fi
+
+ if [[ ! -x "${resolved_source}" ]]; then
+ echo "[server-provision] otelcol-contrib 不存在或不可执行: ${source_bin}" >&2
+ echo "[server-provision] 请先在构建机准备好 otelcol-contrib ${version},再通过 provision-tools 上传到目标机。" >&2
+ exit 1
+ fi
+
+ if [[ "${DRY_RUN}" == "true" ]]; then
+ echo "+ install -m 0755 ${resolved_source} ${target_bin}"
+ return
+ fi
+
+ install -m 0755 "${resolved_source}" "${target_bin}"
+ if ! "${target_bin}" --version >/dev/null 2>&1; then
+ echo "[server-provision] otelcol-contrib 安装后无法执行: ${target_bin}" >&2
+ exit 1
+ fi
+ if ! "${target_bin}" --version 2>/dev/null | grep -q "${version}"; then
+ echo "[server-provision] 警告: otelcol-contrib 版本不是期望的 ${version}: $("${target_bin}" --version 2>/dev/null || true)" >&2
+ fi
+}
+
sync_spacetime_install() {
local root_dir="$1"
local target_bin_dir="${root_dir}/bin/current"
@@ -106,14 +169,6 @@ sync_spacetime_install() {
local resolved_command="${SPACETIME_BIN_SOURCE}"
local install_dir=""
local root_bin="${root_dir}/bin"
- local share_bin_dir=""
- local version_dir=""
- local parent_dir=""
-
- if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then
- echo "[server-provision] SpacetimeDB current 目录已存在: ${target_bin_dir}"
- return
- fi
echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}"
if [[ "${DRY_RUN}" == "true" ]]; then
@@ -128,26 +183,10 @@ sync_spacetime_install() {
install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)"
mkdir -p "${root_bin}"
- for share_bin_dir in \
- "/usr/.local/share/spacetime/bin" \
- "/root/.local/share/spacetime/bin" \
- "${HOME:-}/.local/share/spacetime/bin"; do
- if [[ -d "${share_bin_dir}" ]]; then
- version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
- if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then
- echo "[server-provision] 同步 SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}"
- rm -rf "${target_bin_dir}"
- mkdir -p "${target_bin_dir}"
- cp -a "${version_dir}/." "${target_bin_dir}/"
- chmod +x "${target_cli}" "${target_standalone}"
- chown -R spacetimedb:spacetimedb "${root_bin}"
- return
- fi
- fi
- done
-
if [[ -d "${install_dir}/bin" ]]; then
echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}"
+ rm -rf "${root_bin}"
+ mkdir -p "${root_bin}"
cp -a "${install_dir}/bin/." "${root_bin}/"
elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then
echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}"
@@ -156,14 +195,8 @@ sync_spacetime_install() {
cp -f "${install_dir}/spacetimedb-cli" "${target_cli}"
cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}"
chmod +x "${target_cli}" "${target_standalone}"
- elif [[ -f "${resolved_command}" ]]; then
- parent_dir="$(cd -- "${install_dir}/.." && pwd)"
- if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then
- echo "[server-provision] 同步 SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}"
- cp -a "${parent_dir}/bin/." "${root_bin}/"
- else
- echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2
- fi
+ else
+ echo "[server-provision] 未能从 SpacetimeDB 交付包推断完整安装目录: ${resolved_command}" >&2
fi
if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then
@@ -387,6 +420,10 @@ render_api_env_example() {
deploy/env/api-server.env.example
}
+render_otelcol_service() {
+ cat deploy/systemd/otelcol-contrib.service
+}
+
validate_nginx_tls() {
local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}"
if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then
@@ -523,6 +560,8 @@ render_api_service() {
require_path deploy/systemd/spacetimedb.service
require_path deploy/systemd/genarrative-api.service
+require_path deploy/systemd/otelcol-contrib.service
+require_path deploy/otelcol/genarrative-debug.yaml
require_path deploy/nginx/genarrative.conf
require_path deploy/nginx/genarrative-dev-http.conf
require_path deploy/nginx/snippets/genarrative-maintenance.conf
@@ -532,13 +571,15 @@ require_path scripts/deploy/maintenance-off.sh
require_path scripts/deploy/maintenance-status.sh
validate_server_names
+require_non_root_relative_path "PROVISION_TOOLS_DIR" "${PROVISION_TOOLS_DIR}"
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
run_cmd id
install_build_dependencies
+install_nginx_brotli_modules
install_sccache
-run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth
+run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox
if ! id spacetimedb >/dev/null 2>&1; then
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
@@ -585,6 +626,16 @@ else
echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}"
fi
+if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
+ sync_otelcol_install
+ otelcol_service="$(mktemp)"
+ render_otelcol_service >"${otelcol_service}"
+ install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644
+ rm -f "${otelcol_service}"
+else
+ echo "[server-provision] ENABLE_OTELCOL=${ENABLE_OTELCOL:-},跳过 otelcol-contrib service 安装。"
+fi
+
if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then
install_nginx_config_with_rollback
else
@@ -593,7 +644,13 @@ fi
run_cmd systemctl daemon-reload
if [[ "${ENABLE_SERVICES}" == "true" ]]; then
+ if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
+ run_cmd systemctl enable otelcol-contrib.service
+ fi
run_cmd systemctl enable spacetimedb.service genarrative-api.service
+ if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
+ run_cmd systemctl restart otelcol-contrib.service
+ fi
run_cmd systemctl restart spacetimedb.service
wait_for_spacetimedb_service
ensure_spacetime_owner_client_token
diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md
index 0b406675..2f071e8d 100644
--- a/scripts/loadtest/README.md
+++ b/scripts/loadtest/README.md
@@ -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"
```
+## 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
```bash
@@ -151,17 +162,38 @@ BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=spike \
START_RPS=5 \
-PEAK_RPS=100 \
-HOLD=2m \
+PEAK_RPS=25 \
+HOLD=60s \
DETAIL_RATIO=0 \
npm run loadtest:k6:works
```
默认阈值:
-- `http_req_failed < 5%`
+- `http_req_failed < 1%`
- `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,123 @@ npm run loadtest:k6:works
## 排障
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。
+- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS` 以及 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。429 表示 Nginx 或 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。
- 如果详情全部 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 在生产与容器模板里默认开启。需要临时关闭时,显式把 `GENARRATIVE_OTEL_ENABLED=false`;需要验证 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.cpu.time`:进程 user + system 累计 CPU 秒数。
+- `genarrative.process.cpu.usage_percent`:两次指标采集之间的进程 CPU 使用率;100% 约等于占满 1 个 CPU core。
+- `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 数,带 `pool=default|gallery|detail|admin`;如果目标 pool 未接近 0,说明没有打满对应 `GENARRATIVE_API_*_MAX_CONCURRENT_REQUESTS`。
+- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.stale_hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.refreshes_started` / `genarrative.puzzle_gallery.cache.refreshes_failed` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存 fresh 命中、stale 命中、未命中、后台刷新和重建次数。
+- `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
diff --git a/scripts/loadtest/data/works-list.sample.from-migration-1.json b/scripts/loadtest/data/works-list.sample.from-migration-1.json
new file mode 100644
index 00000000..0a8b9def
--- /dev/null
+++ b/scripts/loadtest/data/works-list.sample.from-migration-1.json
@@ -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
+ }
+ }
+ ]
+}
diff --git a/scripts/loadtest/data/works-list.sample.json b/scripts/loadtest/data/works-list.sample.json
index 250157bd..d60cf0c0 100644
--- a/scripts/loadtest/data/works-list.sample.json
+++ b/scripts/loadtest/data/works-list.sample.json
@@ -1,10 +1,12 @@
{
- "source": "spacetime-migration-7.local.json",
- "generatedAt": "2026-05-11T13:09:51.569Z",
+ "source": "spacetime-migration-1.json",
+ "generatedAt": "2026-05-18T11:54:04.280Z",
"counts": {
"puzzle_work_profile": 3,
"custom_world_profile": 1,
- "match3d_work_profile": 0
+ "match3d_work_profile": 0,
+ "square_hole_work_profile": 0,
+ "visual_novel_work_profile": 0
},
"tables": {
"puzzle_work_profile": [
@@ -113,7 +115,9 @@
}
}
],
- "match3d_work_profile": []
+ "match3d_work_profile": [],
+ "square_hole_work_profile": [],
+ "visual_novel_work_profile": []
},
"profileIds": {
"puzzle": [
diff --git a/scripts/loadtest/k6-works-list.js b/scripts/loadtest/k6-works-list.js
index 45e51a82..95f0d212 100644
--- a/scripts/loadtest/k6-works-list.js
+++ b/scripts/loadtest/k6-works-list.js
@@ -56,20 +56,22 @@ const scenarioOptions = {
scenarios: {
spike: {
executor: 'ramping-arrival-rate',
+ startRate: Number(__ENV.START_RPS || 5),
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
maxVUs: Number(__ENV.MAX_VUS || 200),
timeUnit: '1s',
stages: [
- { target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
- { target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
+ { target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.RAMP_UP || '30s' },
+ { target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.HOLD || '2m' },
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
],
},
},
thresholds: {
- http_req_failed: ['rate<0.05'],
+ http_req_failed: ['rate<0.01'],
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'],
},
},
};
@@ -135,12 +137,12 @@ function unwrapPayload(json) {
}
function hasCollection(payload, keys) {
- return keys.some((key) => Array.isArray(payload?.[key]));
+ return Boolean(payload) && keys.some((key) => Array.isArray(payload[key]));
}
function firstCollection(payload, keys) {
for (const key of keys) {
- if (Array.isArray(payload?.[key])) return payload[key];
+ if (payload && Array.isArray(payload[key])) return payload[key];
}
return [];
}
@@ -150,10 +152,11 @@ function hasListItemShape(payload, keys) {
if (collection.length === 0) return true;
const item = collection[0];
const hasId = Boolean(
- item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode,
+ item &&
+ (item.profileId || item.profile_id || item.workId || item.work_id || item.publicWorkCode),
);
const hasTitle = Boolean(
- item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName,
+ item && (item.title || item.workTitle || item.work_title || item.levelName || item.worldName),
);
return hasId && hasTitle;
}
@@ -211,7 +214,8 @@ function performDetailRequest() {
const payload = unwrapPayload(json);
const ok = check(response, {
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
- [`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]),
+ [`${endpoint.name} has detail payload`]: () =>
+ Boolean(payload) && endpoint.expectKeys.some((key) => payload[key]),
});
worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
}
diff --git a/scripts/prepare-server-provision-tools.sh b/scripts/prepare-server-provision-tools.sh
new file mode 100755
index 00000000..2e73a5f4
--- /dev/null
+++ b/scripts/prepare-server-provision-tools.sh
@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
+OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}"
+PREPARE_OTELCOL="${PREPARE_OTELCOL:-${ENABLE_OTELCOL:-true}}"
+OTELCOL_ARCHIVE_SOURCE="${OTELCOL_ARCHIVE_SOURCE:-}"
+OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download}"
+SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}"
+SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}"
+PROVISION_TOOLS_TMP_PARENT="${PROVISION_TOOLS_TMP_PARENT:-${WORKSPACE:-$(pwd)}/.tmp/server-provision-tools}"
+TMP_DIR_TO_CLEAN=""
+OTELCOL_SOURCE_DESCRIPTION="skipped"
+
+cleanup_tmp_dir() {
+ if [[ -n "${TMP_DIR_TO_CLEAN}" ]]; then
+ rm -rf "${TMP_DIR_TO_CLEAN}"
+ fi
+}
+
+require_cmd() {
+ local name="$1"
+ if ! command -v "${name}" >/dev/null 2>&1; then
+ echo "[prepare-provision-tools] 缺少命令: ${name}" >&2
+ exit 1
+ fi
+}
+
+download_file() {
+ local url="$1"
+ local output="$2"
+
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL --retry 3 --retry-delay 2 "${url}" -o "${output}"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -O "${output}" "${url}"
+ else
+ echo "[prepare-provision-tools] 需要 curl 或 wget 下载: ${url}" >&2
+ exit 1
+ fi
+}
+
+make_spacetime_wrapper() {
+ local target="$1"
+
+ cat >"${target}" <<'EOF'
+#!/usr/bin/env sh
+set -eu
+SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+exec "$SELF_DIR/bin/current/spacetimedb-cli" "$@"
+EOF
+ chmod 0755 "${target}"
+}
+
+prepare_otelcol() {
+ local tmp_dir="$1"
+ local archive="${tmp_dir}/otelcol-contrib.tar.gz"
+ local extract_dir="${tmp_dir}/otelcol-contrib"
+ local url="${OTELCOL_DOWNLOAD_ROOT}/v${OTELCOL_VERSION}/otelcol-contrib_${OTELCOL_VERSION}_linux_amd64.tar.gz"
+ local source_archive="${OTELCOL_ARCHIVE_SOURCE}"
+ local target="${PROVISION_TOOLS_DIR}/otelcol-contrib"
+
+ require_cmd tar
+
+ mkdir -p "${extract_dir}"
+ if [[ -n "${source_archive}" ]]; then
+ if [[ ! -f "${source_archive}" ]]; then
+ echo "[prepare-provision-tools] 上传的 otelcol-contrib 包不存在: ${source_archive}" >&2
+ exit 1
+ fi
+ echo "[prepare-provision-tools] 使用手动上传的 otelcol-contrib 包: ${source_archive}"
+ cp "${source_archive}" "${archive}"
+ OTELCOL_SOURCE_DESCRIPTION="manual archive ${source_archive}"
+ else
+ echo "[prepare-provision-tools] 下载 otelcol-contrib: ${url}"
+ download_file "${url}" "${archive}"
+ OTELCOL_SOURCE_DESCRIPTION="download ${url}"
+ fi
+ tar -xzf "${archive}" -C "${extract_dir}"
+
+ if [[ ! -x "${extract_dir}/otelcol-contrib" ]]; then
+ echo "[prepare-provision-tools] otelcol-contrib 包中缺少可执行文件。" >&2
+ exit 1
+ fi
+
+ install -m 0755 "${extract_dir}/otelcol-contrib" "${target}"
+ "${target}" --version >/dev/null
+}
+
+prepare_spacetime() {
+ local tmp_dir="$1"
+ local install_root="${tmp_dir}/spacetime-root"
+ local target_dir="${PROVISION_TOOLS_DIR}/spacetime"
+
+ echo "[prepare-provision-tools] 使用官方安装器准备 SpacetimeDB: ${SPACETIME_INSTALLER_URL}"
+ mkdir -p "${install_root}"
+ download_file "${SPACETIME_INSTALLER_URL}" "${tmp_dir}/spacetime-install.sh"
+ chmod 0755 "${tmp_dir}/spacetime-install.sh"
+ TMPDIR="${tmp_dir}" SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT}" sh "${tmp_dir}/spacetime-install.sh" --root-dir "${install_root}" -y
+
+ if [[ ! -x "${install_root}/bin/current/spacetimedb-cli" ]]; then
+ echo "[prepare-provision-tools] SpacetimeDB 安装结果缺少 bin/current/spacetimedb-cli。" >&2
+ exit 1
+ fi
+ if [[ ! -x "${install_root}/bin/current/spacetimedb-standalone" ]]; then
+ echo "[prepare-provision-tools] SpacetimeDB 安装结果缺少 bin/current/spacetimedb-standalone。" >&2
+ exit 1
+ fi
+
+ mkdir -p "${target_dir}"
+ cp -a "${install_root}/bin" "${target_dir}/bin"
+ make_spacetime_wrapper "${target_dir}/spacetime"
+
+ "${target_dir}/spacetime" --version >/dev/null
+}
+
+main() {
+ local tmp_dir
+
+ require_cmd chmod
+ require_cmd cp
+ require_cmd install
+ require_cmd mktemp
+ require_cmd rm
+
+ mkdir -p "${PROVISION_TOOLS_TMP_PARENT}"
+ tmp_dir="$(mktemp -d "${PROVISION_TOOLS_TMP_PARENT%/}/run.XXXXXX")"
+ TMP_DIR_TO_CLEAN="${tmp_dir}"
+ trap cleanup_tmp_dir EXIT
+
+ rm -rf "${PROVISION_TOOLS_DIR}"
+ mkdir -p "${PROVISION_TOOLS_DIR}"
+
+ if [[ "${PREPARE_OTELCOL}" == "true" ]]; then
+ prepare_otelcol "${tmp_dir}"
+ else
+ echo "[prepare-provision-tools] PREPARE_OTELCOL=${PREPARE_OTELCOL},跳过 otelcol-contrib 工具包准备。"
+ fi
+ prepare_spacetime "${tmp_dir}"
+
+ cat >"${PROVISION_TOOLS_DIR}/MANIFEST.txt" < {
+ 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}
+`;
+}
diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock
index 74415c0e..a74d29db 100644
--- a/server-rs/Cargo.lock
+++ b/server-rs/Cargo.lock
@@ -105,6 +105,7 @@ dependencies = [
"module-square-hole",
"module-story",
"module-visual-novel",
+ "opentelemetry",
"platform-agent",
"platform-auth",
"platform-llm",
@@ -118,6 +119,7 @@ dependencies = [
"shared-contracts",
"shared-kernel",
"shared-logging",
+ "socket2 0.6.3",
"spacetime-client",
"time",
"tokio",
@@ -129,6 +131,7 @@ dependencies = [
"urlencoding",
"uuid",
"webp",
+ "windows-sys 0.61.2",
"zip",
]
@@ -1761,6 +1764,7 @@ dependencies = [
"platform-auth",
"serde",
"serde_json",
+ "sha2",
"shared-kernel",
"time",
"tokio",
@@ -2070,6 +2074,90 @@ dependencies = [
"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]]
name = "parking_lot"
version = "0.12.5"
@@ -2151,6 +2239,26 @@ dependencies = [
"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]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2320,6 +2428,29 @@ dependencies = [
"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]]
name = "protobuf"
version = "3.7.2"
@@ -2622,6 +2753,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
+ "futures-channel",
"futures-core",
"futures-util",
"http 1.4.0",
@@ -3036,6 +3168,12 @@ dependencies = [
name = "shared-logging"
version = "0.1.0"
dependencies = [
+ "opentelemetry",
+ "opentelemetry-appender-tracing",
+ "opentelemetry-otlp",
+ "opentelemetry_sdk",
+ "tracing",
+ "tracing-opentelemetry",
"tracing-subscriber",
]
@@ -3130,6 +3268,7 @@ dependencies = [
"module-square-hole",
"module-story",
"module-visual-novel",
+ "opentelemetry",
"serde",
"serde_json",
"shared-contracts",
@@ -3137,6 +3276,7 @@ dependencies = [
"spacetimedb-sdk",
"time",
"tokio",
+ "tracing",
]
[[package]]
@@ -3807,6 +3947,38 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "tower"
version = "0.5.3"
@@ -3898,6 +4070,22 @@ dependencies = [
"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]]
name = "tracing-subscriber"
version = "0.3.23"
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index 3a6ea980..bddf6c17 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -100,6 +100,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_urlencoded = "0.7"
sha2 = "0.10"
+socket2 = "0.6"
spacetimedb = "2.2.0"
spacetimedb-sdk = "2.2.0"
spacetimedb-lib = { version = "2.2.0", default-features = false }
@@ -110,7 +111,13 @@ tokio-tungstenite = "0.27"
tower = "0.5"
tower-http = "0.6"
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"
+windows-sys = "0.61"
url = "2"
urlencoding = "2"
uuid = "1"
diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml
index 90ab2c7b..b423be50 100644
--- a/server-rs/crates/api-server/Cargo.toml
+++ b/server-rs/crates/api-server/Cargo.toml
@@ -11,6 +11,7 @@ base64 = { workspace = true }
bytes = { workspace = true }
dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] }
+http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
webp = { workspace = true }
module-ai = { workspace = true }
@@ -43,18 +44,23 @@ sha2 = { workspace = true }
shared-contracts = { workspace = true, features = ["oss-contracts"] }
shared-kernel = { workspace = true }
shared-logging = { workspace = true }
+socket2 = { 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", "fs", "io-util"] }
tokio-stream = { workspace = true }
futures-util = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tower-http = { workspace = true, features = ["trace"] }
tracing = { workspace = true }
+opentelemetry = { workspace = true }
url = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
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]
base64 = { workspace = true }
hmac = { workspace = true }
diff --git a/server-rs/crates/api-server/src/api_response.rs b/server-rs/crates/api-server/src/api_response.rs
index 35a8bc64..c9e7ffee 100644
--- a/server-rs/crates/api-server/src/api_response.rs
+++ b/server-rs/crates/api-server/src/api_response.rs
@@ -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_json::Value;
#[cfg(test)]
@@ -32,6 +41,30 @@ where
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::));
+ return json_body_response(Body::from_stream(stream));
+ }
+
+ json_bytes_response(data_json)
+}
+
pub fn json_error_body(
request_context: Option<&RequestContext>,
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)]
mod tests {
use super::*;
@@ -106,6 +152,31 @@ mod tests {
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]
fn error_body_returns_legacy_shape_without_envelope_header() {
let request_context = build_request_context(false);
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index 17956263..e5e4f27c 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -1,7 +1,7 @@
use axum::{
Router,
body::Body,
- extract::Extension,
+ extract::{Extension, FromRef},
http::Request,
middleware,
response::Response,
@@ -11,17 +11,19 @@ use tower_http::{
classify::ServerErrorsFailureClass,
trace::{DefaultOnRequest, TraceLayer},
};
-use tracing::{Level, Span, error, info, info_span, warn};
+use tracing::{Level, Span, error, info_span};
use crate::{
auth::{AuthenticatedAccessToken, require_bearer_auth},
+ backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled,
error_middleware::normalize_error_response,
modules,
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
runtime_inventory::get_runtime_inventory_state,
- state::AppState,
+ state::{AppState, BackpressureState},
+ telemetry::record_http_observability,
tracking::record_route_tracking_event_after_success,
vector_engine_audio_generation::{
create_background_music_task, create_sound_effect_task,
@@ -42,8 +44,6 @@ use crate::{
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router {
- let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
-
Router::new()
.merge(modules::admin::router(state.clone()))
.merge(modules::health::router(state.clone()))
@@ -77,6 +77,11 @@ pub fn build_router(state: AppState) -> Router {
state.clone(),
require_creation_entry_route_enabled,
))
+ // HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
+ .layer(middleware::from_fn_with_state(
+ BackpressureState::from_ref(&state),
+ limit_concurrent_requests,
+ ))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
@@ -86,47 +91,55 @@ pub fn build_router(state: AppState) -> Router {
state.clone(),
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、响应头与错误中间件继续在这里扩展。
.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request| {
let request_id =
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
+ let route = crate::telemetry::observability_route(request.uri().path());
+ let scheme = crate::telemetry::resolve_request_scheme(request.headers());
+ let span_name = format!("{} {}", request.method(), route);
info_span!(
"http.request",
+ otel.kind = "server",
+ otel.name = %span_name,
+ otel.status_code = tracing::field::Empty,
+ http.response.status_code = tracing::field::Empty,
method = %request.method(),
- uri = %request.uri(),
+ http.request.method = %request.method(),
+ http.route = %route,
+ url.scheme = %scheme,
+ url.path = %request.uri().path(),
request_id = %request_id,
+ status = tracing::field::Empty,
+ latency_ms = tracing::field::Empty,
)
})
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(
- move |response: &axum::response::Response,
- latency: std::time::Duration,
- span: &Span| {
+ |response: &axum::response::Response,
+ latency: std::time::Duration,
+ span: &Span| {
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
let status = response.status().as_u16();
- let slow_request = latency_ms >= slow_request_threshold_ms;
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);
- 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(
diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs
index 8b3afd6b..33d46ae5 100644
--- a/server-rs/crates/api-server/src/assets.rs
+++ b/server-rs/crates/api-server/src/assets.rs
@@ -752,10 +752,14 @@ mod tests {
};
use hmac::{Hmac, Mac};
use http_body_util::BodyExt;
+ use platform_auth::{
+ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
+ };
use reqwest::{Method, multipart};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use shared_kernel::new_uuid_simple_string;
+ use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState};
@@ -873,13 +877,17 @@ mod tests {
..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
.oneshot(
Request::builder()
.method("POST")
.uri("/api/assets/direct-upload-tickets")
+ .header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.header("x-request-id", "req-oss-ticket")
.header("x-genarrative-response-envelope", "1")
@@ -1693,6 +1701,33 @@ mod tests {
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(
config: &AppConfig,
object_key: &str,
diff --git a/server-rs/crates/api-server/src/backpressure.rs b/server-rs/crates/api-server/src/backpressure.rs
new file mode 100644
index 00000000..3fc2b689
--- /dev/null
+++ b/server-rs/crates/api-server/src/backpressure.rs
@@ -0,0 +1,481 @@
+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::{BackpressureState, HttpRequestPermitPool, HttpRequestPermitPoolKind},
+};
+
+pub async fn limit_concurrent_requests(
+ State(state): State,
+ request: Request,
+ next: Next,
+) -> Response {
+ if should_bypass_backpressure(&request) {
+ return next.run(request).await;
+ }
+
+ let requested_pool = classify_request_permit_pool(request.uri().path());
+ let Some((permit_pool_kind, permit_pool)) = state.request_permit_pool(requested_pool) else {
+ return next.run(request).await;
+ };
+
+ match acquire_http_request_permit(permit_pool_kind, 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_kind: HttpRequestPermitPoolKind,
+ permit_pool: Arc,
+) -> Result {
+ match permit_pool.clone().try_acquire_owned() {
+ Ok(permit) => {
+ crate::telemetry::update_http_request_permits_available(
+ permit_pool_kind,
+ permit_pool.available_permits(),
+ );
+ Ok(HttpRequestPermitGuard {
+ permit_pool_kind,
+ permit: Some(permit),
+ permit_pool,
+ })
+ }
+ Err(error) => {
+ crate::telemetry::update_http_request_permits_available(
+ permit_pool_kind,
+ 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_pool_kind: HttpRequestPermitPoolKind,
+ permit: Option,
+ permit_pool: Arc,
+}
+
+impl Drop for HttpRequestPermitGuard {
+ fn drop(&mut self) {
+ drop(self.permit.take());
+ crate::telemetry::update_http_request_permits_available(
+ self.permit_pool_kind,
+ self.permit_pool.available_permits(),
+ );
+ }
+}
+
+fn reject_overloaded_request(request: &Request) -> Response {
+ let request_context = request.extensions().get::().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) -> bool {
+ request.uri().path() == "/healthz"
+}
+
+fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind {
+ if is_gallery_list_path(path) {
+ HttpRequestPermitPoolKind::Gallery
+ } else if is_gallery_detail_path(path) {
+ HttpRequestPermitPoolKind::Detail
+ } else if path.starts_with("/admin/api/") {
+ HttpRequestPermitPoolKind::Admin
+ } else {
+ HttpRequestPermitPoolKind::Default
+ }
+}
+
+fn is_gallery_list_path(path: &str) -> bool {
+ matches!(
+ path,
+ "/api/runtime/puzzle/gallery" | "/api/runtime/custom-world-gallery"
+ )
+}
+
+fn is_gallery_detail_path(path: &str) -> bool {
+ let puzzle_prefix = "/api/runtime/puzzle/gallery/";
+ if let Some(profile_id) = path.strip_prefix(puzzle_prefix) {
+ return !profile_id.is_empty() && !profile_id.contains('/');
+ }
+
+ let custom_world_prefix = "/api/runtime/custom-world-gallery/";
+ if let Some(remainder) = path.strip_prefix(custom_world_prefix) {
+ let mut segments = remainder.split('/');
+ return matches!(
+ (segments.next(), segments.next(), segments.next()),
+ (Some(owner_user_id), Some(profile_id), None)
+ if !owner_user_id.is_empty() && !profile_id.is_empty()
+ );
+ }
+
+ false
+}
+
+#[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 axum::extract::FromRef;
+
+ use crate::{
+ config::AppConfig,
+ state::{AppState, BackpressureState},
+ };
+
+ use super::{classify_request_permit_pool, limit_concurrent_requests};
+
+ #[derive(Clone)]
+ struct HeldRequestGate {
+ entered: Arc,
+ release: Arc,
+ }
+
+ async fn held_request(Extension(gate): Extension) -> &'static str {
+ gate.entered.notify_one();
+ gate.release.notified().await;
+ "ok"
+ }
+
+ async fn fast_request() -> &'static str {
+ "ok"
+ }
+
+ fn test_request(path: &str) -> Request {
+ 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");
+ let backpressure_state = BackpressureState::from_ref(&state);
+
+ Router::new()
+ .route("/held", get(held_request))
+ .route("/fast", get(fast_request))
+ .route("/healthz", get(fast_request))
+ .layer(middleware::from_fn_with_state(
+ backpressure_state,
+ limit_concurrent_requests,
+ ))
+ .layer(Extension(gate))
+ .with_state(state)
+ }
+
+ fn build_grouped_test_app(
+ default_max_concurrent_requests: usize,
+ gallery_max_concurrent_requests: usize,
+ admin_max_concurrent_requests: usize,
+ gate: HeldRequestGate,
+ ) -> Router {
+ let mut config = AppConfig::default();
+ config.max_concurrent_requests = Some(default_max_concurrent_requests);
+ config.gallery_max_concurrent_requests = Some(gallery_max_concurrent_requests);
+ config.admin_max_concurrent_requests = Some(admin_max_concurrent_requests);
+ let state = AppState::new(config).expect("state should build");
+ let backpressure_state = BackpressureState::from_ref(&state);
+
+ Router::new()
+ .route("/held", get(held_request))
+ .route("/api/runtime/puzzle/gallery", get(held_request))
+ .route("/api/runtime/custom-world-gallery", get(held_request))
+ .route("/api/runtime/puzzle/gallery/profile-1", get(held_request))
+ .route(
+ "/api/runtime/puzzle/gallery/profile-1/like",
+ get(fast_request),
+ )
+ .route(
+ "/api/runtime/custom-world-gallery/user-1/profile-1",
+ get(held_request),
+ )
+ .route("/admin/api/overview", get(held_request))
+ .route("/fast", get(fast_request))
+ .layer(middleware::from_fn_with_state(
+ backpressure_state,
+ 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);
+ }
+
+ #[tokio::test]
+ async fn gallery_pool_rejects_gallery_without_blocking_default_routes() {
+ let gate = HeldRequestGate {
+ entered: Arc::new(Notify::new()),
+ release: Arc::new(Notify::new()),
+ };
+ let app = build_grouped_test_app(2, 1, 1, gate.clone());
+ let entered = gate.entered.notified();
+
+ let held_response = tokio::spawn(
+ app.clone()
+ .oneshot(test_request("/api/runtime/puzzle/gallery")),
+ );
+ entered.await;
+
+ let rejected_gallery_response = app
+ .clone()
+ .oneshot(test_request("/api/runtime/custom-world-gallery"))
+ .await
+ .expect("rejected gallery request should complete");
+ assert_eq!(
+ rejected_gallery_response.status(),
+ StatusCode::TOO_MANY_REQUESTS
+ );
+
+ let accepted_default_response = app
+ .clone()
+ .oneshot(test_request("/fast"))
+ .await
+ .expect("default request should complete");
+ assert_eq!(accepted_default_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 detail_pool_falls_back_to_default_when_unset() {
+ let gate = HeldRequestGate {
+ entered: Arc::new(Notify::new()),
+ release: Arc::new(Notify::new()),
+ };
+ let mut config = AppConfig::default();
+ config.max_concurrent_requests = Some(1);
+ config.detail_max_concurrent_requests = None;
+ let state = AppState::new(config).expect("state should build");
+ let backpressure_state = BackpressureState::from_ref(&state);
+ let app = Router::new()
+ .route("/api/runtime/puzzle/gallery/profile-1", get(held_request))
+ .route("/fast", get(fast_request))
+ .layer(middleware::from_fn_with_state(
+ backpressure_state,
+ limit_concurrent_requests,
+ ))
+ .layer(Extension(gate.clone()))
+ .with_state(state);
+ let entered = gate.entered.notified();
+
+ let held_response = tokio::spawn(
+ app.clone()
+ .oneshot(test_request("/api/runtime/puzzle/gallery/profile-1")),
+ );
+ entered.await;
+
+ let rejected_default_response = app
+ .clone()
+ .oneshot(test_request("/fast"))
+ .await
+ .expect("default request should complete");
+ assert_eq!(
+ rejected_default_response.status(),
+ StatusCode::TOO_MANY_REQUESTS
+ );
+
+ 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 admin_pool_is_isolated_from_default_routes() {
+ let gate = HeldRequestGate {
+ entered: Arc::new(Notify::new()),
+ release: Arc::new(Notify::new()),
+ };
+ let app = build_grouped_test_app(2, 1, 1, gate.clone());
+ let entered = gate.entered.notified();
+
+ let held_response = tokio::spawn(app.clone().oneshot(test_request("/admin/api/overview")));
+ entered.await;
+
+ let rejected_admin_response = app
+ .clone()
+ .oneshot(test_request("/admin/api/overview"))
+ .await
+ .expect("rejected admin request should complete");
+ assert_eq!(
+ rejected_admin_response.status(),
+ StatusCode::TOO_MANY_REQUESTS
+ );
+
+ let accepted_default_response = app
+ .clone()
+ .oneshot(test_request("/fast"))
+ .await
+ .expect("default request should complete");
+ assert_eq!(accepted_default_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);
+ }
+
+ #[test]
+ fn classifies_only_exact_gallery_detail_paths_as_detail() {
+ assert_eq!(
+ classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1"),
+ crate::state::HttpRequestPermitPoolKind::Detail
+ );
+ assert_eq!(
+ classify_request_permit_pool("/api/runtime/puzzle/gallery/profile-1/like"),
+ crate::state::HttpRequestPermitPoolKind::Default
+ );
+ assert_eq!(
+ classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1"),
+ crate::state::HttpRequestPermitPoolKind::Detail
+ );
+ assert_eq!(
+ classify_request_permit_pool("/api/runtime/custom-world-gallery/user-1/profile-1/like"),
+ crate::state::HttpRequestPermitPoolKind::Default
+ );
+ }
+}
diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs
index b8af62a4..0398c948 100644
--- a/server-rs/crates/api-server/src/config.rs
+++ b/server-rs/crates/api-server/src/config.rs
@@ -20,7 +20,19 @@ pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
+ pub listen_backlog: i32,
+ pub worker_threads: Option,
+ pub max_concurrent_requests: Option,
+ pub gallery_max_concurrent_requests: Option,
+ pub detail_max_concurrent_requests: Option,
+ pub admin_max_concurrent_requests: Option,
+ pub tracking_outbox_enabled: bool,
+ pub tracking_outbox_dir: PathBuf,
+ pub tracking_outbox_batch_size: usize,
+ pub tracking_outbox_flush_interval: Duration,
+ pub tracking_outbox_max_bytes: u64,
pub log_filter: String,
+ pub otel_enabled: bool,
pub admin_username: Option,
pub admin_password: Option,
pub admin_token_ttl_seconds: u64,
@@ -147,7 +159,19 @@ impl Default for AppConfig {
Self {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
+ listen_backlog: 1024,
+ worker_threads: None,
+ max_concurrent_requests: None,
+ gallery_max_concurrent_requests: None,
+ detail_max_concurrent_requests: None,
+ admin_max_concurrent_requests: None,
+ tracking_outbox_enabled: true,
+ tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"),
+ tracking_outbox_batch_size: 500,
+ tracking_outbox_flush_interval: Duration::from_millis(1_000),
+ tracking_outbox_max_bytes: 256 * 1024 * 1024,
log_filter: "info,tower_http=info".to_string(),
+ otel_enabled: false,
admin_username: None,
admin_password: None,
admin_token_ttl_seconds: 4 * 60 * 60,
@@ -164,11 +188,11 @@ impl Default for AppConfig {
dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false,
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_secret: None,
- sms_sign_name: "速通互联验证码".to_string(),
- sms_template_code: "100001".to_string(),
+ sms_sign_name: "北京亓盒网络科技".to_string(),
+ sms_template_code: "SMS_506245486".to_string(),
sms_template_param_key: "code".to_string(),
sms_country_code: "86".to_string(),
sms_scheme_name: None,
@@ -301,6 +325,57 @@ impl AppConfig {
{
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(max_concurrent_requests) =
+ read_first_usize_env(&["GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS"])
+ {
+ config.gallery_max_concurrent_requests = Some(max_concurrent_requests);
+ }
+ if let Some(max_concurrent_requests) =
+ read_first_usize_env(&["GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"])
+ {
+ config.detail_max_concurrent_requests = Some(max_concurrent_requests);
+ }
+ if let Some(max_concurrent_requests) =
+ read_first_usize_env(&["GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"])
+ {
+ config.admin_max_concurrent_requests = Some(max_concurrent_requests);
+ }
+ if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) {
+ config.tracking_outbox_enabled = enabled;
+ }
+ if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_TRACKING_OUTBOX_DIR"]) {
+ config.tracking_outbox_dir = PathBuf::from(dir);
+ }
+ if let Some(batch_size) = read_first_usize_env(&["GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"])
+ {
+ config.tracking_outbox_batch_size = batch_size;
+ }
+ if let Some(flush_interval_ms) =
+ read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"])
+ {
+ config.tracking_outbox_flush_interval = Duration::from_millis(flush_interval_ms);
+ }
+ if let Some(max_bytes) =
+ read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"])
+ {
+ config.tracking_outbox_max_bytes = max_bytes;
+ }
+ 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_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
@@ -881,6 +956,14 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option {
})
}
+fn read_first_positive_i32_env(keys: &[&str]) -> Option {
+ 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 {
keys.iter().find_map(|key| {
env::var(key)
@@ -946,6 +1029,16 @@ fn parse_duration_seconds(raw: &str) -> Option {
}
fn parse_bool(raw: &str) -> Option {
+ 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() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
@@ -971,6 +1064,15 @@ fn parse_positive_u32(raw: &str) -> Option {
Some(value)
}
+fn parse_positive_i32(raw: &str) -> Option {
+ let value = raw.trim().parse::().ok()?;
+ if value <= 0 {
+ return None;
+ }
+
+ Some(value)
+}
+
fn parse_u32(raw: &str) -> Option {
raw.trim().parse::().ok()
}
@@ -1012,7 +1114,9 @@ fn parse_positive_u16(raw: &str) -> Option {
#[cfg(test)]
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};
static ENV_LOCK: OnceLock> = OnceLock::new();
@@ -1035,13 +1139,44 @@ mod tests {
config.dashscope_base_url,
"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!(
config.wechat_authorize_endpoint,
"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]
fn from_env_reads_non_public_models_and_urls() {
let _guard = ENV_LOCK
@@ -1151,6 +1286,79 @@ 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_API_GALLERY_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
+ 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_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
+ std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
+ std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
+ std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
+ std::env::set_var(
+ "GENARRATIVE_TRACKING_OUTBOX_DIR",
+ "/tmp/genarrative-tracking-outbox",
+ );
+ std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250");
+ std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000");
+ std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576");
+ 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_eq!(config.gallery_max_concurrent_requests, Some(64));
+ assert_eq!(config.detail_max_concurrent_requests, Some(32));
+ assert_eq!(config.admin_max_concurrent_requests, Some(16));
+ assert!(!config.tracking_outbox_enabled);
+ assert_eq!(
+ config.tracking_outbox_dir,
+ std::path::PathBuf::from("/tmp/genarrative-tracking-outbox")
+ );
+ assert_eq!(config.tracking_outbox_batch_size, 250);
+ assert_eq!(
+ config.tracking_outbox_flush_interval,
+ std::time::Duration::from_millis(2_000)
+ );
+ assert_eq!(config.tracking_outbox_max_bytes, 1_048_576);
+ 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_API_GALLERY_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
+ std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
+ std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
+ }
+ }
+
#[test]
fn from_env_reads_wechat_pay_settings() {
let _guard = ENV_LOCK
diff --git a/server-rs/crates/api-server/src/generated_image_assets/mod.rs b/server-rs/crates/api-server/src/generated_image_assets.rs
similarity index 100%
rename from server-rs/crates/api-server/src/generated_image_assets/mod.rs
rename to server-rs/crates/api-server/src/generated_image_assets.rs
diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs
index db6d0d28..01ed6555 100644
--- a/server-rs/crates/api-server/src/main.rs
+++ b/server-rs/crates/api-server/src/main.rs
@@ -13,6 +13,7 @@ mod auth_payload;
mod auth_public_user;
mod auth_session;
mod auth_sessions;
+mod backpressure;
mod bark_battle;
mod big_fish;
mod big_fish_agent_turn;
@@ -54,10 +55,12 @@ mod password_entry;
mod password_management;
mod phone_auth;
mod platform_errors;
+mod process_metrics;
mod profile_identity;
mod prompt;
mod puzzle;
mod puzzle_agent_turn;
+mod puzzle_gallery_cache;
mod refresh_session;
mod registration_reward;
mod request_context;
@@ -75,7 +78,9 @@ mod square_hole_agent_turn;
mod state;
mod story_battles;
mod story_sessions;
+mod telemetry;
mod tracking;
+mod tracking_outbox;
mod vector_engine_audio_generation;
mod visual_novel;
mod volcengine_speech;
@@ -85,8 +90,15 @@ mod wechat_provider;
mod work_author;
mod work_play_tracking;
-use shared_logging::init_tracing;
-use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration};
+use shared_logging::{OtelConfig, init_tracing};
+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::runtime::Builder as TokioRuntimeBuilder;
use tokio::time::timeout;
@@ -103,12 +115,18 @@ fn main() -> Result<(), io::Error> {
.name("api-server-bootstrap".to_string())
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.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()
.thread_name("api-server-worker")
- .thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
- .build()?
- .block_on(run_server())
+ .thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES);
+ if let Some(worker_threads) = config.worker_threads {
+ runtime_builder.worker_threads(worker_threads);
+ }
+
+ runtime_builder.build()?.block_on(run_server(config))
})?;
match server_thread.join() {
@@ -117,28 +135,55 @@ fn main() -> Result<(), io::Error> {
}
}
-async fn run_server() -> Result<(), io::Error> {
- // 运行本地开发与联调时,优先从仓库根目录加载本地变量。
- // 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。
- load_local_env_files();
-
- // 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
- let config = AppConfig::from_env();
- init_tracing(&config.log_filter)?;
+async fn run_server(config: AppConfig) -> Result<(), io::Error> {
+ init_tracing(
+ &config.log_filter,
+ OtelConfig {
+ enabled: config.otel_enabled,
+ },
+ )?;
+ process_metrics::register_process_metrics();
+ telemetry::register_http_runtime_metrics();
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)
.await
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
+ state.puzzle_gallery_cache().spawn_cleanup_task();
+ if let Some(outbox) = state.tracking_outbox() {
+ outbox.spawn_worker();
+ }
let router = build_router(state);
- 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
}
+fn build_tcp_listener(
+ bind_address: SocketAddr,
+ listen_backlog: i32,
+) -> Result {
+ 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(
config: AppConfig,
) -> Result {
diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs
index 4d42df69..405393cd 100644
--- a/server-rs/crates/api-server/src/match3d.rs
+++ b/server-rs/crates/api-server/src/match3d.rs
@@ -343,2277 +343,6 @@ impl Match3DItemAssetsGenerationPlan {
}
}
-pub async fn create_match3d_agent_session(
- State(state): State,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
- let config = build_config_from_create_request(&payload);
- let seed_text = build_seed_text(&payload, &config);
- let welcome_message_text = MATCH3D_QUESTION_THEME.to_string();
-
- let session = state
- .spacetime_client()
- .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput {
- session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX),
- owner_user_id: authenticated.claims().user_id().to_string(),
- seed_text,
- welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX),
- welcome_message_text,
- config_json: serialize_match3d_config(&config),
- created_at_micros: current_utc_micros(),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DAgentSessionResponse {
- session: load_match3d_agent_session_response_with_persisted_assets(
- &state,
- authenticated.claims().user_id(),
- session,
- )
- .await,
- },
- ))
-}
-
-pub async fn get_match3d_agent_session(
- State(state): State,
- Path(session_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- &session_id,
- "sessionId",
- )?;
-
- let session = state
- .spacetime_client()
- .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DAgentSessionResponse {
- session: load_match3d_agent_session_response_with_persisted_assets(
- &state,
- authenticated.claims().user_id(),
- session,
- )
- .await,
- },
- ))
-}
-
-pub async fn submit_match3d_agent_message(
- State(state): State,
- Path(session_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
- let session = submit_and_finalize_match3d_message(
- &state,
- &request_context,
- authenticated.claims().user_id(),
- session_id,
- payload,
- )
- .await?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DAgentSessionResponse {
- session: load_match3d_agent_session_response_with_persisted_assets(
- &state,
- authenticated.claims().user_id(),
- session,
- )
- .await,
- },
- ))
-}
-
-pub async fn stream_match3d_agent_message(
- State(state): State,
- Path(session_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- &session_id,
- "sessionId",
- )?;
-
- let owner_user_id = authenticated.claims().user_id().to_string();
- let request_context_for_stream = request_context.clone();
- let stream = async_stream::stream! {
- let result = submit_and_finalize_match3d_message(
- &state,
- &request_context_for_stream,
- owner_user_id.as_str(),
- session_id,
- payload,
- )
- .await;
-
- match result {
- Ok(session) => {
- let session_response = load_match3d_agent_session_response_with_persisted_assets(
- &state,
- owner_user_id.as_str(),
- session,
- )
- .await;
- if let Some(reply) = session_response.last_assistant_reply.clone() {
- yield Ok::(match3d_sse_json_event_or_error(
- "reply_delta",
- json!({ "text": reply }),
- ));
- }
- yield Ok::(match3d_sse_json_event_or_error(
- "session",
- json!({ "session": session_response }),
- ));
- yield Ok::(match3d_sse_json_event_or_error(
- "done",
- json!({ "ok": true }),
- ));
- }
- Err(response) => {
- yield Ok::(match3d_sse_json_event_or_error(
- "error",
- json!({ "message": response.status().to_string() }),
- ));
- }
- }
- };
-
- Ok(Sse::new(stream).into_response())
-}
-
-pub async fn execute_match3d_agent_action(
- State(state): State,
- Path(session_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- &session_id,
- "sessionId",
- )?;
-
- if payload.action.trim() != "match3d_compile_draft" {
- return Err(match3d_bad_request(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- "unknown match3d action",
- ));
- }
-
- let (session, generated_item_assets) = compile_match3d_draft_for_session(
- &state,
- &request_context,
- &authenticated,
- session_id,
- payload.game_name,
- payload.summary,
- payload.tags,
- payload.cover_image_src,
- payload.generate_click_sound,
- )
- .await?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DAgentActionResponse {
- session: map_match3d_agent_session_response_with_assets(
- session,
- &generated_item_assets,
- ),
- },
- ))
-}
-
-pub async fn compile_match3d_agent_draft(
- State(state): State,
- Path(session_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let payload = payload
- .map(|Json(payload)| payload)
- .unwrap_or(CompileMatch3DDraftRequest {
- game_name: None,
- summary: None,
- tags: None,
- cover_image_src: None,
- generate_click_sound: None,
- });
- ensure_non_empty(
- &request_context,
- MATCH3D_AGENT_PROVIDER,
- &session_id,
- "sessionId",
- )?;
-
- let (session, generated_item_assets) = compile_match3d_draft_for_session(
- &state,
- &request_context,
- &authenticated,
- session_id,
- payload.game_name,
- payload.summary,
- payload.tags,
- payload.cover_image_src,
- payload.generate_click_sound,
- )
- .await?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DAgentActionResponse {
- session: map_match3d_agent_session_response_with_assets(
- session,
- &generated_item_assets,
- ),
- },
- ))
-}
-
-pub async fn get_match3d_works(
- State(state): State,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- let items = state
- .spacetime_client()
- .list_match3d_works(authenticated.claims().user_id().to_string())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorksResponse {
- items: items
- .into_iter()
- .map(map_match3d_work_summary_response)
- .collect(),
- },
- ))
-}
-
-pub async fn list_match3d_gallery(
- State(state): State,
- Extension(request_context): Extension,
-) -> Result, Response> {
- let items = state
- .spacetime_client()
- .list_match3d_gallery()
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorksResponse {
- items: items
- .into_iter()
- .map(map_match3d_work_summary_response)
- .collect(),
- },
- ))
-}
-
-pub async fn get_match3d_work_detail(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
-
- let item = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorkDetailResponse {
- item: map_match3d_work_profile_response(item),
- },
- ))
-}
-
-pub async fn put_match3d_work(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
-
- let existing = state
- .spacetime_client()
- .get_match3d_work_detail(
- profile_id.clone(),
- authenticated.claims().user_id().to_string(),
- )
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
- let theme_text = payload
- .theme_text
- .clone()
- .filter(|value| !value.trim().is_empty())
- .unwrap_or(existing.theme_text);
- let item = state
- .spacetime_client()
- .update_match3d_work(Match3DWorkUpdateRecordInput {
- profile_id,
- owner_user_id: authenticated.claims().user_id().to_string(),
- game_name: payload.game_name,
- theme_text,
- summary_text: payload.summary,
- tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(),
- cover_image_src: payload.cover_image_src.unwrap_or_default(),
- cover_asset_id: String::new(),
- clear_count: payload.clear_count,
- difficulty: payload.difficulty,
- updated_at_micros: current_utc_micros(),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorkMutationResponse {
- item: map_match3d_work_profile_response(item),
- },
- ))
-}
-
-pub async fn put_match3d_audio_assets(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
-
- let owner_user_id = authenticated.claims().user_id().to_string();
- let existing = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
- let session_id = existing.source_session_id.clone().ok_or_else(|| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
- "provider": MATCH3D_WORKS_PROVIDER,
- "message": "抓大鹅作品缺少来源 session,无法写回音频素材",
- })),
- )
- })?;
- let assets = payload
- .generated_item_assets
- .into_iter()
- .map(Match3DGeneratedItemAsset::from)
- .collect::>();
- let session = upsert_match3d_draft_snapshot(
- &state,
- &request_context,
- &authenticated,
- session_id,
- owner_user_id.clone(),
- profile_id.clone(),
- Some(existing.game_name),
- Some(existing.summary),
- Some(serde_json::to_string(&existing.tags).unwrap_or_default()),
- existing.cover_image_src,
- None,
- serialize_match3d_generated_item_assets(&assets),
- )
- .await?;
-
- let item = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id, owner_user_id)
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
- let _ = session;
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorkMutationResponse {
- item: map_match3d_work_profile_response(item),
- },
- ))
-}
-
-pub async fn persist_match3d_generated_model(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &payload.item_id,
- "itemId",
- )?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &payload.item_name,
- "itemName",
- )?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &payload.source_url,
- "sourceUrl",
- )?;
-
- let owner_user_id = authenticated.claims().user_id().to_string();
- let existing = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
- let session_id = existing.source_session_id.clone().ok_or_else(|| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
- "provider": MATCH3D_WORKS_PROVIDER,
- "message": "抓大鹅作品缺少来源 session,无法保存历史模型",
- })),
- )
- })?;
-
- let mut assets =
- parse_match3d_generated_item_assets(existing.generated_item_assets_json.as_deref())
- .into_iter()
- .map(Match3DGeneratedItemAsset::from)
- .collect::>();
- let current_asset = assets
- .iter()
- .find(|asset| asset.item_id == payload.item_id)
- .cloned();
- let item_name = normalize_match3d_item_name(payload.item_name.as_str());
- let item_name = if item_name.is_empty() {
- current_asset
- .as_ref()
- .map(|asset| asset.item_name.clone())
- .unwrap_or_else(|| payload.item_name.trim().to_string())
- } else {
- item_name
- };
- let model_file = hyper3d_contract::Hyper3dDownloadFilePayload {
- name: normalize_optional_text(payload.file_name.as_deref())
- .unwrap_or_else(|| "model.glb".to_string()),
- url: payload.source_url.trim().to_string(),
- };
- let downloaded_model = download_match3d_legacy_model(&model_file)
- .await
- .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
- let task_uuid = normalize_optional_text(payload.task_uuid.as_deref());
- let item_slug = build_match3d_item_slug(payload.item_id.as_str(), item_name.as_str());
- let generated_at_micros = current_utc_micros();
- let uploaded_model = persist_match3d_generated_bytes(
- &state,
- owner_user_id.as_str(),
- session_id.as_str(),
- profile_id.as_str(),
- &[
- "items",
- item_slug.as_str(),
- "model",
- task_uuid.as_deref().unwrap_or("manual"),
- ],
- downloaded_model.file_name.as_str(),
- downloaded_model.content_type.as_str(),
- downloaded_model.bytes,
- "match3d_item_model",
- task_uuid.as_deref(),
- generated_at_micros,
- )
- .await
- .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
- let next_asset = Match3DGeneratedItemAsset {
- item_id: payload.item_id,
- item_name,
- item_size: current_asset
- .as_ref()
- .and_then(|asset| asset.item_size.clone())
- .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())),
- image_src: current_asset
- .as_ref()
- .and_then(|asset| asset.image_src.clone()),
- image_object_key: current_asset
- .as_ref()
- .and_then(|asset| asset.image_object_key.clone()),
- image_views: current_asset
- .as_ref()
- .map(|asset| asset.image_views.clone())
- .unwrap_or_default(),
- model_src: Some(uploaded_model.src),
- model_object_key: Some(uploaded_model.object_key),
- model_file_name: Some(downloaded_model.file_name),
- task_uuid,
- subscription_key: normalize_optional_text(payload.subscription_key.as_deref()).or_else(
- || {
- current_asset
- .as_ref()
- .and_then(|asset| asset.subscription_key.clone())
- },
- ),
- sound_prompt: current_asset
- .as_ref()
- .and_then(|asset| asset.sound_prompt.clone()),
- background_music_title: current_asset
- .as_ref()
- .and_then(|asset| asset.background_music_title.clone()),
- background_music_style: current_asset
- .as_ref()
- .and_then(|asset| asset.background_music_style.clone()),
- background_music_prompt: current_asset
- .as_ref()
- .and_then(|asset| asset.background_music_prompt.clone()),
- background_music: current_asset
- .as_ref()
- .and_then(|asset| asset.background_music.clone()),
- click_sound: current_asset
- .as_ref()
- .and_then(|asset| asset.click_sound.clone()),
- background_asset: current_asset
- .as_ref()
- .and_then(|asset| asset.background_asset.clone()),
- status: "model_ready".to_string(),
- error: None,
- };
- upsert_match3d_generated_item_asset(&mut assets, next_asset.clone());
- persist_match3d_generated_item_assets_snapshot(
- &state,
- &request_context,
- &authenticated,
- session_id.as_str(),
- owner_user_id.as_str(),
- profile_id.as_str(),
- &assets,
- )
- .await?;
-
- Ok(json_success_body(
- Some(&request_context),
- PersistMatch3DGeneratedModelResponse {
- asset: map_match3d_generated_item_asset_for_work(Match3DGeneratedItemAssetJson::from(
- next_asset,
- )),
- },
- ))
-}
-
-pub async fn generate_match3d_cover_image(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
- let prompt = normalize_match3d_cover_prompt(payload.prompt.as_str());
- ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?;
-
- let context =
- load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
- .await?;
- let generated_cover = generate_match3d_cover_image_asset(
- &state,
- &context.owner_user_id,
- context.session_id.as_str(),
- profile_id.as_str(),
- &context.config,
- prompt.as_str(),
- payload.uploaded_image_src,
- collect_match3d_cover_reference_image_sources(
- payload.reference_image_src,
- payload.reference_image_srcs,
- ),
- )
- .await
- .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
-
- let item = update_match3d_work_cover_only(
- &state,
- &request_context,
- context.owner_user_id.as_str(),
- context.profile,
- generated_cover.src.as_str(),
- )
- .await?;
-
- Ok(json_success_body(
- Some(&request_context),
- GenerateMatch3DCoverImageResponse {
- item: map_match3d_work_profile_response(item),
- cover_image_src: generated_cover.src,
- cover_image_object_key: generated_cover.object_key,
- prompt,
- },
- ))
-}
-
-async fn update_match3d_work_cover_only(
- state: &AppState,
- request_context: &RequestContext,
- owner_user_id: &str,
- profile: Match3DWorkProfileRecord,
- cover_image_src: &str,
-) -> Result {
- // 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。
- state
- .spacetime_client()
- .update_match3d_work(Match3DWorkUpdateRecordInput {
- profile_id: profile.profile_id,
- owner_user_id: owner_user_id.to_string(),
- game_name: profile.game_name,
- theme_text: profile.theme_text,
- summary_text: profile.summary,
- tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(),
- cover_image_src: cover_image_src.to_string(),
- cover_asset_id: profile.cover_asset_id.unwrap_or_default(),
- clear_count: profile.clear_count,
- difficulty: profile.difficulty,
- updated_at_micros: current_utc_micros(),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })
-}
-
-pub async fn generate_match3d_background_image_for_work(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
- let prompt = normalize_match3d_background_prompt(payload.prompt.as_str());
- ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?;
- let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str());
-
- let context =
- load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
- .await?;
- let Match3DWorkAssetContext {
- owner_user_id,
- session_id,
- profile,
- config,
- assets,
- } = context;
- let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint);
- let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost(
- &state,
- owner_user_id.as_str(),
- "match3d_ui_background_image",
- billing_asset_id.as_str(),
- MATCH3D_BACKGROUND_IMAGE_POINTS_COST,
- async {
- let generated_background = generate_match3d_background_image(
- &state,
- owner_user_id.as_str(),
- session_id.as_str(),
- profile_id.as_str(),
- &config,
- prompt.as_str(),
- )
- .await?;
- let mut assets = assets;
- attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone());
- let save_result = persist_match3d_generated_item_assets_snapshot(
- &state,
- &request_context,
- &authenticated,
- session_id.as_str(),
- owner_user_id.as_str(),
- profile_id.as_str(),
- &assets,
- )
- .await;
- if let Err(response) = save_result {
- tracing::warn!(
- provider = MATCH3D_WORKS_PROVIDER,
- profile_id,
- owner_user_id = %owner_user_id,
- status = %response.status(),
- "抓大鹅 UI 背景图已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产"
- );
- }
- Ok((generated_background, assets))
- },
- )
- .await
- .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
-
- let item = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone())
- .await
- .map(|item| map_match3d_work_profile_response(item))
- .unwrap_or_else(|error| {
- tracing::warn!(
- provider = MATCH3D_WORKS_PROVIDER,
- profile_id,
- owner_user_id = %owner_user_id,
- error = %error,
- "抓大鹅 UI 背景图生成后读取作品详情失败,降级使用写回前快照"
- );
- map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets(
- profile,
- &generated_assets,
- ))
- });
- let background_image_src = generated_background.image_src.clone().unwrap_or_default();
- let background_image_object_key = generated_background
- .image_object_key
- .clone()
- .unwrap_or_default();
-
- Ok(json_success_body(
- Some(&request_context),
- GenerateMatch3DBackgroundImageResponse {
- item,
- background_image_src,
- background_image_object_key,
- generated_background_asset: map_match3d_background_asset_for_work(generated_background),
- prompt,
- },
- ))
-}
-
-pub async fn generate_match3d_container_image_for_work(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
- let prompt = normalize_match3d_background_prompt(payload.prompt.as_str());
- ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?;
- let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str());
-
- let context =
- load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
- .await?;
- let Match3DWorkAssetContext {
- owner_user_id,
- session_id,
- profile,
- config,
- assets,
- } = context;
- let billing_asset_id = format!(
- "{}:{}:{}:container",
- session_id, profile_id, prompt_fingerprint
- );
- let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost(
- &state,
- owner_user_id.as_str(),
- "match3d_ui_container_image",
- billing_asset_id.as_str(),
- MATCH3D_BACKGROUND_IMAGE_POINTS_COST,
- async {
- let generated_container = generate_match3d_container_image(
- &state,
- owner_user_id.as_str(),
- session_id.as_str(),
- profile_id.as_str(),
- &config,
- prompt.as_str(),
- )
- .await?;
- let mut assets = assets;
- let generated_background =
- merge_match3d_container_image_into_background_asset(&assets, generated_container);
- attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone());
- let save_result = persist_match3d_generated_item_assets_snapshot(
- &state,
- &request_context,
- &authenticated,
- session_id.as_str(),
- owner_user_id.as_str(),
- profile_id.as_str(),
- &assets,
- )
- .await;
- if let Err(response) = save_result {
- tracing::warn!(
- provider = MATCH3D_WORKS_PROVIDER,
- profile_id,
- owner_user_id = %owner_user_id,
- status = %response.status(),
- "抓大鹅容器形象已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产"
- );
- }
- Ok((generated_background, assets))
- },
- )
- .await
- .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
-
- let item = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone())
- .await
- .map(|item| map_match3d_work_profile_response(item))
- .unwrap_or_else(|error| {
- tracing::warn!(
- provider = MATCH3D_WORKS_PROVIDER,
- profile_id,
- owner_user_id = %owner_user_id,
- error = %error,
- "抓大鹅容器形象生成后读取作品详情失败,降级使用写回前快照"
- );
- map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets(
- profile,
- &generated_assets,
- ))
- });
- let container_image_src = generated_background
- .container_image_src
- .clone()
- .unwrap_or_default();
- let container_image_object_key = generated_background
- .container_image_object_key
- .clone()
- .unwrap_or_default();
-
- Ok(json_success_body(
- Some(&request_context),
- GenerateMatch3DContainerImageResponse {
- item,
- container_image_src,
- container_image_object_key,
- generated_background_asset: map_match3d_background_asset_for_work(generated_background),
- prompt,
- },
- ))
-}
-
-pub async fn generate_match3d_item_assets_for_work(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
- let item_names = normalize_match3d_batch_item_names(payload.item_names);
- if item_names.is_empty() {
- return Err(match3d_bad_request(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- "请填写至少一个物品名称",
- ));
- }
- let generation_mode = normalize_match3d_item_assets_generation_mode(payload.mode.as_deref());
-
- let context =
- load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id)
- .await?;
- let Match3DWorkAssetContext {
- owner_user_id,
- session_id,
- profile,
- config,
- assets,
- } = context;
- let generation_plan =
- build_match3d_item_assets_generation_plan(generation_mode, item_names, &assets);
- if generation_plan.billed_item_count() == 0 {
- return Ok(json_success_body(
- Some(&request_context),
- GenerateMatch3DItemAssetsResponse {
- item: map_match3d_work_profile_response(profile),
- generated_item_assets: sort_match3d_generated_assets(assets)
- .into_iter()
- .map(Match3DGeneratedItemAssetJson::from)
- .map(map_match3d_generated_item_asset_for_work)
- .collect(),
- },
- ));
- }
- let billed_item_count = generation_plan.billed_item_count();
- let points_cost = calculate_match3d_item_assets_points_cost(billed_item_count);
- let billing_asset_id = format!(
- "{}:{}:{}:{}",
- session_id,
- profile_id,
- billed_item_count,
- build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str())
- );
- let generated_assets = execute_billable_asset_operation_with_cost(
- &state,
- owner_user_id.as_str(),
- "match3d_item_assets",
- billing_asset_id.as_str(),
- points_cost,
- async {
- append_match3d_item_assets(
- &state,
- &request_context,
- &authenticated,
- owner_user_id.as_str(),
- session_id.as_str(),
- profile_id.as_str(),
- &config,
- generation_plan,
- assets,
- )
- .await
- .map_err(|response| {
- AppError::from_status(response.status()).with_details(json!({
- "provider": MATCH3D_WORKS_PROVIDER,
- "message": "抓大鹅批量新增物品素材失败",
- }))
- })
- },
- )
- .await
- .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?;
-
- let item = state
- .spacetime_client()
- .get_match3d_work_detail(profile_id, owner_user_id)
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
- Ok(json_success_body(
- Some(&request_context),
- GenerateMatch3DItemAssetsResponse {
- item: map_match3d_work_profile_response(item),
- generated_item_assets: generated_assets
- .into_iter()
- .map(Match3DGeneratedItemAssetJson::from)
- .map(map_match3d_generated_item_asset_for_work)
- .collect(),
- },
- ))
-}
-
-pub async fn generate_match3d_work_tags(
- State(state): State,
- Extension(request_context): Extension,
- Extension(_authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?;
- let tags = generate_match3d_work_tags_for_profile(
- &state,
- payload.game_name.as_str(),
- payload.theme_text.as_str(),
- payload.summary.as_deref(),
- )
- .await;
-
- Ok(json_success_body(
- Some(&request_context),
- GenerateMatch3DWorkTagsResponse { tags },
- ))
-}
-
-pub async fn publish_match3d_work(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
-
- let item = state
- .spacetime_client()
- .publish_match3d_work(
- profile_id,
- authenticated.claims().user_id().to_string(),
- current_utc_micros(),
- )
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorkMutationResponse {
- item: map_match3d_work_profile_response(item),
- },
- ))
-}
-
-pub async fn delete_match3d_work(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- &profile_id,
- "profileId",
- )?;
-
- let items = state
- .spacetime_client()
- .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_WORKS_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DWorksResponse {
- items: items
- .into_iter()
- .map(map_match3d_work_summary_response)
- .collect(),
- },
- ))
-}
-
-pub async fn start_match3d_run(
- State(state): State,
- Path(profile_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let maybe_payload = payload.ok().map(|Json(payload)| payload);
- let profile_id = maybe_payload
- .as_ref()
- .map(|payload| payload.profile_id.clone())
- .filter(|value| !value.trim().is_empty())
- .unwrap_or(profile_id);
- ensure_non_empty(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- &profile_id,
- "profileId",
- )?;
-
- let run = state
- .spacetime_client()
- .start_match3d_run(Match3DRunStartRecordInput {
- run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
- owner_user_id: authenticated.claims().user_id().to_string(),
- profile_id: profile_id.clone(),
- started_at_ms: current_utc_ms(),
- item_type_count_override: maybe_payload
- .as_ref()
- .and_then(|payload| payload.item_type_count_override)
- .unwrap_or(0),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- record_work_play_start_after_success(
- &state,
- &request_context,
- WorkPlayTrackingDraft::new(
- "match3d",
- profile_id.clone(),
- &authenticated,
- "/api/runtime/match3d/...",
- )
- .profile_id(profile_id.clone())
- .extra(json!({
- "runId": run.run_id,
- })),
- )
- .await;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DRunResponse {
- run: map_match3d_run_response(run),
- },
- ))
-}
-
-pub async fn get_match3d_run(
- State(state): State,
- Path(run_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
-
- let run = state
- .spacetime_client()
- .get_match3d_run(run_id, authenticated.claims().user_id().to_string())
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DRunResponse {
- run: map_match3d_run_response(run),
- },
- ))
-}
-
-pub async fn click_match3d_item(
- State(state): State,
- Path(run_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?;
- ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
- ensure_non_empty(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- &payload.item_instance_id,
- "itemInstanceId",
- )?;
- ensure_non_empty(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- &payload.client_event_id,
- "clientEventId",
- )?;
-
- let confirmation = state
- .spacetime_client()
- .click_match3d_item(Match3DRunClickRecordInput {
- run_id: payload.run_id.unwrap_or(run_id),
- owner_user_id: authenticated.claims().user_id().to_string(),
- item_instance_id: payload.item_instance_id,
- client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32,
- client_event_id: payload.client_event_id,
- clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64,
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DClickResponse {
- confirmation: map_match3d_click_confirmation_response(confirmation),
- },
- ))
-}
-
-pub async fn stop_match3d_run(
- State(state): State,
- Path(run_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
- payload: Result, JsonRejection>,
-) -> Result, Response> {
- let _ = payload.ok();
- ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
-
- let run = state
- .spacetime_client()
- .stop_match3d_run(Match3DRunStopRecordInput {
- run_id,
- owner_user_id: authenticated.claims().user_id().to_string(),
- stopped_at_ms: current_utc_ms(),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DRunResponse {
- run: map_match3d_run_response(run),
- },
- ))
-}
-
-pub async fn restart_match3d_run(
- State(state): State,
- Path(run_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
-
- let run = state
- .spacetime_client()
- .restart_match3d_run(Match3DRunRestartRecordInput {
- source_run_id: run_id,
- next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX),
- owner_user_id: authenticated.claims().user_id().to_string(),
- restarted_at_ms: current_utc_ms(),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DRunResponse {
- run: map_match3d_run_response(run),
- },
- ))
-}
-
-pub async fn finish_match3d_time_up(
- State(state): State,
- Path(run_id): Path,
- Extension(request_context): Extension,
- Extension(authenticated): Extension,
-) -> Result, Response> {
- ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?;
-
- let run = state
- .spacetime_client()
- .finish_match3d_time_up(Match3DRunTimeUpRecordInput {
- run_id,
- owner_user_id: authenticated.claims().user_id().to_string(),
- finished_at_ms: current_utc_ms(),
- })
- .await
- .map_err(|error| {
- match3d_error_response(
- &request_context,
- MATCH3D_RUNTIME_PROVIDER,
- map_match3d_client_error(error),
- )
- })?;
-
- Ok(json_success_body(
- Some(&request_context),
- Match3DRunResponse {
- run: map_match3d_run_response(run),
- },
- ))
-}
-
-async fn submit_and_finalize_match3d_message(
- state: &AppState,
- request_context: &RequestContext,
- owner_user_id: &str,
- session_id: String,
- payload: SendMatch3DAgentMessageRequest,
-) -> Result {
- 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),
- )
- })
-}
-
-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 {
- 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)
-}
-
-async fn compile_match3d_draft_for_session(
- state: &AppState,
- request_context: &RequestContext,
- authenticated: &AuthenticatedAccessToken,
- session_id: String,
- game_name: Option,
- summary: Option,
- tags: Option>,
- cover_image_src: Option,
- generate_click_sound: Option,
-) -> Result<(Match3DAgentSessionRecord, Vec), 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(
- state: &AppState,
- request_context: &RequestContext,
- owner_user_id: &str,
- billing_asset_id: &str,
- operation: Fut,
-) -> Result
-where
- Fut: Future