merge: master into codex/bark-battle

This commit is contained in:
kdletters
2026-05-19 17:04:32 +08:00
307 changed files with 40711 additions and 26022 deletions

36
.dockerignore Normal file
View File

@@ -0,0 +1,36 @@
.git
.codex-temp
.codex-logs
.codex-runlogs
.idea
.vite
node_modules
target
dist
coverage
logs
tmp
*.log
/*.png
/*.jpg
/*.jpeg
/*.webp
.env
.env.local
.env.secrets.local
.env.secrets.*
spacetime.local.json
deploy/container/api-server.env
server-rs/target
server-rs/target-*
server-rs/.data
server-rs/.spacetimedb
public/generated-*
scripts/loadtest/data/*.local.json
scripts/loadtest/data/k6-*.log
scripts/loadtest/data/k6-*summary*.md
scripts/loadtest/data/latest-*-prefix.txt

View File

@@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d"
SMS_AUTH_ENABLED="true" SMS_AUTH_ENABLED="true"
SMS_AUTH_PROVIDER="aliyun" SMS_AUTH_PROVIDER="aliyun"
ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9" ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com"
ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD" ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技"
ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com" ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486"
ALIYUN_SMS_SIGN_NAME="速通互联验证码"
ALIYUN_SMS_TEMPLATE_CODE="100001"
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code" ALIYUN_SMS_TEMPLATE_PARAM_KEY="code"
ALIYUN_SMS_COUNTRY_CODE="86"
ALIYUN_SMS_SCHEME_NAME=""
ALIYUN_SMS_CODE_LENGTH="6"
ALIYUN_SMS_CODE_TYPE="1"
ALIYUN_SMS_VALID_TIME_SECONDS="300"
ALIYUN_SMS_INTERVAL_SECONDS="60"
ALIYUN_SMS_DUPLICATE_POLICY="1"
ALIYUN_SMS_CASE_AUTH_POLICY="1"
ALIYUN_SMS_RETURN_VERIFY_CODE="false"
VITE_AUTH_ALLOW_DEV_GUEST="false" VITE_AUTH_ALLOW_DEV_GUEST="false"
@@ -70,3 +59,9 @@ GENARRATIVE_SPACETIME_TOKEN=""
GENARRATIVE_ADMIN_USERNAME=admin GENARRATIVE_ADMIN_USERNAME=admin
GENARRATIVE_ADMIN_PASSWORD=123456 GENARRATIVE_ADMIN_PASSWORD=123456
ADMIN_API_TARGET=http://127.0.0.1:3100 ADMIN_API_TARGET=http://127.0.0.1:3100
# OTLP
GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,service.namespace=genarrative

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ temp*build*/
.worktrees/ .worktrees/
.env.secrets.local .env.secrets.local
spacetime.local.json spacetime.local.json
deploy/container/api-server.env
# Local load-test data extracted from private migration files # Local load-test data extracted from private migration files
scripts/loadtest/data/*.local.json scripts/loadtest/data/*.local.json

View File

@@ -24,6 +24,117 @@
- 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单点击生成草稿进入结果页结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime发布成功后再进入正式 runtime。 - 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单点击生成草稿进入结果页结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime发布成功后再进入正式 runtime。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`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 文件并切新 activesealed 文件交给后台 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 readydebug 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/` 模板仍是正式线上来源。
- 生产 Collectorserver-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` 个 5xx200 请求平均 `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 和 logsCollector 统一用 `otelcol-contrib``npm run otel:debug` 负责 debug 采集,`npm run otel:rider` 负责转发到 RiderRider 只是接收与可视化端,不直接替代 Collector。
- 日志口径Rider Logs 面板只展示 log event 自身字段,请求完成日志需要直接携带 `request_id`、HTTP method、规范化 route、scheme、path、status、status_class、latency 和 slow_request更完整的 request attributes 仍以 trace/span 为准。
- 影响范围:`server-rs/crates/shared-logging``server-rs/crates/api-server``scripts/run-otelcol.mjs`、压测与运维文档。
- 验证方式:`cargo test -p shared-logging --manifest-path server-rs/Cargo.toml generic_otlp_http_endpoint_expands_to_signal_paths``cargo test -p api-server --manifest-path server-rs/Cargo.toml observability_route_keeps_metrics_labels_low_cardinality``cargo test -p api-server --manifest-path server-rs/Cargo.toml resolve_request_scheme_uses_forwarded_proto_first_value``cargo check -p api-server --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``scripts/loadtest/README.md`
## 2026-05-14 创作页图像输入统一封装为图像组件 ## 2026-05-14 创作页图像输入统一封装为图像组件
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。 - 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
@@ -141,7 +252,8 @@
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化 ## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。 - 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc``uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。 - 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt``uiBackgroundImageSrc``uiBackgroundImageObjectKey``compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc``uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。 - 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。
- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding` - 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx``cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md` - 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`
@@ -516,6 +628,14 @@
- 验证方式生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md` - 验证方式生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
- 关联文档:`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、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路需要避免范围泛化。 - 背景“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路需要避免范围泛化。
@@ -523,3 +643,11 @@
- 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。 - 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。
- 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/``docs/tracking/` - 验证方式:非 `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` - 关联文档:`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 文件;关键事件仍立即影响任务 / 统计。

View File

@@ -195,6 +195,13 @@ npm run check:server-rs-ddd
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` - `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md` - `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`
## 生产压测与观测默认口径
- 作品列表 50 HTTP req/s 压测使用 `scripts/loadtest/README.md` 中的 K6 命令;当前脚本一次 iteration 请求两个公开列表接口,因此目标 50 HTTP req/s 对应 `PEAK_RPS=25`
- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、systemd 限制、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
- OpenTelemetry 现阶段可选发送 traces / metrics / logs但不会取代本地 `journalctl -u genarrative-api.service``logs/api-server/``/var/log/nginx/genarrative.*.log`
- 指标 label 不写 raw URI、userId、profileId 或 request_idrequest_id 只用于 trace/log 串联。
## 前端相关默认验证 ## 前端相关默认验证
前端修改后,应根据修改范围选择: 前端修改后,应根据修改范围选择:

View File

@@ -22,6 +22,22 @@
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。 - 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
- 关联:`src/components/common/CreativeImageInputPanel.tsx``src/components/puzzle-agent/PuzzleAgentWorkspace.tsx` - 关联:`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不是 gRPCCollector 才是接收和转发边界。
- 处理:生产模板用 `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` 覆盖当前页面素材。 - 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。
- 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets``clearCount``difficulty` - 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets``clearCount``difficulty`
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。 - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。
- 关联:`src/components/match3d-result/Match3DResultView.tsx``server-rs/crates/api-server/src/match3d.rs``server-rs/crates/spacetime-module/src/match3d/mod.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md` - 关联:`src/components/match3d-result/Match3DResultView.tsx``server-rs/crates/api-server/src/match3d.rs``server-rs/crates/spacetime-module/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## OSS V4 签名时间和 bucket/object_key 兼容 ## OSS V4 签名时间和 bucket/object_key 兼容
@@ -83,6 +99,62 @@
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md``npm run check:encoding` - 关联:`AGENTS.md``npm run check:encoding`
## SpacetimeDB 运行态查询不要绕过已有索引或用 procedure JSON 回传
- 现象:运行态接口看起来只查当前用户、作品或任务,却在 `spacetime-module` 中使用 `ctx.db.<table>().iter().filter(...)` 整表遍历;或者 procedure result 返回 `items_json/run_json/work_json` 等 JSON 字符串,`spacetime-client` mapper 再反序列化成旧兼容结构。
- 原因:新增索引或 typed snapshot 后,没有同步清理旧 mapper / 测试兼容层,也没有用静态检查拦截回退写法。
- 处理表上已有主键、unique 或 `#[index]` 覆盖查询前缀时,先用对应 accessor `.find(...)` / `.filter(...)`只对索引无法覆盖的条件做内存残余过滤procedure result 返回 typed snapshot / typed value不再跨层传 `*_json: Option<String>` 作为 payload。
- 验证:执行 `npm run check:spacetime-runtime-access``npm run check:server-rs-ddd`,涉及绑定变化时先执行 `npm run spacetime:generate``npm run check:spacetime-schema`
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``scripts/check-spacetime-runtime-access.mjs``server-rs/crates/spacetime-module/src/*``server-rs/crates/spacetime-client/src/mapper.rs`
## 拼图广场列表不要每次 HTTP 请求调用 SpacetimeDB procedure
- 现象:`/api/runtime/puzzle/gallery` 每个请求都走 `spacetime-client.list_puzzle_gallery()` 调用 SpacetimeDB procedure导致 SpacetimeDB WASM 侧重复组装全量列表,客户端再映射一遍;历史实现还出现过 procedure JSON 字符串往返。
- 原因:`api-server` 的服务器端 `spacetime-client` 没有订阅可公开读取的 gallery 投影,虽然 SDK 支持 client cache但请求路径仍把列表读取当作 procedure 调用。
- 处理:`spacetime-module` 中用 public view `puzzle_gallery_card_view` 暴露已发布拼图作品的列表卡片字段,不携带 `levels` / `anchor_pack` 等详情级载荷;`spacetime-client` 建连接后订阅 `SELECT * FROM puzzle_gallery_card_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 并等待 `on_applied`。HTTP gallery 通过 `PuzzleGalleryCache` 缓存最终 `PuzzleGalleryResponse` DTO`items` 返回前 10 个完整卡片,`previewRefs` 返回后 10 个作品号引用cache miss / TTL 过期时单飞重建,后台 cleanup task 周期清理旧响应。旧 `list_puzzle_gallery` procedure 只作兼容,不再作为 HTTP gallery 主路径。
- 验证:搜索 `server-rs/crates/spacetime-client/src/puzzle.rs` 不应再出现 gallery 主路径调用 `list_puzzle_gallery_then`;搜索 `server-rs/crates/spacetime-client/src/lib.rs` 应订阅 `puzzle_gallery_card_view`;执行 `npm run spacetime:generate``cargo check --manifest-path server-rs/Cargo.toml -p spacetime-client``cargo check --manifest-path server-rs/Cargo.toml -p api-server` 和 schema/runtime access 检查。
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs``server-rs/crates/spacetime-client/src/lib.rs``server-rs/crates/spacetime-client/src/puzzle.rs``server-rs/crates/api-server/src/puzzle_gallery_cache.rs``/api/runtime/puzzle/gallery`
## Windows 本地直连高 VU 压测不要误判成业务内存泄漏
- 现象:本地 Windows release `api-server` 直连 K6 压测时250 RPS、`PREALLOCATED_VUS=300` 能把进程 private memory 瞬时推到约 7GB同样配置打 `/healthz` 小响应也能复现,压测结束后回落到 100MB 级。
- 原因:高水位主要来自本机直连的 K6 VU / 长连接 / Hyper 发送链路和 Windows 连接缓冲,不是 SpacetimeDB procedure、拼图 JSON 缓存或 OTEL exporter。降低到接近真实并发的 VU 后,同样 250 RPS 拼图广场 p95 约 9ms峰值约 600MB。
- 处理:本地容量判断时让 `PREALLOCATED_VUS` / `MAX_VUS` 接近真实并发,不要把过高 VU 预分配当作默认吞吐测试;同时观察 `process.memory.*``process.windows.handle.count``genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available``genarrative.puzzle_gallery.cache.*``genarrative.spacetime.read.*`。如果内存高但 body in-flight、背压 permit、cache rebuild 和 SpacetimeDB read 都不显示积压,优先按连接 / 发送链路高水位处理。
- 验证:对照打 `/api/runtime/puzzle/gallery``/healthz`;对比 `PREALLOCATED_VUS=300 MAX_VUS=800``PREALLOCATED_VUS=20 MAX_VUS=40`;压测结束后继续采样 10 秒确认 private memory 回落。
- 关联:`scripts/loadtest/README.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``server-rs/crates/api-server/src/process_metrics.rs``server-rs/crates/api-server/src/telemetry.rs`
## 容器高 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>` 浅拷贝壳;新增共享状态字段时放入 `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 MiBcgroup 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` procedure50RPS 以上会把 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 日播放数;入口配置优先读订阅 cachecache 缺失时用最近一次成功内存快照,再兜底调用 `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 并单张串行 ## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。 - 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
@@ -390,6 +462,14 @@
- 验证:请求返回 JSON相关页面不再出现 HTML parse 错误。 - 验证:请求返回 JSON相关页面不再出现 HTML parse 错误。
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md` - 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`
## `npm run build` 因 Vite warning 被 build-gate 判失败
- 现象:主站或后台 Vite 已经输出 `built in ...`,但根命令最后仍失败并打印 `Build gate failed because warnings were emitted`
- 原因:`scripts/build-gate.mjs` 会收集 stdout / stderr 中的 warning 行并作为硬失败;常见触发是产物 chunk 超过 `vite.config.ts``apps/admin-web/vite.config.ts``chunkSizeWarningLimit`
- 处理:先看 warning 原文确认来源。若是合理的入口级 chunk 体积增长,调整对应 Vite 配置阈值或做真实拆包;不要把这类失败按 Rust / SpacetimeDB 编译错误排查。
- 验证:重新执行 `npm run build`,主站与后台均构建完成且没有 build-gate warning 汇总。
- 关联:`scripts/build-gate.mjs``vite.config.ts``apps/admin-web/vite.config.ts`
## 反馈页清空 file input 前必须先拷贝 FileList ## 反馈页清空 file input 前必须先拷贝 FileList
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。 - 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
@@ -410,8 +490,8 @@
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。 - 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
- 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。 - 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local` - 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env``.env.local``.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`本地认证开关例外:`SMS_AUTH_ENABLED``SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖, shell 变量仍然最高优先级。 - 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })``.env.local``SMS_AUTH_ENABLED=true` 时应返回 true
- 关联:`scripts/dev-utils.mjs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md` - 关联:`scripts/dev-utils.mjs``server-rs/crates/api-server/src/hyper3d_generation.rs``docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`
## OSS 密钥键名不要把字母 O 写成数字 0 ## OSS 密钥键名不要把字母 O 写成数字 0
@@ -440,28 +520,28 @@
## 本地短信登录页签突然消失 ## 本地短信登录页签突然消失
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。 - 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类: - 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。
- 本地启动脚本没有让 `.env.local` 覆盖 `.env``SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]` - 本地启动脚本没有让 `.env.local` 覆盖 `.env``SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500``AuthGate` 降级成 `["password"]` - Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500``AuthGate` 降级成 `["password"]`
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。 - 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。 - 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。 - 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
- 处理:优先用 `npm run dev:api-server``npm run dev:spacetime``npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local``SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启 - 处理:当前口径是登录弹窗永远展示 `短信登录``密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server``npm run dev:spacetime``npm run dev` 启动,确认 `.env.local` 覆盖 `.env``RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标
- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}`,登录弹窗会恢复短信登录页签和“获取验证码”按钮 - 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录``密码登录``验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证
- 关联:`scripts/dev-utils.mjs``scripts/dev.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md` - 关联:`src/components/auth/AuthGate.tsx``src/components/auth/LoginScreen.tsx``src/components/auth/AuthGate.test.tsx``scripts/dev-utils.mjs``scripts/dev.mjs`
## 本地短信收不到验证码先查 provider ## 本地短信收不到验证码先查 provider
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值 - 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程Rust bool 解析失败后保持默认 false
- 处理:真实短信联调时把 `.env.local``SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE` - 处理:真实短信联调时把 `.env.local``SMS_AUTH_ENABLED=true``SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com``ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技``ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486``ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。 - 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]``api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID``ALIYUN_SMS_ACCESS_KEY_SECRET``ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`
- 关联:`scripts/dev-utils.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md``docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md` - 关联:`server-rs/crates/api-server/src/config.rs``scripts/dev-utils.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md``docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`
## 手机验证码登录 500 先查短信 provider 语义 ## 手机验证码登录 500 先查短信 provider 语义
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN``biz.FREQUENCY``check frequency failed` - 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN``biz.FREQUENCY``check frequency failed`
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。 - 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server` - 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500` - 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`
- 关联:`server-rs/crates/module-auth/src/errors.rs``server-rs/crates/api-server/src/phone_auth.rs``docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md` - 关联:`server-rs/crates/module-auth/src/errors.rs``server-rs/crates/api-server/src/phone_auth.rs``docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`
@@ -656,6 +736,14 @@
- 验证:扫描 `jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES``CHUNK_SIZE``SERVER_BACKUP_DIRECTORY``SMOKE_HEALTH_URL` 等可选参数不再裸读。 - 验证:扫描 `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` - 关联:`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 ## 个人任务 scope 不得扩成 work/site/module
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。 - 现象:个人任务配置为 `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` - 验证:执行 `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` - 关联:`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/s320 档无 dropped iterations、无 5xx、无 OOM200 请求 `request_time p95` 约 0.292s。336 / 352 档 p95 升到约 0.31s / 0.32sSpacetimeDB 内存尾部可到约 `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 解码 ## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind``status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。 - 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind``status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
@@ -798,7 +902,7 @@
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。 - 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
- 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]``imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。 - 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]``imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml` - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx``cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`
- 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/match3d-runtime/match3dRuntimeClient.ts``server-rs/crates/module-match3d/src/application.rs``server-rs/crates/spacetime-module/src/match3d/mod.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md` - 关联:`src/components/match3d-result/Match3DResultView.tsx``src/services/match3d-runtime/match3dRuntimeClient.ts``server-rs/crates/module-match3d/src/application.rs``server-rs/crates/spacetime-module/src/match3d.rs``docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉 ## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
@@ -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` - 验证:`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` - 关联:`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不设置全局 busyUI 背景只禁用自己的按钮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 ## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH
- 现象:`Genarrative-Database-Import``Genarrative-Database-Export` 运行到迁移脚本时,`bash``node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。 - 现象:`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` 时尽早报错。 - 处理:导入 / 导出流水线在调用迁移脚本前先 `source scripts/jenkins-prepare-toolchain-env.sh`;该脚本会把 `GENARRATIVE_JENKINS_TOOL_PATHS``/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin``/var/lib/jenkins/.cargo/bin``/var/lib/jenkins/.local/bin` 和系统 PATH 前缀统一补齐,并在缺少 `node` 时尽早报错。
- 验证:重新跑 `Genarrative-Database-Import``Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain``node=...` 解析结果,而不是在迁移中途报 `node: command not found` - 验证:重新跑 `Genarrative-Database-Import``Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain``node=...` 解析结果,而不是在迁移中途报 `node: command not found`
- 关联:`scripts/jenkins-prepare-toolchain-env.sh``jenkins/Jenkinsfile.production-database-import``jenkins/Jenkinsfile.production-database-export``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` - 关联:`scripts/jenkins-prepare-toolchain-env.sh``jenkins/Jenkinsfile.production-database-import``jenkins/Jenkinsfile.production-database-export``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Windows Jenkins `powershell` step 在 Stdb module 构建里曾触发 CreateProcess error=5
- 现象:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 节点上报 `java.io.IOException: Cannot run program "powershell" (in directory "C:\\Users\\DSK\\.jenkins-local\\workspace\\Genarrative-Stdb-Module-Build"): CreateProcess error=5, 拒绝访问。`;日志里能看到 `durable-task` 已写出 `powershellWrapper.ps1`,但在真正启动裸 `powershell` 子进程时失败。
- 原因Jenkins durable-task 的 `powershell` step 依赖一个隐式命令解析/启动路径,在这台 Windows 本地 Jenkins 环境里会被拒绝。`powershell.exe` 本体和 workspace ACL 都是正常的,问题出在 Jenkins step 的启动方式,而不是 PowerShell 脚本内容。修复后若日志能打印 `[jenkins-powershell] exe:`,但随后仅报 `拒绝访问` / `script returned exit code 5`,通常已经不是 PowerShell 启动失败,而是 Checkout 脚本内部命令在 Windows workspace 里触发权限拒绝。若 `.jenkins-*.ps1` 里中文 `throw '[stdb-build] ...'``MissingArrayIndexExpression`,则是 Windows PowerShell 5.1 用 `-File` 解析无 BOM UTF-8 脚本时按本地 ANSI 误解码。
- 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build``Checkout``Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。
- 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:``[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds/<n>/log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底
- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。
- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。
- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)``ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。
- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``npm run typecheck``npm run check:encoding`
- 关联:`src/index.css``src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/rpg-entry/rpgEntryWorldPresentation.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 生成中草稿刷新后不要只恢复作品架遮罩
- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。
- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。
- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice恢复对应玩法生成进度页恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -0,0 +1,27 @@
# 前端直订阅公开作品列表准入待办
## 背景
未来可以考虑让前端直接订阅公开作品列表,以减少列表读取链路中的 HTTP 往返,并复用 SpacetimeDB 的实时同步能力。
## 当前结论
短期仍由 `api-server` / BFF 订阅 SpacetimeDB public read model并从本地 cache 读取后对外提供 HTTP 列表接口。前端不直接订阅作品源表,也不把正式列表排序、分页、权限裁剪逻辑下放到 UI。
## 落地前置条件
- 建立专用、稳定、低基数的 public read model例如 `public_work_gallery_entry`
- 明确权限边界,只暴露公开列表所需字段,不泄露作者私有信息、审核内部状态或运营字段。
- 固化字段契约,明确字段含义、默认值、兼容策略和生成绑定更新流程。
- 明确排序与分页语义,避免依赖自增 ID 顺序,优先使用时间戳或显式排序字段。
- 补齐埋点方案,能区分直订阅首屏、增量更新、分页加载和 fallback 命中。
- 保留 BFF HTTP fallback用于低版本客户端、订阅失败、权限策略调整和灰度回滚。
- 禁止前端订阅 `puzzle_work_profile``custom_world_profile` 等作品源表。
## 建议验收
- 文档确认直订阅只面向专用 public read model不绕过 BFF 读取源表。
- schema、绑定、字段契约、排序分页和权限说明同步更新。
- 前端具备订阅失败后的 BFF HTTP fallback。
- 自动测试覆盖公开字段裁剪、排序分页稳定性和 fallback 路径。
- 监控可观察直订阅成功率、首屏耗时、增量更新延迟和 fallback 比例。

10
.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

1
.idea/.name generated
View File

@@ -1 +0,0 @@
mod.rs

View File

@@ -1,59 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

248
.idea/editor.xml generated
View File

@@ -1,248 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="BackendCodeEditorSettings">
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CDeclarationWithImplicitIntType/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConstevalIfIsAlwaysConstant/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractClassWithoutSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractVirtualFunctionCallInCtor/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAccessSpecifierWithNoDeclarations/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAwaiterTypeIsNotClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBooleanIncrementExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatBadCode/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatLegacyCode/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatMixedArgs/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooFewArgs/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCompileTimeConstantCanBeReplacedWithBooleanConstant/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConceptNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConditionalExpressionCanBeSimplified/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstValueFunctionReturnType/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCoroutineCallResolveError/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantConditions/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFADeletedPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInvalidatedMemory/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesScope/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALoopConditionNotUpdated/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAMemoryLeak/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANotInitializedField/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANullDereference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesLocal/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesUncapturedLocal/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationSpecifierWithoutDeclarators/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorDisambiguatedAsFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorUsedBeforeInitialization/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultCaseNotHandledInSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedEntity/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedOverridenMethod/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedRegisterStorageClassSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDereferenceOperatorLimitExceeded/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDiscardedPostfixOperatorResult/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenSyntaxError/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUndocumentedParameter/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUnresolvedReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEmptyDeclaration/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersOrder/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersPlacement/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceDoStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceForStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceFunctionDeclarationStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceIfStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceNestedNamespacesStyle/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingDestructorStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingFunctionStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceTypeAliasCodeStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceWhileStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityAssignedButNoRead/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityUsedOnlyInUnevaluatedContext/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnumeratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEqualOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEvaluationFailure/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExplicitSpecializationInNonNamespaceScope/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExpressionWithoutSideEffects/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalFunctionInFinalClass/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalNonOverridingVirtualFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForLoopCanBeReplacedWithWhile/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForwardEnumDeclarationWithoutUnderlyingType/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionResultShouldBeUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionalStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHeaderHasBeenAlreadyIncluded/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHiddenFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHidingFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIdenticalOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIfCanBeReplacedByConstexprIf/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberInitializersOrder/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMismatchedClassTags/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingIncludeGuard/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingKeywordThrow/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppModulePartitionWithSeveralPartitionUnits/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtAddressOfClassRValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtBindingRValueToLvalueReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtCopyElisionInCopyInitDeclarator/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtDoubleUserConversionInCopyInit/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtNotInitializedStaticConstLocalVar/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtReinterpretCastFromNullptr/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterLiteral/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterWideLiteral/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMustBePublicVirtualToImplementInterface/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMutableSpecifierOnReferenceMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNoDiscardExpression/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNodiscardFunctionWithoutReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExceptionSafeResourceAcquisition/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConversionOperator/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConvertingConstructor/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineFunctionDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineVariableDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNotAllPathsReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppObjectMemberMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOutParameterMustBeWritten/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConstPtrOrRef/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNamesMismatch/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPassValueParameterByConstReference/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerConversionDropsQualifiers/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerToIntegralConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPolymorphicClassWithNonVirtualPublicDestructor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyErroneousEmptyStatements/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUnintendedObjectSlicing/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderIsNotIncluded/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderNotFound/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfBadFormat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfExtraArg/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfMissedArg/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfRiskyFormat/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrivateSpecialMemberFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRangeBasedForIncompatibleReference/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedefinitionOfDefaultArgumentInOverrideFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBooleanExpressionArgument/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantCastExpression/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantComplexityInComparison/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConditionalExpression/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConstSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantControlFlowJump/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantDereferencingAndTakingAddress/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElaboratedTypeSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeyword/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeywordInsideCompoundStatement/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyDeclaration/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyStatement/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantExportKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantFwdClassOrEnumSpecifier/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantInlineSpecifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantLambdaParameterList/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantMemberInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantNamespaceDefinition/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantParentheses/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifier/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifierADL/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnMemberAllocationFunction/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnThreadLocalLocalVariable/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateArguments/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantVoidArgumentList/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantZeroInitializerInAggregateInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReinterpretCastFromVoidPtr/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRemoveRedundantBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceMemsetWithZeroInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceTieWithStructuredBinding/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReturnNoValueInNonVoidFunction/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSmartPointerVsMakeFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSomeObjectMembersMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSpecialFunctionWithoutNoexceptSpecification/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticAssertFailure/@EntryIndexedValue" value="ERROR" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppThrowExpressionCanBeReplacedWithRethrow/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScope/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScopeInitStatement/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTypeAliasNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedDependentBaseClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedNonStaticDataMember/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnionMemberOfReferenceType/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAssociativeContains/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAuto/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAutoForNumeric/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseElementsView/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseEraseAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseFamiliarTemplateSyntaxForGenericLambdas/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseRangeAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStdSize/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStructuredBinding/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseTypeTraitAlias/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUserDefinedLiteralSuffixDoesNotStartWithUnderscore/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUsingResultOfAssignmentAsCondition/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVariableCanBeMadeConstexpr/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionCallInsideCtor/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionInFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVolatileParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWarningDirective/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongIncludesOrder/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongSlashesInIncludeDirective/@EntryIndexedValue" value="HINT" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroConstantCanBeReplacedWithNullptr/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroValuedExpressionUsedAsNullPointer/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml generated
View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Genarrative.iml" filepath="$PROJECT_DIR$/.idea/Genarrative.iml" />
</modules>
</component>
</project>

6
.idea/prettier.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

183
deploy/container/README.md Normal file
View File

@@ -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 :8082Linux release 构建,连接 compose 内 SpacetimeDB
├─ otelcol :4317/4318debug 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` 个 5xx200 请求平均 `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<AppStateInner>` 浅拷贝后,容器内直连 `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 MiBcgroup 峰值约 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。

View File

@@ -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

View File

@@ -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=

View File

@@ -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:

220
deploy/container/nginx.conf Normal file
View File

@@ -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;
}
}
}

View File

@@ -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]

View File

@@ -0,0 +1,23 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
exporters: [debug]
metrics:
receivers: [otlp]
exporters: [debug]
logs:
receivers: [otlp]
exporters: [debug]

View File

@@ -5,6 +5,21 @@ GENARRATIVE_ENV=production
GENARRATIVE_API_HOST=127.0.0.1 GENARRATIVE_API_HOST=127.0.0.1
GENARRATIVE_API_PORT=8082 GENARRATIVE_API_PORT=8082
GENARRATIVE_API_LOG=info,tower_http=info GENARRATIVE_API_LOG=info,tower_http=info
GENARRATIVE_API_LISTEN_BACKLOG=1024
GENARRATIVE_API_WORKER_THREADS=4
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
GENARRATIVE_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_USERNAME=
GENARRATIVE_ADMIN_PASSWORD= GENARRATIVE_ADMIN_PASSWORD=
@@ -79,9 +94,9 @@ SMS_AUTH_ENABLED=false
SMS_AUTH_PROVIDER=aliyun SMS_AUTH_PROVIDER=aliyun
ALIYUN_SMS_ACCESS_KEY_ID= ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET= ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME= ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
ALIYUN_SMS_TEMPLATE_CODE= ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_COUNTRY_CODE=86 ALIYUN_SMS_COUNTRY_CODE=86

View File

@@ -11,13 +11,18 @@
## Brotli ## Brotli
- Brotli 只在目标服务器 Nginx 接受 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 指令,不可用时保留注释说明。 - Provision 脚本通过临时配置执行 `nginx -t` 做能力探测;探测配置会先 `include /etc/nginx/modules-enabled/*.conf`,避免 Ubuntu 动态模块已安装但测试配置未加载模块导致误判。可用时把模板中的 `# __GENARRATIVE_BROTLI_DIRECTIVES__` 替换为 brotli 指令,不可用时保留注释说明。
- 不要直接在静态模板里无条件写 `brotli on;`,否则没有 brotli 模块的服务器会 `nginx -t` 失败并回滚。 - 不要直接在静态模板里无条件写 `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 ```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' \ curl -sSI -H 'Accept-Encoding: gzip' \
http://<host>/api/runtime/puzzle/gallery \ http://<host>/api/runtime/puzzle/gallery \
| grep -iE 'content-encoding|vary|content-type|content-length' | grep -iE 'content-encoding|vary|content-type|content-length'

View File

@@ -1,9 +1,32 @@
# 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。 # 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。
# 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。 # 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。
# 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。 # 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。
log_format genarrative_upstream
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
'upstream_status=$upstream_status request_id=$request_id';
upstream genarrative_api {
server 127.0.0.1:8082;
keepalive 64;
}
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
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 { server {
listen 80; listen 80;
server_name genarrative.example.com; server_name genarrative.example.com;
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
error_log /var/log/nginx/genarrative.error.log warn;
limit_conn_status 429;
limit_conn_log_level warn;
limit_req_status 429;
limit_req_log_level warn;
gzip on; gzip on;
gzip_vary on; gzip_vary on;
@@ -29,13 +52,16 @@ server {
location ^~ /admin/api/ { location ^~ /admin/api/ {
default_type application/json; default_type application/json;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_admin_rps burst=16 nodelay;
if ($genarrative_maintenance) { if ($genarrative_maintenance) {
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
} }
proxy_pass http://127.0.0.1:8082/admin/api/; proxy_pass http://genarrative_api/admin/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -65,20 +91,119 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# 临时兼容主站仍在使用的 /api/* HTTP facade前端完成 SpacetimeDB SDK 迁移后删除。 location = /api/runtime/puzzle/gallery {
location ~ ^/api(?:/|$) {
default_type application/json; default_type application/json;
limit_conn genarrative_api_conn 320;
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
if ($genarrative_maintenance) { if ($genarrative_maintenance) {
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
} }
proxy_pass http://127.0.0.1:8082; proxy_pass http://genarrative_api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
add_header X-Accel-Buffering no always; add_header X-Accel-Buffering no always;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -1,7 +1,30 @@
# 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。 # 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。
log_format genarrative_upstream
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
'upstream_status=$upstream_status request_id=$request_id';
upstream genarrative_api {
server 127.0.0.1:8082;
keepalive 64;
}
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
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 { server {
listen 80; listen 80;
server_name genarrative.example.com; server_name genarrative.example.com;
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
error_log /var/log/nginx/genarrative.error.log warn;
limit_conn_status 429;
limit_conn_log_level warn;
limit_req_status 429;
limit_req_log_level warn;
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/html; root /var/www/html;
@@ -15,6 +38,12 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name genarrative.example.com; server_name genarrative.example.com;
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
error_log /var/log/nginx/genarrative.error.log warn;
limit_conn_status 429;
limit_conn_log_level warn;
limit_req_status 429;
limit_req_log_level warn;
gzip on; gzip on;
gzip_vary on; gzip_vary on;
@@ -43,13 +72,16 @@ server {
location ^~ /admin/api/ { location ^~ /admin/api/ {
default_type application/json; default_type application/json;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_admin_rps burst=16 nodelay;
if ($genarrative_maintenance) { if ($genarrative_maintenance) {
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
} }
proxy_pass http://127.0.0.1:8082/admin/api/; proxy_pass http://genarrative_api/admin/api/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -79,20 +111,119 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# 临时兼容主站仍在使用的 /api/* HTTP facade前端完成 SpacetimeDB SDK 迁移后删除。 location = /api/runtime/puzzle/gallery {
location ~ ^/api(?:/|$) {
default_type application/json; default_type application/json;
limit_conn genarrative_api_conn 320;
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
if ($genarrative_maintenance) { if ($genarrative_maintenance) {
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
} }
proxy_pass http://127.0.0.1:8082; proxy_pass http://genarrative_api;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
add_header X-Accel-Buffering no always; add_header X-Accel-Buffering no always;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -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]

View File

@@ -15,6 +15,8 @@ Restart=always
RestartSec=5 RestartSec=5
KillSignal=SIGINT KillSignal=SIGINT
TimeoutStopSec=30 TimeoutStopSec=30
LimitNOFILE=65535
TasksMax=2048
# api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。 # api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。
NoNewPrivileges=true NoNewPrivileges=true

View File

@@ -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

View File

@@ -40,6 +40,12 @@ server-rs + Axum + SpacetimeDB
npm run check:server-rs-ddd npm run check:server-rs-ddd
``` ```
## `spacetime-client` mapper 组织
`server-rs/crates/spacetime-client/src/mapper.rs` 只作为聚合入口,负责声明 `src/mapper/` 下的领域子模块并 re-export 原有 record / mapper 能力;不要在该文件继续堆叠大段映射实现。
当前子模块按调用领域拆分:`assets.rs``auth.rs``runtime.rs``runtime_profile.rs``custom_world.rs``puzzle.rs``match3d.rs``square_hole.rs``visual_novel.rs``big_fish.rs``story.rs``ai.rs``bark_battle.rs``combat.rs``inventory.rs``npc.rs`,跨领域轻量 helper 和共享 record 统一放在 `common.rs`。该拆分只改变 `spacetime-client` 文件组织,不改变 SpacetimeDB schema、生成绑定、procedure result 契约或外部 DTO后续新增 mapper 时优先落到对应领域子模块,不得重新引入跨层 JSON 字符串兼容结构。
## API 路由分组 ## API 路由分组
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组: 路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
@@ -73,6 +79,33 @@ npm run check:server-rs-ddd
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。 2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。 3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
4. 大 handler 拆分时优先按 `router.rs``handlers.rs``application.rs``assets.rs``mapper.rs``errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response业务规则继续下沉到 `module-*` 4. 大 handler 拆分时优先按 `router.rs``handlers.rs``application.rs``assets.rs``mapper.rs``errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response业务规则继续下沉到 `module-*`
5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs``match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。
拼图 `api-server` 内部拆分:
- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit对外继续引用同一批 handler 名称。
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export不继续承载大段实现。
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper并返回 HTTP/SSE 响应。
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
抓大鹅 Match3D `api-server` 内部拆分:
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit对外 handler 名称保持不变。
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper并返回 HTTP 响应。
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品 sheet 生成、绿幕 / 近白底透明化、切图、append / replace / delete / sort / merge 和素材持久化。
- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 承接 VectorEngine Gemini 请求体、响应解析、base64 图片下载和上游错误归一。
- `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。
该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*``/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`
生成资产 Adapter 规则: 生成资产 Adapter 规则:
@@ -84,14 +117,19 @@ npm run check:server-rs-ddd
## SpacetimeDB schema 变更规则 ## SpacetimeDB schema 变更规则
1. 任何 table、reducer、procedure、row shape 或 bindings 变化,都必须同步 `server-rs/crates/spacetime-module/src/migration.rs`、本文件表目录和生成绑定 1. 任何 table、view、reducer、procedure、row shape 或 bindings 变化,都必须同步本文件表 / view 目录和生成绑定;真实 table 变化还必须同步 `server-rs/crates/spacetime-module/src/migration.rs`view 属于派生投影,不写入迁移导入导出表清单
2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]` 2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。 3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
4. Vec 字段不要直接写无法 const 求值的 default需要默认空集合时优先使用 `Option<Vec<T>>``#[default(None::<Vec<T>>)]`,业务层归一为空数组。 4. Vec 字段不要直接写无法 const 求值的 default需要默认空集合时优先使用 `Option<Vec<T>>``#[default(None::<Vec<T>>)]`,业务层归一为空数组。
5. 修改后运行: 5. 运行态读表必须按已声明索引访问。只要 table 上存在覆盖查询前缀的 `#[index(...)]` 或主键 / unique accessor列表、详情、快照组装和计数都先用对应 accessor `.filter(...)` / `.find(...)`,再在内存中处理索引无法覆盖的残余条件;不得用 `.iter().filter(...)` 扫整表替代现成索引。
6. 面向公开列表的只读投影优先做成 public view / public 读模型表,并由 `api-server``spacetime-client` 长期订阅后读本地 cache。短期不把作品列表整体交给浏览器前端直接订阅不要让 HTTP 列表接口每次请求都调用 procedure 重新组装全量列表。需要请求时间窗口的轻量统计可订阅公开统计表后在 `api-server` 本地聚合,需要写入副作用的详情、点赞、游玩记录仍可走 procedure / reducer。中期如要让前端可选直连订阅只能新增或统一稳定的专用 public read model例如 `public_work_gallery_entry`,并保持字段、排序键、公开权限和降级语义由后端投影定义;前端不得直接订阅 `puzzle_work_profile``custom_world_profile` 等领域源表,也不得自己做 join、聚合或权限逻辑。首屏、排序、字段归一、权限降级和 HTTP fallback 仍由 `api-server` BFF 维持。
7. 多列索引按 SpacetimeDB 绑定生成的元组参数直接传入,例如 `.filter((source_type, profile_id, played_day))`;前缀查询只传前缀元组,例如 `.filter((scope_kind, scope_id.as_str()))`。不要为了绕过类型问题退回整表遍历。
8. procedure result 必须返回 typed snapshot / typed value。`spacetime-client` mapper 不得再通过 `row_json/session_json/work_json/items_json/run_json/event_json/feedback_json: Option<String>` 做跨层 JSON 字符串传输,也不得在 mapper 里反序列化旧 `*JsonRecord` 兼容结构。业务内部持久化字段如 `profile_payload_json``levels_json` 等不属于 procedure result 载荷例外,仍按各自表契约处理。
9. 修改后运行:
```bash ```bash
npm run spacetime:generate npm run spacetime:generate
npm run check:spacetime-runtime-access
npm run check:spacetime-schema npm run check:spacetime-schema
npm run check:server-rs-ddd npm run check:server-rs-ddd
``` ```
@@ -222,7 +260,7 @@ npm run check:server-rs-ddd
### `battle_state` ### `battle_state`
- Rust 结构体:`BattleState` - Rust 结构体:`BattleState`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `big_fish_agent_message` ### `big_fish_agent_message`
@@ -238,6 +276,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`BigFishCreationSession` - Rust 结构体:`BigFishCreationSession`
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
- 索引:`by_big_fish_session_owner_user_id``by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。
### `big_fish_event` ### `big_fish_event`
@@ -249,10 +288,17 @@ npm run check:server-rs-ddd
- Rust 结构体:`BigFishRuntimeRun` - Rust 结构体:`BigFishRuntimeRun`
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
### SpacetimeDB view`big_fish_gallery_view`
- Rust view`big_fish_gallery_view`
- 返回类型:`Vec<BigFishWorkSummarySnapshot>`
- 源码:`server-rs/crates/spacetime-module/src/big_fish/session.rs`
- 说明:大鱼吃小鱼公开广场列表投影,只从 `Published` creation session 组装公开卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM big_fish_gallery_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'` 后,从本地 cache 构造 `/api/runtime/big-fish/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_big_fish_works` procedure个人作品列表、详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
### `chapter_progression` ### `chapter_progression`
- Rust 结构体:`ChapterProgression` - Rust 结构体:`ChapterProgression`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `creation_entry_config` ### `creation_entry_config`
@@ -267,37 +313,38 @@ npm run check:server-rs-ddd
### `custom_world_agent_message` ### `custom_world_agent_message`
- Rust 结构体:`CustomWorldAgentMessage` - Rust 结构体:`CustomWorldAgentMessage`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `custom_world_agent_operation` ### `custom_world_agent_operation`
- Rust 结构体:`CustomWorldAgentOperation` - Rust 结构体:`CustomWorldAgentOperation`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `custom_world_agent_session` ### `custom_world_agent_session`
- Rust 结构体:`CustomWorldAgentSession` - Rust 结构体:`CustomWorldAgentSession`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `custom_world_draft_card` ### `custom_world_draft_card`
- Rust 结构体:`CustomWorldDraftCard` - Rust 结构体:`CustomWorldDraftCard`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `custom_world_gallery_entry` ### `custom_world_gallery_entry`
- Rust 结构体:`CustomWorldGalleryEntry` - Rust 结构体:`CustomWorldGalleryEntry`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
- 作用:自定义世界公开作品列表读模型。`api-server``spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'``/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。
### `custom_world_profile` ### `custom_world_profile`
- Rust 结构体:`CustomWorldProfile` - Rust 结构体:`CustomWorldProfile`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `custom_world_session` ### `custom_world_session`
- Rust 结构体:`CustomWorldSession` - Rust 结构体:`CustomWorldSession`
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
### `database_migration_import_chunk` ### `database_migration_import_chunk`
@@ -312,7 +359,7 @@ npm run check:server-rs-ddd
### `inventory_slot` ### `inventory_slot`
- Rust 结构体:`InventorySlot` - Rust 结构体:`InventorySlot`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `match3d_agent_message` ### `match3d_agent_message`
@@ -334,15 +381,22 @@ npm run check:server-rs-ddd
- Rust 结构体:`Match3DWorkProfileRow` - Rust 结构体:`Match3DWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
### SpacetimeDB view`match_3_d_gallery_view`
- Rust view`match3d_gallery_view`
- 返回类型:`Vec<Match3DGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/match3d.rs`
- 说明:抓大鹅公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM match_3_d_gallery_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` 后,从本地 cache 构造 `/api/runtime/match3d/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_match3d_works` procedure个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
### `npc_state` ### `npc_state`
- Rust 结构体:`NpcState` - Rust 结构体:`NpcState`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `player_progression` ### `player_progression`
- Rust 结构体:`PlayerProgression` - Rust 结构体:`PlayerProgression`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `profile_dashboard_state` ### `profile_dashboard_state`
@@ -460,15 +514,64 @@ npm run check:server-rs-ddd
- Rust 结构体:`PuzzleWorkProfileRow` - Rust 结构体:`PuzzleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs` - 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
### SpacetimeDB view`puzzle_gallery_view`
- Rust view`puzzle_gallery_view`
- 返回类型:`Vec<PuzzleWorkProfile>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图广场公开详情兼容投影,只暴露 `publication_status = Published` 的作品,但返回完整 `PuzzleWorkProfile`,包含 levels / anchor_pack 等详情级字段;公开列表主路径不再订阅该 view。
### SpacetimeDB view`puzzle_gallery_card_view`
- Rust view`puzzle_gallery_card_view`
- 返回类型:`Vec<PuzzleGalleryCardViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
- 说明:拼图广场公开列表卡片投影,只暴露前端列表卡片需要的公开字段,不携带 levels / anchor_pack 等详情级载荷;`api-server``spacetime-client` 长期订阅 `SELECT * FROM puzzle_gallery_card_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'` 后,从本地 cache 构造 `/api/runtime/puzzle/gallery` 响应,并在本地按当前请求时间聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
### 拼图公开列表 HTTP 窗口缓存
- 接口:`GET /api/runtime/puzzle/gallery`
- 响应契约:保留 `items` 字段兼容旧前端;当前 `items` 只返回前 10 个完整卡片,新增 `previewRefs` 返回后 10 个 `workId/profileId` 引用,并返回 `hasMore``nextCursor``totalCount`
- 缓存策略:`api-server``PuzzleGalleryCache` 中缓存最终 `PuzzleGalleryResponse` 的预序列化 data JSON。缓存 miss / 过期时单飞重建避免并发请求重复排序、映射、DTO 深拷贝和 `serde_json::Value` 树构造;开启响应 envelope 时只按请求拼接轻量 meta缓存短 TTL 刷新 `recentPlayCount7d`,后台 cleanup task 周期清理超过最大空闲窗口的旧响应。OTLP 通过 `genarrative.puzzle_gallery.cache.*``genarrative.spacetime.read.*``genarrative.http.server.response_bodies.in_flight``genarrative.http.server.request_permits.available` 区分缓存重建、SpacetimeDB 本地订阅读、响应 body 生命周期和 HTTP 背压状态。
- 详情路径:公开详情、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理;前端拿到 `previewRefs` 后如果需要展开更多内容,应优先使用后续列表窗口能力或详情 cache不要把自动详情预取变成新的 procedure 热点。
### api-server 长期订阅读模型
`spacetime-client` 建立每个池连接时会等待下列订阅初始同步:
- `SELECT * FROM puzzle_gallery_card_view`
- `SELECT * FROM custom_world_gallery_entry`
- `SELECT * FROM match_3_d_gallery_view`
- `SELECT * FROM square_hole_gallery_view`
- `SELECT * FROM visual_novel_gallery_view`
- `SELECT * FROM big_fish_gallery_view`
下列订阅用于统计或配置缓存,订阅失败不会让公开列表连接整体不可用,调用方保留兼容兜底:
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
- `SELECT * FROM creation_entry_config`
- `SELECT * FROM creation_entry_type_config`
拼图、自定义世界、抓大鹅、方洞挑战、视觉小说和大鱼吃小鱼的公开列表 HTTP 路由都从订阅 cache 读取公开 read model / view。各玩法的个人作品列表、详情、发布、点赞、游玩记录、Remix 和其它需要鉴权或写入副作用的路径继续走 procedure / reducer不要为了公开列表性能把这些 owner-specific 或 mutation 语义混进 public view。
`GET /api/creation-entry/config` 和入口熔断优先从订阅 cache 读取创作入口配置cache 缺失时使用最近一次成功读取的内存快照,再兜底调用 `get_creation_entry_config` procedure 完成空库种子或旧库兼容。
未来可选:若发现页、推荐流和各玩法广场需要统一给浏览器前端直接订阅公开作品列表,只新增 / 统一专用 public read model例如 `public_work_gallery_entry`。该 read model 必须是后端投影后的公开作品卡片契约,覆盖作品类型、公开作品号、标题、摘要、封面、作者展示名、排序键、公开统计和入口开关后的可见性,不暴露玩法领域源表 row shape。前端可选择订阅这个稳定投影来减少 HTTP 拉取,但不能订阅 `puzzle_work_profile``custom_world_profile` 等源表后自行拼装列表BFF 仍保留首屏、SEO / 分享、旧客户端、订阅失败和灰度期间的 HTTP fallback。
### `quest_log` ### `quest_log`
- Rust 结构体:`QuestLog` - Rust 结构体:`QuestLog`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `quest_record` ### `quest_record`
- Rust 结构体:`QuestRecord` - Rust 结构体:`QuestRecord`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `refresh_session` ### `refresh_session`
@@ -505,30 +608,39 @@ npm run check:server-rs-ddd
- Rust 结构体:`SquareHoleWorkProfileRow` - Rust 结构体:`SquareHoleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs` - 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs`
### SpacetimeDB view`square_hole_gallery_view`
- Rust view`square_hole_gallery_view`
- 返回类型:`Vec<SquareHoleGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/square_hole.rs`
- 说明:方洞挑战公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server``spacetime-client` 长期订阅 `SELECT * FROM square_hole_gallery_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` 后,从本地 cache 构造 `/api/runtime/square-hole/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_square_hole_works` procedure个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。
### `story_event` ### `story_event`
- Rust 结构体:`StoryEvent` - Rust 结构体:`StoryEvent`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `story_session` ### `story_session`
- Rust 结构体:`StorySession` - Rust 结构体:`StorySession`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `tracking_daily_stat` ### `tracking_daily_stat`
- Rust 结构体:`TrackingDailyStat` - Rust 结构体:`TrackingDailyStat`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。
### `tracking_event` ### `tracking_event`
- Rust 结构体:`TrackingEvent` - Rust 结构体:`TrackingEvent`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` - 源码:`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` ### `treasure_record`
- Rust 结构体:`TreasureRecord` - Rust 结构体:`TreasureRecord`
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` - 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
### `user_account` ### `user_account`
@@ -569,3 +681,10 @@ npm run check:server-rs-ddd
- Rust 结构体:`VisualNovelWorkProfileRow` - Rust 结构体:`VisualNovelWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
### SpacetimeDB view`visual_novel_gallery_view`
- Rust view`visual_novel_gallery_view`
- 返回类型:`Vec<VisualNovelGalleryViewRow>`
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
- 说明:视觉小说公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;`api-server``spacetime-client` 长期订阅 `SELECT * FROM visual_novel_gallery_view``SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` 后,从本地 cache 构造 `/api/runtime/visual-novel/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_visual_novel_works` procedure个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。

View File

@@ -79,6 +79,8 @@ npm run lint
npm run check npm run check
``` ```
`npm run build``scripts/build-gate.mjs` 串行构建主站和后台;该门禁会把 Vite warning 当成失败处理。若看到 `Build gate failed because warnings were emitted`,先看 warning 原文,例如 chunk 体积超过 `vite.config.ts` / `apps/admin-web/vite.config.ts``chunkSizeWarningLimit`,不要先按 Rust 编译失败排查。
视觉小说负向扫描与验收门禁: 视觉小说负向扫描与验收门禁:
```bash ```bash
@@ -147,8 +149,53 @@ Nginx 负责站点和反向代理
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
``` ```
Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git不写入文档示例。 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git不写入文档示例。
`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` 个 5xx200 请求平均 `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` - `GENARRATIVE_SPACETIME_SERVER_URL`
@@ -164,9 +211,33 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- `WECHAT_*` - `WECHAT_*`
- `ALIYUN_OSS_*` - `ALIYUN_OSS_*`
### 手机验证码短信
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
生产默认短信配置:
```env
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
```
阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH``ALIYUN_SMS_CODE_TYPE``ALIYUN_SMS_RETURN_VERIFY_CODE``ALIYUN_SMS_CASE_AUTH_POLICY``ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun``SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。修改 `.env.local` 后必须重启 `api-server`,再用 `/api/auth/login-options` 确认返回包含 `phone`;如果通过 shell 临时覆盖PowerShell 使用 `$env:SMS_AUTH_ENABLED="true"`cmd 使用 `set SMS_AUTH_ENABLED=true`,不要把引号作为环境变量值的一部分传给进程。
如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行:
```powershell
$env:ALIYUN_SMS_ACCESS_KEY_ID="..."
$env:ALIYUN_SMS_ACCESS_KEY_SECRET="..."
$env:ALIYUN_SMS_REAL_TEST_PHONE_NUMBER="13800138000"
cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture
```
## 埋点与运营查询 ## 埋点与运营查询
用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入: 用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`高频 HTTP route tracking 不直接阻塞请求链路:`api-server` 将普通 route tracking 先写入本机 tracking outbox再由后台 worker 按数量或时间阈值批量写入 SpacetimeDB`daily_login`、作品游玩 `work_play_start`、付费、任务领奖和钱包相关关键事件继续同步直写数据库,避免用户任务进度、游玩统计或支付状态出现可感知延迟。任务配置、进度、领奖、钱包流水分别写入:
- `profile_task_config` - `profile_task_config`
- `profile_task_progress` - `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` 个人任务首版 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 ```sql

View File

@@ -8,13 +8,17 @@
当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。 当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`
## 草稿与作品架 ## 草稿与作品架
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。 2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon删除等破坏性动作继续收口到左滑或长按操作层。 3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon删除等破坏性动作继续收口到左滑或长按操作层。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。 4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取 5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
## 拼图 ## 拼图
@@ -27,9 +31,17 @@
当前口径: 当前口径:
- 图像输入复用 `CreativeImageInputPanel` - 图像输入复用 `CreativeImageInputPanel`
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。 - 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化
- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。 - 草稿生成会先持久化 `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 背景重生成只禁用 UI 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度。
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
@@ -51,7 +63,7 @@
难度映射: 难度映射:
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 | | 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
| --- | ---: | ---: | ---: | ---: | | ---- | ---------: | ---------: | -------: | -------: |
| 轻松 | 8 | 2 | 24 | 3 | | 轻松 | 8 | 2 | 24 | 3 |
| 标准 | 12 | 4 | 36 | 9 | | 标准 | 12 | 4 | 36 | 9 |
| 进阶 | 16 | 6 | 48 | 15 | | 进阶 | 16 | 6 | 48 | 15 |
@@ -60,15 +72,15 @@
当前素材生成流水线: 当前素材生成流水线:
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。 1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
2. 先写入可恢复草稿 profile再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic` 2. 先写入可恢复草稿 profile再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`草稿完成条件不包含 `backgroundMusic`
3. 物品素材不再调用 Hyper3D Rodin不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。 3. 物品素材不再调用 Hyper3D Rodin不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。 4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。 5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。 6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大``中``小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大``中``小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品且入库前必须合成为全画幅不透明图片不允许出现透明区域容器图走 `/v1/images/edits` 参考透明容器图。 8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品且入库前必须合成为全画幅不透明图片不允许出现透明区域容器图走 `/v1/images/edits` 参考透明容器图。
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材 10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器
结果页当前结构: 结果页当前结构:
@@ -81,15 +93,16 @@
运行态当前口径: 运行态当前口径:
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。 - 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。 - 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。 - 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。 - 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。 - 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。 - 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell` - 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`
- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。 - 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]``top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL只有资源源列表变化或换签失败后才允许进入兜底视觉。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL只有资源源列表变化或换签失败后才允许进入兜底视觉。
- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸`中``小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。 - `itemSize` 只缩放生成 2D 图片本体:`大``中``小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小`中``小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。 - 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
@@ -172,3 +185,4 @@
3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。 3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。
4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。 4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。
5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。 5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。
6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。

View File

@@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。 内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。
## 账户与登录
1. 主站登录弹窗必须稳定展示 `短信登录``密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
## 账户与充值 ## 账户与充值
1. “我的”页账户充值弹窗包含 `泥点充值``会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。 1. “我的”页账户充值弹窗包含 `泥点充值``会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。

View File

@@ -22,7 +22,8 @@ pipeline {
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit') string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名') 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: '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: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
@@ -31,6 +32,9 @@ pipeline {
string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口') string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口')
choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-httprelease 正式入口选 production-https') choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-httprelease 正式入口选 production-https')
booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务') booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务')
booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collectorapi-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 { stages {
@@ -60,8 +64,30 @@ pipeline {
} }
} }
} }
if (!params.SPACETIME_BIN_SOURCE?.trim()) { if (!params.PROVISION_TOOLS_DIR?.trim()) {
error('SPACETIME_BIN_SOURCE 不能为空。') 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() def nginxMode = params.NGINX_CONFIG_MODE?.trim()
if (!(nginxMode in ['none', 'production-https', 'development-http'])) { 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') { stage('Checkout Provision Files') {
agent { agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
@@ -109,7 +209,7 @@ pipeline {
set -euo pipefail set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ 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_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \
@@ -124,10 +224,20 @@ BASH
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}" label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
} }
steps { steps {
unstash 'server-provision-tools'
sh ''' sh '''
bash <<'BASH' bash <<'BASH'
set -euo pipefail 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 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 scripts/jenkins-server-provision.sh
BASH BASH
''' '''

View File

@@ -1,3 +1,24 @@
def runWindowsPowerShell(String scriptName, String scriptBody) {
def scriptPath = ".jenkins-${scriptName}.ps1"
writeFile file: scriptPath, text: scriptBody, encoding: 'UTF-8'
bat label: "PowerShell ${scriptName}", script: """
@echo off
setlocal
set "GENARRATIVE_POWERSHELL=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
if not exist "%GENARRATIVE_POWERSHELL%" (
echo [jenkins-powershell] powershell.exe not found: %GENARRATIVE_POWERSHELL%
exit /b 1
)
echo [jenkins-powershell] user:
whoami
echo [jenkins-powershell] exe: %GENARRATIVE_POWERSHELL%
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "\$path = '%CD%\\${scriptPath}'; \$text = [System.IO.File]::ReadAllText(\$path, [System.Text.Encoding]::UTF8); \$utf8Bom = New-Object System.Text.UTF8Encoding(\$true); [System.IO.File]::WriteAllText(\$path, \$text, \$utf8Bom)"
if errorlevel 1 exit /b %ERRORLEVEL%
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%CD%\\${scriptPath}"
exit /b %ERRORLEVEL%
"""
}
pipeline { pipeline {
agent { agent {
label 'windows' label 'windows'
@@ -45,23 +66,95 @@ pipeline {
], ],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
]) ])
powershell ''' script {
runWindowsPowerShell('stdb-checkout', '''
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' } $sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' }
$commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' } $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' } $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) { function Invoke-GitCommand {
git checkout --force $commitHash param(
} else { [string]$Label,
git checkout --force "origin/$sourceBranch" [string[]]$Arguments
)
Write-Host "[stdb-checkout] $Label"
& git @Arguments
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0) {
throw "[stdb-checkout] $Label failed with exit code $exitCode"
} }
git clean -ffdx }
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() $resolvedCommit = (git rev-parse HEAD).Trim()
$utf8NoBom = New-Object System.Text.UTF8Encoding $false $utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom) [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.jenkins-source-commit'), "$resolvedCommit`n", $utf8NoBom)
''' ''')
script {
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim() env.SOURCE_COMMIT = readFile('.jenkins-source-commit').replace('\uFEFF', '').trim()
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
} }
@@ -72,7 +165,7 @@ pipeline {
steps { steps {
script { script {
def buildStep = { def buildStep = {
powershell ''' runWindowsPowerShell('stdb-build', '''
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" } $workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
$env:CARGO_HOME = "$workspaceTmp/cargo-home" $env:CARGO_HOME = "$workspaceTmp/cargo-home"
@@ -110,6 +203,7 @@ pipeline {
} }
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION" npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
''' '''
)
} }
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) { if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
withCredentials([ withCredentials([

78
package-lock.json generated
View File

@@ -72,6 +72,7 @@
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1515,7 +1516,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -1528,7 +1528,6 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -1542,8 +1541,7 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "16.3.2", "version": "16.3.2",
@@ -1606,8 +1604,7 @@
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -1650,7 +1647,8 @@
"version": "4.3.20", "version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
"dev": true "dev": true,
"peer": true
}, },
"node_modules/@types/chai-subset": { "node_modules/@types/chai-subset": {
"version": "1.3.6", "version": "1.3.6",
@@ -1696,6 +1694,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1705,6 +1704,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -1796,6 +1796,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@@ -2126,6 +2127,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2216,7 +2218,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -2338,6 +2339,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2629,7 +2631,6 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -2685,8 +2686,7 @@
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true
"peer": true
}, },
"node_modules/domexception": { "node_modules/domexception": {
"version": "4.0.0", "version": "4.0.0",
@@ -2873,6 +2873,7 @@
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -3697,6 +3698,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"abab": "^2.0.6", "abab": "^2.0.6",
"cssstyle": "^3.0.0", "cssstyle": "^3.0.0",
@@ -4096,7 +4098,6 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -4435,6 +4436,7 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4486,6 +4488,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4619,6 +4622,7 @@
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -4627,6 +4631,7 @@
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -5074,6 +5079,7 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true, "devOptional": true,
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -5126,6 +5132,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -5207,6 +5214,7 @@
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -7027,6 +7035,7 @@
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"peer": true,
"requires": { "requires": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -7835,15 +7844,13 @@
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true
"peer": true
}, },
"pretty-format": { "pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -7854,8 +7861,7 @@
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true
"peer": true
} }
} }
}, },
@@ -7891,8 +7897,7 @@
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true
"peer": true
}, },
"@types/babel__core": { "@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -7935,7 +7940,8 @@
"version": "4.3.20", "version": "4.3.20",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
"dev": true "dev": true,
"peer": true
}, },
"@types/chai-subset": { "@types/chai-subset": {
"version": "1.3.6", "version": "1.3.6",
@@ -7978,6 +7984,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -7987,6 +7994,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"peer": true,
"requires": {} "requires": {}
}, },
"@types/semver": { "@types/semver": {
@@ -8053,6 +8061,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@@ -8263,7 +8272,8 @@
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true "dev": true,
"peer": true
}, },
"acorn-jsx": { "acorn-jsx": {
"version": "5.3.2", "version": "5.3.2",
@@ -8326,7 +8336,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -8396,6 +8405,7 @@
"version": "4.28.1", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"peer": true,
"requires": { "requires": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -8605,8 +8615,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true
"peer": true
}, },
"detect-libc": { "detect-libc": {
"version": "2.1.2", "version": "2.1.2",
@@ -8646,8 +8655,7 @@
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true
"peer": true
}, },
"domexception": { "domexception": {
"version": "4.0.0", "version": "4.0.0",
@@ -8782,6 +8790,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -9360,6 +9369,7 @@
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
"dev": true, "dev": true,
"peer": true,
"requires": { "requires": {
"abab": "^2.0.6", "abab": "^2.0.6",
"cssstyle": "^3.0.0", "cssstyle": "^3.0.0",
@@ -9566,8 +9576,7 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true
"peer": true
}, },
"magic-string": { "magic-string": {
"version": "0.30.21", "version": "0.30.21",
@@ -9813,7 +9822,8 @@
"picomatch": { "picomatch": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"peer": true
}, },
"pkg-types": { "pkg-types": {
"version": "1.3.1", "version": "1.3.1",
@@ -9843,6 +9853,7 @@
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"peer": true,
"requires": { "requires": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -9926,12 +9937,14 @@
"react": { "react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==" "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"peer": true
}, },
"react-dom": { "react-dom": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"peer": true,
"requires": { "requires": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
} }
@@ -10256,6 +10269,7 @@
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"devOptional": true, "devOptional": true,
"peer": true,
"requires": { "requires": {
"esbuild": "~0.27.0", "esbuild": "~0.27.0",
"fsevents": "~2.3.3", "fsevents": "~2.3.3",
@@ -10287,7 +10301,8 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true "dev": true,
"peer": true
}, },
"ufo": { "ufo": {
"version": "1.6.3", "version": "1.6.3",
@@ -10339,6 +10354,7 @@
"version": "6.4.1", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"peer": true,
"requires": { "requires": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View File

@@ -10,11 +10,14 @@
"dev:web": "node scripts/dev.mjs web", "dev:web": "node scripts/dev.mjs web",
"dev:admin-web": "node scripts/dev.mjs admin-web", "dev:admin-web": "node scripts/dev.mjs admin-web",
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"otel:debug": "node scripts/run-otelcol.mjs debug",
"otel:rider": "node scripts/run-otelcol.mjs rider",
"admin-web:build": "node scripts/admin-web-build.mjs build", "admin-web:build": "node scripts/admin-web-build.mjs build",
"admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck", "admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
"admin-web:preview": "npm --prefix apps/admin-web run preview --", "admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
"check:api-server-env": "node scripts/check-api-server-env.mjs", "check:api-server-env": "node scripts/check-api-server-env.mjs",
"check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs",
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh", "build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
@@ -29,7 +32,7 @@
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
"check:server-rs-ddd": "npm run check:spacetime-schema && node scripts/check-server-rs-ddd-boundaries.mjs", "check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && node scripts/check-server-rs-ddd-boundaries.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"lint:guardrails": "npm run lint:eslint", "lint:guardrails": "npm run lint:eslint",
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
@@ -42,6 +45,14 @@
"test:watch": "vitest", "test:watch": "vitest",
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs", "loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js", "loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js",
"container:init": "node scripts/container-compose.mjs init",
"container:build": "node scripts/container-compose.mjs build",
"container:up": "node scripts/container-compose.mjs up",
"container:down": "node scripts/container-compose.mjs down",
"container:logs": "node scripts/container-compose.mjs logs",
"container:ps": "node scripts/container-compose.mjs ps",
"container:config": "node scripts/container-compose.mjs config",
"container:k6": "node scripts/container-compose.mjs k6",
"check": "npm run lint && npm run test && npm run build && npm run check:content", "check": "npm run lint && npm run test && npm run build && npm run check:content",
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",

View File

@@ -5,6 +5,7 @@
import type { CreationAudioAsset } from './creationAudio'; import type { CreationAudioAsset } from './creationAudio';
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string;
export type Match3DGeneratedItemAssetStatus = export type Match3DGeneratedItemAssetStatus =
| 'pending' | 'pending'
@@ -163,6 +164,7 @@ export interface Match3DWorkSummary {
updatedAt: string; updatedAt: string;
publishedAt?: string | null; publishedAt?: string | null;
publishReady: boolean; publishReady: boolean;
generationStatus?: Match3DWorkGenerationStatus | null;
backgroundPrompt?: string | null; backgroundPrompt?: string | null;
backgroundImageSrc?: string | null; backgroundImageSrc?: string | null;
backgroundImageObjectKey?: string | null; backgroundImageObjectKey?: string | null;

View File

@@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest =
imageModel?: string | null; imageModel?: string | null;
aiRedraw?: boolean; aiRedraw?: boolean;
candidateCount?: number; candidateCount?: number;
shouldAutoNameLevel?: boolean;
workTitle?: string; workTitle?: string;
workDescription?: string; workDescription?: string;
summary?: string; summary?: string;

View File

@@ -2,6 +2,7 @@ import type { JsonObject } from './common';
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft'; import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
export type PuzzleWorkPublicationStatus = 'draft' | 'published'; export type PuzzleWorkPublicationStatus = 'draft' | 'published';
export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus'];
export interface PuzzleWorkSummary { export interface PuzzleWorkSummary {
workId: string; workId: string;
@@ -28,6 +29,7 @@ export interface PuzzleWorkSummary {
pointIncentiveTotalPoints?: number; pointIncentiveTotalPoints?: number;
pointIncentiveClaimablePoints?: number; pointIncentiveClaimablePoints?: number;
publishReady: boolean; publishReady: boolean;
generationStatus?: PuzzleWorkGenerationStatus | null;
levels?: PuzzleDraftLevel[]; levels?: PuzzleDraftLevel[];
} }
@@ -40,6 +42,19 @@ export interface PuzzleWorksResponse {
items: PuzzleWorkSummary[]; items: PuzzleWorkSummary[];
} }
export interface PuzzleGalleryWorkRef {
workId: string;
profileId: string;
}
export interface PuzzleGalleryResponse {
items: PuzzleWorkSummary[];
previewRefs?: PuzzleGalleryWorkRef[];
hasMore?: boolean;
nextCursor?: string | null;
totalCount?: number;
}
export interface PuzzleWorkDetailResponse { export interface PuzzleWorkDetailResponse {
item: PuzzleWorkProfile; item: PuzzleWorkProfile;
} }

View File

@@ -27,6 +27,10 @@ function printStatus(key, present) {
const env = mergeApiServerEnv(process.cwd(), process.env); const env = mergeApiServerEnv(process.cwd(), process.env);
const missing = []; const missing = [];
console.log('[api-server-env] 认证短信配置检查');
printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true');
printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER));
console.log('[api-server-env] 拼图真实生成配置检查'); console.log('[api-server-env] 拼图真实生成配置检查');
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) { for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
const present = hasValue(env[key]); const present = hasValue(env[key]);

View File

@@ -0,0 +1,221 @@
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
function readUtf8(relativePath) {
const absolute = path.join(repoRoot, relativePath);
if (!fs.existsSync(absolute)) {
failures.push(`${relativePath}: 文件不存在,无法执行 SpacetimeDB runtime access 检查`);
return null;
}
return fs.readFileSync(absolute, 'utf8');
}
const forbiddenSnippets = [
{
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.owner_user_id == input.owner_user_id)',
reason: 'puzzle_work_profile 已有 by_puzzle_work_owner_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.publication_status == PuzzlePublicationStatus::Published)',
reason: 'puzzle_work_profile 已有 by_puzzle_work_publication_status 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
snippet: '.puzzle_leaderboard_entry()\n .iter()\n .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)',
reason: 'puzzle_leaderboard_entry 已有 by_puzzle_leaderboard_profile_grid 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/match3d.rs',
snippet: '.match3d_work_profile()\n .iter()\n .filter(|row| {',
reason: 'match3d_work_profile 已有 owner/status 索引,列表不应整表过滤',
},
{
file: 'server-rs/crates/spacetime-module/src/visual_novel.rs',
snippet: '.visual_novel_work_profile()\n .iter()\n .filter(|row| {',
reason: 'visual_novel_work_profile 已有 owner/status 索引,列表不应整表过滤',
},
{
file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs',
snippet: '.asset_object()\n .iter()\n .find(|row| row.bucket == input.bucket && row.object_key == input.object_key)',
reason: 'asset_object 已有 by_bucket_object_key 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs',
snippet: '.asset_object()\n .iter()\n .filter(|row| row.asset_kind == asset_kind)',
reason: 'asset_object 已有 asset_kind 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/ai/stages.rs',
snippet: '.ai_task_stage()\n .iter()\n .filter(|row| row.task_id == task_id)',
reason: 'ai_task_stage 已有 by_ai_task_stage_task_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/ai/stages.rs',
snippet: '.ai_text_chunk()\n .iter()\n .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)',
reason: 'ai_text_chunk 已有 by_ai_text_chunk_task_id / by_ai_text_chunk_task_stage_sequence 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs',
snippet: '.ai_task_stage()\n .iter()\n .filter(|stage| stage.task_id == row.task_id)',
reason: 'ai_task_stage 快照组装应使用 by_ai_task_stage_task_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs',
snippet: '.ai_result_reference()\n .iter()\n .filter(|reference| reference.task_id == row.task_id)',
reason: 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)',
reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)',
reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {',
reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引analytics 查询不应整表过滤',
},
{
file: 'server-rs/crates/spacetime-module/src/custom_world.rs',
snippet: '.custom_world_profile()\n .iter()\n .find(|row| {',
reason: 'custom_world_profile owner 维度已有 by_custom_world_profile_owner_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/custom_world.rs',
snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {',
reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引',
},
];
const procedureResultFiles = [
'server-rs/crates/module-puzzle/src/application.rs',
'server-rs/crates/module-big-fish/src/domain.rs',
'server-rs/crates/spacetime-module/src/match3d/types.rs',
'server-rs/crates/spacetime-module/src/square_hole/types.rs',
'server-rs/crates/spacetime-module/src/visual_novel.rs',
'server-rs/crates/spacetime-module/src/bark_battle/types.rs',
];
const mapperCompatibilityFiles = [
'server-rs/crates/spacetime-client/src/mapper.rs',
'server-rs/crates/spacetime-client/src/lib.rs',
];
const bigFishRuntimeFiles = [
'server-rs/crates/module-big-fish/src/commands.rs',
'server-rs/crates/spacetime-module/src/big_fish/runtime.rs',
'server-rs/crates/spacetime-module/src/big_fish/session.rs',
];
const legacyMapperPatterns = [
{
pattern: /\b[A-Za-z0-9_]*JsonRecord\b/u,
reason: 'spacetime-client mapper 不应保留旧 ProcedureResult JSON 兼容 Record',
},
{
pattern: /\bCompatibleBigFish[A-Za-z0-9_]*\b/u,
reason: 'spacetime-client mapper 不应保留 BigFish 旧 JSON 兼容结构',
},
{
pattern: /\bmap_[A-Za-z0-9_]*_json\b/u,
reason: 'spacetime-client mapper 不应再通过 map_*_json 反序列化 procedure payload',
},
{
pattern: /serde_json::from_str::<[A-Za-z0-9_:]*JsonRecord/u,
reason: 'spacetime-client mapper 不应把 procedure result 再反序列化为 JsonRecord',
},
{
pattern: /\b(?:items|run|work|session|event|feedback)_json:\s*Some\(/u,
reason: 'mapper 测试与兼容路径不应再构造旧 procedure JSON 字符串字段',
},
];
const typedProcedurePayloadFieldPattern =
/\b(?:row|session|work|item|items|run|event|feedback)_json:\s*Option<String>/gu;
const failures = [];
for (const rule of forbiddenSnippets) {
const content = readUtf8(rule.file);
if (content === null) {
continue;
}
if (content.includes(rule.snippet)) {
failures.push(`${rule.file}: ${rule.reason}`);
}
}
for (const file of procedureResultFiles) {
const content = readUtf8(file);
if (content === null) {
continue;
}
const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? [];
for (const block of resultBlocks) {
const jsonFields = block.match(typedProcedurePayloadFieldPattern);
if (jsonFields?.length) {
const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult';
failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`);
}
}
}
for (const file of mapperCompatibilityFiles) {
const content = readUtf8(file);
if (content === null) {
continue;
}
for (const rule of legacyMapperPatterns) {
if (rule.pattern.test(content)) {
failures.push(`${file}: ${rule.reason}`);
}
}
}
for (const file of bigFishRuntimeFiles) {
const content = readUtf8(file);
if (content === null) {
continue;
}
const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? [];
for (const block of resultBlocks) {
const jsonFields = block.match(typedProcedurePayloadFieldPattern);
if (jsonFields?.length) {
const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult';
failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`);
}
}
}
if (failures.length > 0) {
console.error('SpacetimeDB runtime access 检查失败:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('SpacetimeDB runtime access 检查通过。');

View File

@@ -0,0 +1,99 @@
import {spawn} from 'node:child_process';
import {copyFileSync, existsSync} from 'node:fs';
import path from 'node:path';
const [, , rawCommand = 'help', ...args] = process.argv;
const command = rawCommand.trim();
const printComposeConfig = args.includes('--print');
const passthroughArgs = args.filter((arg) => arg !== '--print');
const projectRoot = process.cwd();
const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml');
const envExamplePath = path.join('deploy', 'container', 'api-server.env.example');
const envPath = path.join('deploy', 'container', 'api-server.env');
const supportedCommands = new Set(['init', 'build', 'up', 'down', 'logs', 'ps', 'config', 'k6']);
if (command === 'help' || !supportedCommands.has(command)) {
printHelp(command !== 'help');
process.exit(command === 'help' ? 0 : 1);
}
if (command === 'init') {
ensureEnvFile();
process.exit(0);
}
if (!existsSync(envPath)) {
ensureEnvFile();
console.error('[container] 请先检查 deploy/container/api-server.env 中的 SpacetimeDB 地址、库名和 token。');
process.exit(1);
}
const composeArgs = buildComposeArgs(command, passthroughArgs);
const child = spawn('docker', composeArgs, {
cwd: projectRoot,
env: process.env,
stdio: 'inherit',
shell: false,
});
child.on('error', (error) => {
console.error(`[container] docker compose 启动失败: ${error.message}`);
console.error('[container] 请确认 Docker Desktop 或 Docker Engine 已安装,并且 docker 在 PATH 中。');
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.error(`[container] docker compose 被信号终止: ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});
function buildComposeArgs(selectedCommand, extraArgs) {
const baseArgs = ['compose', '-f', composeFile];
switch (selectedCommand) {
case 'build':
return [...baseArgs, 'build', ...extraArgs];
case 'up':
return [...baseArgs, 'up', '-d', ...extraArgs];
case 'down':
return [...baseArgs, 'down', ...extraArgs];
case 'logs':
return [...baseArgs, 'logs', ...extraArgs];
case 'ps':
return [...baseArgs, 'ps', ...extraArgs];
case 'config':
return [...baseArgs, 'config', ...(printComposeConfig ? [] : ['--quiet']), ...extraArgs];
case 'k6':
return [...baseArgs, '--profile', 'loadtest', 'run', '--rm', 'k6', ...extraArgs];
default:
throw new Error(`unsupported command: ${selectedCommand}`);
}
}
function ensureEnvFile() {
if (existsSync(envPath)) {
console.log(`[container] 已存在 ${envPath}`);
return;
}
copyFileSync(envExamplePath, envPath);
console.log(`[container] 已从 ${envExamplePath} 生成 ${envPath}`);
}
function printHelp(isError) {
const output = isError ? console.error : console.log;
output(`Usage: npm run container:<command> -- [docker compose args]
Commands:
container:init 生成 deploy/container/api-server.env
container:build 构建 api-server 容器镜像
container:up 后台启动 spacetimedb + api-server + nginx + otelcol
container:down 停止并清理容器
container:logs 查看容器日志
container:ps 查看容器状态
container:config 校验 compose 配置,传 -- --print 可展开完整配置
container:k6 在 compose 网络内运行 k6
`);
}

View File

@@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs';
import {dirname, isAbsolute, resolve} from 'node:path'; import {dirname, isAbsolute, resolve} from 'node:path';
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local']; export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
const LOCAL_ENV_OVERRIDE_KEYS = new Set([
'SMS_AUTH_ENABLED',
'SMS_AUTH_PROVIDER',
'SMS_AUTH_MOCK_VERIFY_CODE',
'WECHAT_AUTH_ENABLED',
'WECHAT_AUTH_PROVIDER',
]);
export function buildProtectedEnvKeys(baseEnv) { export function buildProtectedEnvKeys(baseEnv) {
return new Set( return new Set(
@@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) {
} }
const [, key, rawValue] = match; const [, key, rawValue] = match;
if (protectedKeys.has(key)) { if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) {
continue; continue;
} }

View File

@@ -68,6 +68,26 @@ describe('dev utils env merge', () => {
); );
}); });
test('本地认证开关覆盖外层 shell 旧值', () => {
withTempEnvFiles(
{
'.env.local': [
'SMS_AUTH_ENABLED=true',
'SMS_AUTH_PROVIDER=aliyun',
].join('\n'),
},
(_env, tempDir) => {
const env = mergeApiServerEnv(tempDir, {
SMS_AUTH_ENABLED: 'false',
SMS_AUTH_PROVIDER: 'mock',
});
expect(env.SMS_AUTH_ENABLED).toBe('true');
expect(env.SMS_AUTH_PROVIDER).toBe('aliyun');
},
);
});
test('空外层 shell 变量不会遮蔽本地私密配置', () => { test('空外层 shell 变量不会遮蔽本地私密配置', () => {
withTempEnvFiles( withTempEnvFiles(
{ {

View File

@@ -21,6 +21,14 @@ const TARGETS = [
'src', 'src',
'module_bindings', 'module_bindings',
), ),
entryFile: path.join(
REPO_ROOT,
'server-rs',
'crates',
'spacetime-client',
'src',
'module_bindings.rs',
),
}, },
]; ];
@@ -64,6 +72,7 @@ for (const target of selectedTargets) {
console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`); console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
await replaceGeneratedDir(tempOutDir, target.outDir); await replaceGeneratedDir(tempOutDir, target.outDir);
await moveGeneratedEntryFile(target);
} }
await rm(tempRoot, {recursive: true, force: true}); await rm(tempRoot, {recursive: true, force: true});
@@ -111,6 +120,23 @@ async function replaceGeneratedDir(fromDir, toDir) {
} }
} }
async function moveGeneratedEntryFile(target) {
if (!target.entryFile) {
return;
}
assertInside(target.entryFile, REPO_ROOT, '生成入口文件');
const generatedModFile = path.join(target.outDir, 'mod.rs');
if (!existsSync(generatedModFile)) {
throw new Error(`${target.name} bindings 缺少入口文件: ${generatedModFile}`);
}
await rm(target.entryFile, {force: true});
await cp(generatedModFile, target.entryFile, {force: true});
await rm(generatedModFile, {force: true});
}
function assertInside(candidate, parent, label) { function assertInside(candidate, parent, label) {
const relative = path.relative(path.resolve(parent), path.resolve(candidate)); const relative = path.relative(path.resolve(parent), path.resolve(candidate));
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {

View File

@@ -1,6 +1,24 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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() { require_path() {
local path="$1" local path="$1"
if [[ ! -e "${path}" ]]; then if [[ ! -e "${path}" ]]; then
@@ -63,6 +81,15 @@ install_build_dependencies() {
fi 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() { install_sccache() {
for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
@@ -81,16 +108,16 @@ install_sccache() {
fi fi
echo "[server-provision] 未找到 sccache准备通过 cargo install sccache 安装。" 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 if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ cargo install sccache --locked" echo "+ cargo install sccache --locked"
return return
fi 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 cargo install sccache --locked
if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then
echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2 echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2
@@ -98,6 +125,42 @@ install_sccache() {
fi 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() { sync_spacetime_install() {
local root_dir="$1" local root_dir="$1"
local target_bin_dir="${root_dir}/bin/current" local target_bin_dir="${root_dir}/bin/current"
@@ -106,14 +169,6 @@ sync_spacetime_install() {
local resolved_command="${SPACETIME_BIN_SOURCE}" local resolved_command="${SPACETIME_BIN_SOURCE}"
local install_dir="" local install_dir=""
local root_bin="${root_dir}/bin" 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}" echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}"
if [[ "${DRY_RUN}" == "true" ]]; then if [[ "${DRY_RUN}" == "true" ]]; then
@@ -128,26 +183,10 @@ sync_spacetime_install() {
install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)"
mkdir -p "${root_bin}" 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 if [[ -d "${install_dir}/bin" ]]; then
echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}"
rm -rf "${root_bin}"
mkdir -p "${root_bin}"
cp -a "${install_dir}/bin/." "${root_bin}/" cp -a "${install_dir}/bin/." "${root_bin}/"
elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then
echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}" 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-cli" "${target_cli}"
cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}" cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}"
chmod +x "${target_cli}" "${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 else
echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2 echo "[server-provision] 未能从 SpacetimeDB 交付包推断完整安装目录: ${resolved_command}" >&2
fi
fi fi
if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then
@@ -387,6 +420,10 @@ render_api_env_example() {
deploy/env/api-server.env.example deploy/env/api-server.env.example
} }
render_otelcol_service() {
cat deploy/systemd/otelcol-contrib.service
}
validate_nginx_tls() { validate_nginx_tls() {
local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}" local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}"
if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then
@@ -523,6 +560,8 @@ render_api_service() {
require_path deploy/systemd/spacetimedb.service require_path deploy/systemd/spacetimedb.service
require_path deploy/systemd/genarrative-api.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.conf
require_path deploy/nginx/genarrative-dev-http.conf require_path deploy/nginx/genarrative-dev-http.conf
require_path deploy/nginx/snippets/genarrative-maintenance.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 require_path scripts/deploy/maintenance-status.sh
validate_server_names 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)" 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 run_cmd id
install_build_dependencies install_build_dependencies
install_nginx_brotli_modules
install_sccache 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 if ! id spacetimedb >/dev/null 2>&1; then
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
@@ -585,6 +626,16 @@ else
echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}" echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}"
fi 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 if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then
install_nginx_config_with_rollback install_nginx_config_with_rollback
else else
@@ -593,7 +644,13 @@ fi
run_cmd systemctl daemon-reload run_cmd systemctl daemon-reload
if [[ "${ENABLE_SERVICES}" == "true" ]]; then 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 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 run_cmd systemctl restart spacetimedb.service
wait_for_spacetimedb_service wait_for_spacetimedb_service
ensure_spacetime_owner_client_token ensure_spacetime_owner_client_token

View File

@@ -113,6 +113,17 @@ $env:WORKS_DATA="data/works-list.local.json"
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max" npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
``` ```
## 50 HTTP req/s 口径
`k6-works-list.js` 默认一次 iteration 会依次请求两个公开列表接口:`/api/runtime/puzzle/gallery``/api/runtime/custom-world-gallery`。因此目标约 50 HTTP req/s 时,`ramping-arrival-rate``PEAK_RPS` 应设置为 `25`。如果传入 `AUTH_TOKEN` 或把 `DETAIL_RATIO` 设为大于 0每次 iteration 的请求数会增加,需要重新折算。
验收目标:
- `http_req_failed < 1%`
- `http_req_duration p95 < 2000ms`
- `dropped_iterations = 0`
- 压测窗口内 Nginx 无新增 502
## Smoke ## Smoke
```bash ```bash
@@ -151,17 +162,38 @@ BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \ WORKS_DATA=data/works-list.local.json \
SCENARIO=spike \ SCENARIO=spike \
START_RPS=5 \ START_RPS=5 \
PEAK_RPS=100 \ PEAK_RPS=25 \
HOLD=2m \ HOLD=60s \
DETAIL_RATIO=0 \ DETAIL_RATIO=0 \
npm run loadtest:k6:works npm run loadtest:k6:works
``` ```
默认阈值: 默认阈值:
- `http_req_failed < 5%` - `http_req_failed < 1%`
- `http_req_duration p95 < 2000ms` - `http_req_duration p95 < 2000ms`
- `works_list_shape_error_rate < 5%` - `dropped_iterations = 0`
- `works_list_shape_error_rate < 1%`
PowerShell
```powershell
$env:BASE_URL="https://genarrative.world"
$env:WORKS_DATA="data/works-list.local.json"
$env:SCENARIO="spike"
$env:START_RPS="5"
$env:PEAK_RPS="25"
$env:HOLD="60s"
$env:END_RPS="5"
$env:DETAIL_RATIO="0"
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
```
线上 release 回归可使用同一组环境变量:
```bash
SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works
```
## 带登录态压测个人作品列表 ## 带登录态压测个人作品列表
@@ -194,9 +226,123 @@ npm run loadtest:k6:works
## 排障 ## 排障
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503检查本地 creation entry 配置是否禁用了对应入口。 - 如果公开 gallery 返回 `creation_entry_disabled` 或 503检查本地 creation entry 配置是否禁用了对应入口。
- 如果高压下返回 429优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS` 以及 `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。 - 如果个人作品列表返回 401确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
- 如果详情全部 404确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。 - 如果详情全部 404确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
## 压测窗口采集
Nginx upstream timing
```bash
sudo tail -f /var/log/nginx/genarrative.access.log
sudo tail -f /var/log/nginx/genarrative.error.log
```
api-server 与 SpacetimeDB 日志:
```bash
sudo journalctl -u genarrative-api.service -f
sudo journalctl -u spacetimedb.service -f
```
api-server 的 OpenTelemetry 在生产与容器模板里默认开启。需要临时关闭时,显式把 `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 ```bash

View File

@@ -0,0 +1,218 @@
{
"source": "spacetime-migration-1.json",
"generatedAt": "2026-05-16T13:35:40.282Z",
"counts": {
"puzzle_work_profile": 3,
"custom_world_profile": 1,
"match3d_work_profile": 0,
"square_hole_work_profile": 0,
"visual_novel_work_profile": 0
},
"tables": {
"puzzle_work_profile": [
{
"profile_id": "profile-001",
"work_id": "work-001",
"owner_user_id": "user-001",
"author_display_name": "author-001",
"cover_asset_id": "asset-001",
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
"work_title": "化学家",
"level_name": "文学家",
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
"publication_status": {
"Published": []
},
"play_count": 1,
"like_count": 0,
"remix_count": 1,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777703338322544
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777648804043558
},
"published_at": {
"__timestamp_micros_since_unix_epoch__": 1777649364112270
}
},
{
"profile_id": "profile-002",
"work_id": "work-002",
"owner_user_id": "user-002",
"author_display_name": "author-002",
"work_title": "我不知道",
"level_name": "",
"summary": "你猜我是谁",
"work_description": "你猜我是谁",
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
"theme_tags_json": "[\"我不知道\"]",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777619351714201
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777619336673245
}
},
{
"profile_id": "profile-003",
"work_id": "work-003",
"owner_user_id": "user-003",
"author_display_name": "author-002",
"work_title": "",
"level_name": "",
"summary": "",
"work_description": "",
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
}
}
],
"custom_world_profile": [
{
"profile_id": "profile-081",
"owner_user_id": "user-002",
"author_display_name": "author-012",
"author_public_user_code": "author-code-001",
"world_name": "青春飞扬校园",
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
"subtitle": "反内卷的自由学习之旅",
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
"publication_status": {
"Draft": []
},
"play_count": 0,
"like_count": 0,
"remix_count": 0,
"updated_at": {
"__timestamp_micros_since_unix_epoch__": 1777532006629209
},
"created_at": {
"__timestamp_micros_since_unix_epoch__": 1777531745887256
}
}
],
"match3d_work_profile": [],
"square_hole_work_profile": [],
"visual_novel_work_profile": []
},
"profileIds": {
"puzzle": [
"profile-001",
"profile-002",
"profile-003"
],
"customWorld": [
"profile-081"
],
"match3d": [],
"squareHole": [],
"bigFish": [],
"visualNovel": []
},
"workIds": {
"puzzle": [
"work-001",
"work-002",
"work-003"
],
"customWorld": [],
"match3d": [],
"squareHole": [],
"bigFish": [],
"visualNovel": []
},
"normalizedWorks": [
{
"type": "puzzle",
"workId": "work-001",
"profileId": "profile-001",
"ownerUserId": "user-001",
"title": "化学家",
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
"publicationStatus": {
"Published": []
},
"playCount": 1,
"likeCount": 0,
"remixCount": 1,
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777703338322544
}
},
{
"type": "puzzle",
"workId": "work-002",
"profileId": "profile-002",
"ownerUserId": "user-002",
"title": "我不知道",
"subtitle": "你猜我是谁",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777619351714201
}
},
{
"type": "puzzle",
"workId": "work-003",
"profileId": "profile-003",
"ownerUserId": "user-003",
"title": "",
"subtitle": "",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777622285252380
}
},
{
"type": "customWorld",
"profileId": "profile-081",
"ownerUserId": "user-002",
"title": "青春飞扬校园",
"subtitle": "反内卷的自由学习之旅",
"publicationStatus": {
"Draft": []
},
"playCount": 0,
"likeCount": 0,
"remixCount": 0,
"updatedAt": {
"__timestamp_micros_since_unix_epoch__": 1777532006629209
}
}
]
}

View File

@@ -1,10 +1,12 @@
{ {
"source": "spacetime-migration-7.local.json", "source": "spacetime-migration-1.json",
"generatedAt": "2026-05-11T13:09:51.569Z", "generatedAt": "2026-05-18T11:54:04.280Z",
"counts": { "counts": {
"puzzle_work_profile": 3, "puzzle_work_profile": 3,
"custom_world_profile": 1, "custom_world_profile": 1,
"match3d_work_profile": 0 "match3d_work_profile": 0,
"square_hole_work_profile": 0,
"visual_novel_work_profile": 0
}, },
"tables": { "tables": {
"puzzle_work_profile": [ "puzzle_work_profile": [
@@ -113,7 +115,9 @@
} }
} }
], ],
"match3d_work_profile": [] "match3d_work_profile": [],
"square_hole_work_profile": [],
"visual_novel_work_profile": []
}, },
"profileIds": { "profileIds": {
"puzzle": [ "puzzle": [

View File

@@ -56,20 +56,22 @@ const scenarioOptions = {
scenarios: { scenarios: {
spike: { spike: {
executor: 'ramping-arrival-rate', executor: 'ramping-arrival-rate',
startRate: Number(__ENV.START_RPS || 5),
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50), preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
maxVUs: Number(__ENV.MAX_VUS || 200), maxVUs: Number(__ENV.MAX_VUS || 200),
timeUnit: '1s', timeUnit: '1s',
stages: [ stages: [
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' }, { target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.RAMP_UP || '30s' },
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' }, { target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.HOLD || '2m' },
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' }, { target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
], ],
}, },
}, },
thresholds: { thresholds: {
http_req_failed: ['rate<0.05'], http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<2000'], http_req_duration: ['p(95)<2000'],
works_list_shape_error_rate: ['rate<0.05'], dropped_iterations: ['count==0'],
works_list_shape_error_rate: ['rate<0.01'],
}, },
}, },
}; };
@@ -135,12 +137,12 @@ function unwrapPayload(json) {
} }
function hasCollection(payload, keys) { 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) { function firstCollection(payload, keys) {
for (const key of keys) { for (const key of keys) {
if (Array.isArray(payload?.[key])) return payload[key]; if (payload && Array.isArray(payload[key])) return payload[key];
} }
return []; return [];
} }
@@ -150,10 +152,11 @@ function hasListItemShape(payload, keys) {
if (collection.length === 0) return true; if (collection.length === 0) return true;
const item = collection[0]; const item = collection[0];
const hasId = Boolean( 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( 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; return hasId && hasTitle;
} }
@@ -211,7 +214,8 @@ function performDetailRequest() {
const payload = unwrapPayload(json); const payload = unwrapPayload(json);
const ok = check(response, { const ok = check(response, {
[`${endpoint.name} status is 200`]: (res) => res.status === 200, [`${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 }); worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
} }

View File

@@ -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" <<EOF
otelcol-contrib ${OTELCOL_VERSION} ${OTELCOL_SOURCE_DESCRIPTION}
spacetime installer ${SPACETIME_INSTALLER_URL}
spacetime download root ${SPACETIME_DOWNLOAD_ROOT}
EOF
echo "[prepare-provision-tools] 工具包已准备: ${PROVISION_TOOLS_DIR}"
find "${PROVISION_TOOLS_DIR}" -maxdepth 5 \( -type f -o -type l \) | sort
}
main "$@"

119
scripts/run-otelcol.mjs Normal file
View File

@@ -0,0 +1,119 @@
import {spawn} from 'node:child_process';
import {mkdirSync, writeFileSync} from 'node:fs';
import path from 'node:path';
const [, , rawMode = 'debug', ...args] = process.argv;
const mode = rawMode.trim();
const printConfigOnly = args.includes('--print-config');
const supportedModes = new Set(['debug', 'rider']);
if (!supportedModes.has(mode)) {
console.error('[otelcol] mode must be one of: debug, rider');
process.exit(1);
}
const otlpHttpEndpoint = readEnv('OTELCOL_OTLP_HTTP_ENDPOINT', '127.0.0.1:4318');
const otlpGrpcEndpoint = readEnv('OTELCOL_OTLP_GRPC_ENDPOINT', '127.0.0.1:4317');
const riderEndpoint = readEnv('RIDER_OTLP_GRPC_ENDPOINT', '127.0.0.1:17011');
const debugVerbosity = readEnv('OTELCOL_DEBUG_VERBOSITY', 'detailed');
const otelcolBin = readEnv('OTELCOL_BIN', 'otelcol-contrib');
const configText = buildConfig(mode);
const configDir = path.resolve('.codex-temp', 'otelcol');
const configPath = path.join(configDir, `genarrative-${mode}.yaml`);
mkdirSync(configDir, {recursive: true});
writeFileSync(configPath, configText, 'utf8');
console.log(`[otelcol] wrote ${configPath}`);
console.log(`[otelcol] receiving OTLP HTTP at http://${otlpHttpEndpoint}`);
console.log(`[otelcol] receiving OTLP gRPC at ${otlpGrpcEndpoint}`);
if (mode === 'rider') {
console.log(`[otelcol] forwarding traces/metrics/logs to Rider OTLP gRPC at ${riderEndpoint}`);
}
console.log(
'[otelcol] api-server env: GENARRATIVE_OTEL_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318'
);
if (printConfigOnly) {
console.log(configText);
process.exit(0);
}
const child = spawn(otelcolBin, ['--config', configPath], {
cwd: process.cwd(),
env: process.env,
stdio: 'inherit',
});
const stopChild = () => {
if (!child.killed) {
child.kill();
}
};
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(signal, () => {
stopChild();
process.exit(130);
});
}
process.on('exit', stopChild);
child.on('error', (error) => {
console.error(`[otelcol] failed to start ${otelcolBin}: ${error.message}`);
console.error('[otelcol] install otelcol-contrib and make sure it is on PATH, or set OTELCOL_BIN.');
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.error(`[otelcol] exited by signal: ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});
function readEnv(key, fallback) {
const value = process.env[key]?.trim();
return value ? value : fallback;
}
function buildConfig(selectedMode) {
const exporters =
selectedMode === 'rider'
? ` otlp/rider:
endpoint: ${riderEndpoint}
tls:
insecure: true
debug:
verbosity: ${debugVerbosity}`
: ` debug:
verbosity: ${debugVerbosity}`;
const pipelineExporters = selectedMode === 'rider' ? '[otlp/rider, debug]' : '[debug]';
return `receivers:
otlp:
protocols:
grpc:
endpoint: ${otlpGrpcEndpoint}
http:
endpoint: ${otlpHttpEndpoint}
exporters:
${exporters}
service:
pipelines:
traces:
receivers: [otlp]
exporters: ${pipelineExporters}
metrics:
receivers: [otlp]
exporters: ${pipelineExporters}
logs:
receivers: [otlp]
exporters: ${pipelineExporters}
`;
}

188
server-rs/Cargo.lock generated
View File

@@ -105,6 +105,7 @@ dependencies = [
"module-square-hole", "module-square-hole",
"module-story", "module-story",
"module-visual-novel", "module-visual-novel",
"opentelemetry",
"platform-agent", "platform-agent",
"platform-auth", "platform-auth",
"platform-llm", "platform-llm",
@@ -118,6 +119,7 @@ dependencies = [
"shared-contracts", "shared-contracts",
"shared-kernel", "shared-kernel",
"shared-logging", "shared-logging",
"socket2 0.6.3",
"spacetime-client", "spacetime-client",
"time", "time",
"tokio", "tokio",
@@ -129,6 +131,7 @@ dependencies = [
"urlencoding", "urlencoding",
"uuid", "uuid",
"webp", "webp",
"windows-sys 0.61.2",
"zip", "zip",
] ]
@@ -1761,6 +1764,7 @@ dependencies = [
"platform-auth", "platform-auth",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"shared-kernel", "shared-kernel",
"time", "time",
"tokio", "tokio",
@@ -2070,6 +2074,90 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "opentelemetry"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "opentelemetry-appender-tracing"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
dependencies = [
"opentelemetry",
"tracing",
"tracing-core",
"tracing-opentelemetry",
"tracing-subscriber",
]
[[package]]
name = "opentelemetry-http"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
dependencies = [
"async-trait",
"bytes",
"http 1.4.0",
"opentelemetry",
"reqwest 0.12.28",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
dependencies = [
"http 1.4.0",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest 0.12.28",
"thiserror 2.0.18",
]
[[package]]
name = "opentelemetry-proto"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
"tonic",
"tonic-prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"rand 0.9.4",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -2151,6 +2239,26 @@ dependencies = [
"indexmap 2.14.0", "indexmap 2.14.0",
] ]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -2320,6 +2428,29 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "3.7.2" version = "3.7.2"
@@ -2622,6 +2753,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http 1.4.0", "http 1.4.0",
@@ -3036,6 +3168,12 @@ dependencies = [
name = "shared-logging" name = "shared-logging"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"opentelemetry",
"opentelemetry-appender-tracing",
"opentelemetry-otlp",
"opentelemetry_sdk",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -3130,6 +3268,7 @@ dependencies = [
"module-square-hole", "module-square-hole",
"module-story", "module-story",
"module-visual-novel", "module-visual-novel",
"opentelemetry",
"serde", "serde",
"serde_json", "serde_json",
"shared-contracts", "shared-contracts",
@@ -3137,6 +3276,7 @@ dependencies = [
"spacetimedb-sdk", "spacetimedb-sdk",
"time", "time",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -3807,6 +3947,38 @@ version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tonic"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-trait",
"base64 0.22.1",
"bytes",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"percent-encoding",
"pin-project",
"sync_wrapper 1.0.2",
"tokio-stream",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -3898,6 +4070,22 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-opentelemetry"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
dependencies = [
"js-sys",
"opentelemetry",
"smallvec",
"tracing",
"tracing-core",
"tracing-log",
"tracing-subscriber",
"web-time",
]
[[package]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"

View File

@@ -100,6 +100,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
sha2 = "0.10" sha2 = "0.10"
socket2 = "0.6"
spacetimedb = "2.2.0" spacetimedb = "2.2.0"
spacetimedb-sdk = "2.2.0" spacetimedb-sdk = "2.2.0"
spacetimedb-lib = { version = "2.2.0", default-features = false } spacetimedb-lib = { version = "2.2.0", default-features = false }
@@ -110,7 +111,13 @@ tokio-tungstenite = "0.27"
tower = "0.5" tower = "0.5"
tower-http = "0.6" tower-http = "0.6"
tracing = "0.1" tracing = "0.1"
opentelemetry = "0.31"
opentelemetry-appender-tracing = { version = "0.31", default-features = false, features = ["experimental_use_tracing_span_context"] }
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["http-proto", "reqwest-blocking-client", "trace", "metrics", "logs"] }
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics", "logs"] }
tracing-opentelemetry = { version = "0.32", default-features = false }
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
windows-sys = "0.61"
url = "2" url = "2"
urlencoding = "2" urlencoding = "2"
uuid = "1" uuid = "1"

View File

@@ -11,6 +11,7 @@ base64 = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
dotenvy = { workspace = true } dotenvy = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "webp"] } image = { workspace = true, features = ["jpeg", "png", "webp"] }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
webp = { workspace = true } webp = { workspace = true }
module-ai = { workspace = true } module-ai = { workspace = true }
@@ -43,18 +44,23 @@ sha2 = { workspace = true }
shared-contracts = { workspace = true, features = ["oss-contracts"] } shared-contracts = { workspace = true, features = ["oss-contracts"] }
shared-kernel = { workspace = true } shared-kernel = { workspace = true }
shared-logging = { workspace = true } shared-logging = { workspace = true }
socket2 = { workspace = true }
spacetime-client = { workspace = true } spacetime-client = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] }
tokio-stream = { workspace = true } tokio-stream = { workspace = true }
futures-util = { workspace = true } futures-util = { workspace = true }
time = { workspace = true, features = ["formatting"] } time = { workspace = true, features = ["formatting"] }
tower-http = { workspace = true, features = ["trace"] } tower-http = { workspace = true, features = ["trace"] }
tracing = { workspace = true } tracing = { workspace = true }
opentelemetry = { workspace = true }
url = { workspace = true } url = { workspace = true }
urlencoding = { workspace = true } urlencoding = { workspace = true }
uuid = { workspace = true, features = ["v4"] } uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate"] } zip = { workspace = true, features = ["deflate"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
[dev-dependencies] [dev-dependencies]
base64 = { workspace = true } base64 = { workspace = true }
hmac = { workspace = true } hmac = { workspace = true }

View File

@@ -1,4 +1,13 @@
use axum::Json; use std::convert::Infallible;
use axum::{
Json,
body::Body,
http::{HeaderValue, header},
response::{IntoResponse, Response},
};
use bytes::Bytes;
use futures_util::stream;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
#[cfg(test)] #[cfg(test)]
@@ -32,6 +41,30 @@ where
Json(serde_json::to_value(data).unwrap_or(Value::Null)) Json(serde_json::to_value(data).unwrap_or(Value::Null))
} }
pub fn json_success_data_bytes_response(
request_context: Option<&RequestContext>,
data_json: Bytes,
) -> Response {
if let Some(context) = request_context
&& context.wants_envelope()
{
let meta = serde_json::to_vec(&build_api_response_meta(Some(context)))
.map(Bytes::from)
.unwrap_or_else(|_| Bytes::from_static(b"null"));
let chunks = [
Bytes::from_static(b"{\"ok\":true,\"data\":"),
data_json,
Bytes::from_static(b",\"error\":null,\"meta\":"),
meta,
Bytes::from_static(b"}"),
];
let stream = stream::iter(chunks.into_iter().map(Ok::<Bytes, Infallible>));
return json_body_response(Body::from_stream(stream));
}
json_bytes_response(data_json)
}
pub fn json_error_body( pub fn json_error_body(
request_context: Option<&RequestContext>, request_context: Option<&RequestContext>,
error: &ApiErrorPayload, error: &ApiErrorPayload,
@@ -65,6 +98,19 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo
) )
} }
fn json_bytes_response(bytes: Bytes) -> Response {
json_body_response(Body::from(bytes))
}
fn json_body_response(body: Body) -> Response {
let mut response = body.into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
);
response
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -106,6 +152,31 @@ mod tests {
assert!(body.get("meta").is_none()); assert!(body.get("meta").is_none());
} }
#[tokio::test]
async fn success_response_streams_cached_data_inside_standard_envelope() {
use http_body_util::BodyExt;
let request_context = build_request_context(true);
let response = json_success_data_bytes_response(
Some(&request_context),
Bytes::from_static(br#"{"items":[]}"#),
);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value = serde_json::from_slice(&body).expect("body should be json");
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(payload["data"]["items"], Value::Array(Vec::new()));
assert_eq!(
payload["meta"]["requestId"],
Value::String("req-test".to_string())
);
}
#[test] #[test]
fn error_body_returns_legacy_shape_without_envelope_header() { fn error_body_returns_legacy_shape_without_envelope_header() {
let request_context = build_request_context(false); let request_context = build_request_context(false);

View File

@@ -1,7 +1,7 @@
use axum::{ use axum::{
Router, Router,
body::Body, body::Body,
extract::Extension, extract::{Extension, FromRef},
http::Request, http::Request,
middleware, middleware,
response::Response, response::Response,
@@ -11,17 +11,19 @@ use tower_http::{
classify::ServerErrorsFailureClass, classify::ServerErrorsFailureClass,
trace::{DefaultOnRequest, TraceLayer}, trace::{DefaultOnRequest, TraceLayer},
}; };
use tracing::{Level, Span, error, info, info_span, warn}; use tracing::{Level, Span, error, info_span};
use crate::{ use crate::{
auth::{AuthenticatedAccessToken, require_bearer_auth}, auth::{AuthenticatedAccessToken, require_bearer_auth},
backpressure::limit_concurrent_requests,
creation_entry_config::require_creation_entry_route_enabled, creation_entry_config::require_creation_entry_route_enabled,
error_middleware::normalize_error_response, error_middleware::normalize_error_response,
modules, modules,
request_context::{RequestContext, attach_request_context, resolve_request_id}, request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header, response_headers::propagate_request_id_header,
runtime_inventory::get_runtime_inventory_state, runtime_inventory::get_runtime_inventory_state,
state::AppState, state::{AppState, BackpressureState},
telemetry::record_http_observability,
tracking::record_route_tracking_event_after_success, tracking::record_route_tracking_event_after_success,
vector_engine_audio_generation::{ vector_engine_audio_generation::{
create_background_music_task, create_sound_effect_task, create_background_music_task, create_sound_effect_task,
@@ -42,8 +44,6 @@ use crate::{
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
Router::new() Router::new()
.merge(modules::admin::router(state.clone())) .merge(modules::admin::router(state.clone()))
.merge(modules::health::router(state.clone())) .merge(modules::health::router(state.clone()))
@@ -77,6 +77,11 @@ pub fn build_router(state: AppState) -> Router {
state.clone(), state.clone(),
require_creation_entry_route_enabled, require_creation_entry_route_enabled,
)) ))
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
.layer(middleware::from_fn_with_state(
BackpressureState::from_ref(&state),
limit_concurrent_requests,
))
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response)) .layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。 // 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
@@ -86,47 +91,55 @@ pub fn build_router(state: AppState) -> Router {
state.clone(), state.clone(),
record_api_tracking_after_success, record_api_tracking_after_success,
)) ))
// HTTP 指标与请求完成日志放在 tracing span 内侧,日志事件可以继承当前 trace/span context。
.layer(middleware::from_fn_with_state(
state.clone(),
record_http_observability,
))
// 当前阶段先统一挂接 HTTP tracing后续 request_id、响应头与错误中间件继续在这里扩展。 // 当前阶段先统一挂接 HTTP tracing后续 request_id、响应头与错误中间件继续在这里扩展。
.layer( .layer(
TraceLayer::new_for_http() TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| { .make_span_with(|request: &Request<Body>| {
let request_id = let request_id =
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string()); resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
let route = crate::telemetry::observability_route(request.uri().path());
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
let span_name = format!("{} {}", request.method(), route);
info_span!( info_span!(
"http.request", "http.request",
otel.kind = "server",
otel.name = %span_name,
otel.status_code = tracing::field::Empty,
http.response.status_code = tracing::field::Empty,
method = %request.method(), method = %request.method(),
uri = %request.uri(), http.request.method = %request.method(),
http.route = %route,
url.scheme = %scheme,
url.path = %request.uri().path(),
request_id = %request_id, request_id = %request_id,
status = tracing::field::Empty,
latency_ms = tracing::field::Empty,
) )
}) })
.on_request(DefaultOnRequest::new().level(Level::INFO)) .on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response( .on_response(
move |response: &axum::response::Response, |response: &axum::response::Response,
latency: std::time::Duration, latency: std::time::Duration,
span: &Span| { span: &Span| {
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64; let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
let status = response.status().as_u16(); let status = response.status().as_u16();
let slow_request = latency_ms >= slow_request_threshold_ms;
span.record("status", status); span.record("status", status);
span.record("latency_ms", latency_ms); span.record("http.response.status_code", status);
if slow_request { span.record(
warn!( "otel.status_code",
parent: span, if response.status().is_server_error() {
status, "ERROR"
latency_ms,
slow_request = true,
"http request completed slowly"
);
} else { } else {
info!( "OK"
parent: span, },
status,
latency_ms,
slow_request = false,
"http request completed"
); );
} span.record("latency_ms", latency_ms);
}, },
) )
.on_failure( .on_failure(

View File

@@ -752,10 +752,14 @@ mod tests {
}; };
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use reqwest::{Method, multipart}; use reqwest::{Method, multipart};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use shared_kernel::new_uuid_simple_string; use shared_kernel::new_uuid_simple_string;
use time::OffsetDateTime;
use tower::ServiceExt; use tower::ServiceExt;
use crate::{app::build_router, config::AppConfig, state::AppState}; use crate::{app::build_router, config::AppConfig, state::AppState};
@@ -873,13 +877,17 @@ mod tests {
..AppConfig::default() ..AppConfig::default()
}; };
let app = build_router(AppState::new(config).expect("state should build")); let state = AppState::new(config).expect("state should build");
let token =
seed_authenticated_token(&state, "13800138120", "sess_assets_direct_upload").await;
let app = build_router(state);
let response = app let response = app
.oneshot( .oneshot(
Request::builder() Request::builder()
.method("POST") .method("POST")
.uri("/api/assets/direct-upload-tickets") .uri("/api/assets/direct-upload-tickets")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json") .header("content-type", "application/json")
.header("x-request-id", "req-oss-ticket") .header("x-request-id", "req-oss-ticket")
.header("x-genarrative-response-envelope", "1") .header("x-genarrative-response-envelope", "1")
@@ -1693,6 +1701,33 @@ mod tests {
Ok(fields) Ok(fields)
} }
async fn seed_authenticated_token(
state: &AppState,
phone_number: &str,
session_seed: &str,
) -> String {
let user = state
.seed_test_phone_user_with_password(phone_number, "secret123")
.await;
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: state.seed_test_refresh_session_for_user(&user, session_seed),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
}
fn build_object_url( fn build_object_url(
config: &AppConfig, config: &AppConfig,
object_key: &str, object_key: &str,

View File

@@ -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<BackpressureState>,
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<HttpRequestPermitPool>,
) -> Result<HttpRequestPermitGuard, TryAcquireError> {
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<OwnedSemaphorePermit>,
permit_pool: Arc<HttpRequestPermitPool>,
}
impl Drop for HttpRequestPermitGuard {
fn drop(&mut self) {
drop(self.permit.take());
crate::telemetry::update_http_request_permits_available(
self.permit_pool_kind,
self.permit_pool.available_permits(),
);
}
}
fn reject_overloaded_request(request: &Request<Body>) -> Response {
let request_context = request.extensions().get::<RequestContext>().cloned();
let mut response = AppError::from_status(StatusCode::TOO_MANY_REQUESTS)
.with_message("服务繁忙,请稍后重试")
.into_response_with_context(request_context.as_ref());
response
.headers_mut()
.insert(RETRY_AFTER, HeaderValue::from_static("1"));
response
}
fn should_bypass_backpressure(request: &Request<Body>) -> bool {
request.uri().path() == "/healthz"
}
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<Notify>,
release: Arc<Notify>,
}
async fn held_request(Extension(gate): Extension<HeldRequestGate>) -> &'static str {
gate.entered.notify_one();
gate.release.notified().await;
"ok"
}
async fn fast_request() -> &'static str {
"ok"
}
fn test_request(path: &str) -> Request<Body> {
Request::builder()
.uri(path)
.body(Body::empty())
.expect("test request should build")
}
fn build_test_app(max_concurrent_requests: usize, gate: HeldRequestGate) -> Router {
let mut config = AppConfig::default();
config.max_concurrent_requests = Some(max_concurrent_requests);
let state = AppState::new(config).expect("state should build");
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
);
}
}

View File

@@ -20,7 +20,19 @@ pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000
pub struct AppConfig { pub struct AppConfig {
pub bind_host: String, pub bind_host: String,
pub bind_port: u16, pub bind_port: u16,
pub listen_backlog: i32,
pub worker_threads: Option<usize>,
pub max_concurrent_requests: Option<usize>,
pub gallery_max_concurrent_requests: Option<usize>,
pub detail_max_concurrent_requests: Option<usize>,
pub admin_max_concurrent_requests: Option<usize>,
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 log_filter: String,
pub otel_enabled: bool,
pub admin_username: Option<String>, pub admin_username: Option<String>,
pub admin_password: Option<String>, pub admin_password: Option<String>,
pub admin_token_ttl_seconds: u64, pub admin_token_ttl_seconds: u64,
@@ -147,7 +159,19 @@ impl Default for AppConfig {
Self { Self {
bind_host: "127.0.0.1".to_string(), bind_host: "127.0.0.1".to_string(),
bind_port: 3000, bind_port: 3000,
listen_backlog: 1024,
worker_threads: None,
max_concurrent_requests: None,
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(), log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
admin_username: None, admin_username: None,
admin_password: None, admin_password: None,
admin_token_ttl_seconds: 4 * 60 * 60, admin_token_ttl_seconds: 4 * 60 * 60,
@@ -164,11 +188,11 @@ impl Default for AppConfig {
dev_password_entry_auto_register_enabled: false, dev_password_entry_auto_register_enabled: false,
sms_auth_enabled: false, sms_auth_enabled: false,
sms_auth_provider: "mock".to_string(), sms_auth_provider: "mock".to_string(),
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), sms_endpoint: "dysmsapi.aliyuncs.com".to_string(),
sms_access_key_id: None, sms_access_key_id: None,
sms_access_key_secret: None, sms_access_key_secret: None,
sms_sign_name: "速通互联验证码".to_string(), sms_sign_name: "北京亓盒网络科技".to_string(),
sms_template_code: "100001".to_string(), sms_template_code: "SMS_506245486".to_string(),
sms_template_param_key: "code".to_string(), sms_template_param_key: "code".to_string(),
sms_country_code: "86".to_string(), sms_country_code: "86".to_string(),
sms_scheme_name: None, sms_scheme_name: None,
@@ -301,6 +325,57 @@ impl AppConfig {
{ {
config.log_filter = log_filter; config.log_filter = log_filter;
} }
if let Some(listen_backlog) =
read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"])
{
config.listen_backlog = listen_backlog;
}
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
config.worker_threads = Some(worker_threads);
}
if let Some(max_concurrent_requests) =
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
{
config.max_concurrent_requests = Some(max_concurrent_requests);
}
if let Some(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_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]); config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
@@ -881,6 +956,14 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
}) })
} }
fn read_first_positive_i32_env(keys: &[&str]) -> Option<i32> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_i32(&value))
})
}
fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> { fn read_first_positive_u64_env(keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| { keys.iter().find_map(|key| {
env::var(key) env::var(key)
@@ -946,6 +1029,16 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
} }
fn parse_bool(raw: &str) -> Option<bool> { fn parse_bool(raw: &str) -> Option<bool> {
let raw = raw.trim();
let raw = raw
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.or_else(|| {
raw.strip_prefix('\'')
.and_then(|value| value.strip_suffix('\''))
})
.unwrap_or(raw);
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true), "1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false), "0" | "false" | "no" | "off" => Some(false),
@@ -971,6 +1064,15 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
Some(value) Some(value)
} }
fn parse_positive_i32(raw: &str) -> Option<i32> {
let value = raw.trim().parse::<i32>().ok()?;
if value <= 0 {
return None;
}
Some(value)
}
fn parse_u32(raw: &str) -> Option<u32> { fn parse_u32(raw: &str) -> Option<u32> {
raw.trim().parse::<u32>().ok() raw.trim().parse::<u32>().ok()
} }
@@ -1012,7 +1114,9 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider}; use super::{
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new(); static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -1035,13 +1139,44 @@ mod tests {
config.dashscope_base_url, config.dashscope_base_url,
"https://dashscope.aliyuncs.com/api/v1" "https://dashscope.aliyuncs.com/api/v1"
); );
assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com"); assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com");
assert_eq!(config.sms_sign_name, "北京亓盒网络科技");
assert_eq!(config.sms_template_code, "SMS_506245486");
assert_eq!(config.sms_template_param_key, "code");
assert_eq!( assert_eq!(
config.wechat_authorize_endpoint, config.wechat_authorize_endpoint,
"https://open.weixin.qq.com/connect/qrconnect" "https://open.weixin.qq.com/connect/qrconnect"
); );
} }
#[test]
fn parse_bool_accepts_wrapped_quotes_from_shell_env() {
assert_eq!(parse_bool("\"true\""), Some(true));
assert_eq!(parse_bool("'true'"), Some(true));
assert_eq!(parse_bool("\"false\""), Some(false));
assert_eq!(parse_bool("'off'"), Some(false));
}
#[test]
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("SMS_AUTH_ENABLED");
std::env::set_var("SMS_AUTH_ENABLED", "\"true\"");
}
let config = AppConfig::from_env();
assert!(config.sms_auth_enabled);
unsafe {
std::env::remove_var("SMS_AUTH_ENABLED");
}
}
#[test] #[test]
fn from_env_reads_non_public_models_and_urls() { fn from_env_reads_non_public_models_and_urls() {
let _guard = ENV_LOCK let _guard = ENV_LOCK
@@ -1151,6 +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] #[test]
fn from_env_reads_wechat_pay_settings() { fn from_env_reads_wechat_pay_settings() {
let _guard = ENV_LOCK let _guard = ENV_LOCK

View File

@@ -13,6 +13,7 @@ mod auth_payload;
mod auth_public_user; mod auth_public_user;
mod auth_session; mod auth_session;
mod auth_sessions; mod auth_sessions;
mod backpressure;
mod bark_battle; mod bark_battle;
mod big_fish; mod big_fish;
mod big_fish_agent_turn; mod big_fish_agent_turn;
@@ -54,10 +55,12 @@ mod password_entry;
mod password_management; mod password_management;
mod phone_auth; mod phone_auth;
mod platform_errors; mod platform_errors;
mod process_metrics;
mod profile_identity; mod profile_identity;
mod prompt; mod prompt;
mod puzzle; mod puzzle;
mod puzzle_agent_turn; mod puzzle_agent_turn;
mod puzzle_gallery_cache;
mod refresh_session; mod refresh_session;
mod registration_reward; mod registration_reward;
mod request_context; mod request_context;
@@ -75,7 +78,9 @@ mod square_hole_agent_turn;
mod state; mod state;
mod story_battles; mod story_battles;
mod story_sessions; mod story_sessions;
mod telemetry;
mod tracking; mod tracking;
mod tracking_outbox;
mod vector_engine_audio_generation; mod vector_engine_audio_generation;
mod visual_novel; mod visual_novel;
mod volcengine_speech; mod volcengine_speech;
@@ -85,8 +90,15 @@ mod wechat_provider;
mod work_author; mod work_author;
mod work_play_tracking; mod work_play_tracking;
use shared_logging::init_tracing; use shared_logging::{OtelConfig, init_tracing};
use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration}; use socket2::{Domain, Protocol, Socket, Type};
use std::{
collections::HashSet,
env, fs, io,
net::{SocketAddr, TcpListener as StdTcpListener},
panic, thread,
time::Duration,
};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::time::timeout; use tokio::time::timeout;
@@ -103,12 +115,18 @@ fn main() -> Result<(), io::Error> {
.name("api-server-bootstrap".to_string()) .name("api-server-bootstrap".to_string())
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES) .stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.spawn(|| { .spawn(|| {
TokioRuntimeBuilder::new_multi_thread() load_local_env_files();
let config = AppConfig::from_env();
let mut runtime_builder = TokioRuntimeBuilder::new_multi_thread();
runtime_builder
.enable_all() .enable_all()
.thread_name("api-server-worker") .thread_name("api-server-worker")
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES) .thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES);
.build()? if let Some(worker_threads) = config.worker_threads {
.block_on(run_server()) runtime_builder.worker_threads(worker_threads);
}
runtime_builder.build()?.block_on(run_server(config))
})?; })?;
match server_thread.join() { match server_thread.join() {
@@ -117,28 +135,55 @@ fn main() -> Result<(), io::Error> {
} }
} }
async fn run_server() -> Result<(), io::Error> { async fn run_server(config: AppConfig) -> Result<(), io::Error> {
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。 init_tracing(
// 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。 &config.log_filter,
load_local_env_files(); OtelConfig {
enabled: config.otel_enabled,
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。 },
let config = AppConfig::from_env(); )?;
init_tracing(&config.log_filter)?; process_metrics::register_process_metrics();
telemetry::register_http_runtime_metrics();
let bind_address = config.bind_socket_addr(); let bind_address = config.bind_socket_addr();
let listener = TcpListener::bind(bind_address).await?; let listen_backlog = config.listen_backlog;
let worker_threads = config.worker_threads;
let otel_enabled = config.otel_enabled;
let listener = build_tcp_listener(bind_address, listen_backlog)?;
let state = restore_app_state_for_startup(config) let state = restore_app_state_for_startup(config)
.await .await
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?; .map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
state.puzzle_gallery_cache().spawn_cleanup_task();
if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker();
}
let router = build_router(state); let router = build_router(state);
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听"); info!(
%bind_address,
listen_backlog,
worker_threads = worker_threads.unwrap_or(0),
otel_enabled,
"api-server 已完成 tracing 初始化并开始监听"
);
axum::serve(listener, router).await axum::serve(listener, router).await
} }
fn build_tcp_listener(
bind_address: SocketAddr,
listen_backlog: i32,
) -> Result<TcpListener, io::Error> {
let domain = Domain::for_address(bind_address);
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&bind_address.into())?;
socket.listen(listen_backlog)?;
TcpListener::from_std(StdTcpListener::from(socket))
}
async fn restore_app_state_for_startup( async fn restore_app_state_for_startup(
config: AppConfig, config: AppConfig,
) -> Result<AppState, state::AppStateInitError> { ) -> Result<AppState, state::AppStateInitError> {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,881 @@
use super::*;
pub(super) async fn submit_and_finalize_match3d_message(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
session_id: String,
payload: SendMatch3DAgentMessageRequest,
) -> Result<Match3DAgentSessionRecord, Response> {
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&session_id,
"sessionId",
)?;
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&payload.client_message_id,
"clientMessageId",
)?;
ensure_non_empty(
request_context,
MATCH3D_AGENT_PROVIDER,
&payload.text,
"text",
)?;
let submitted = state
.spacetime_client()
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.to_string(),
user_message_id: payload.client_message_id.clone(),
user_message_text: payload.text.clone(),
submitted_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
let next_turn = submitted.current_turn.saturating_add(1);
let next_config = build_config_from_message(&submitted, &payload);
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
let progress_percent = resolve_progress_percent_for_turn(next_turn);
let stage = if progress_percent >= 100 {
"ReadyToCompile"
} else {
"Collecting"
}
.to_string();
state
.spacetime_client()
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
session_id,
owner_user_id: owner_user_id.to_string(),
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
assistant_reply_text: Some(assistant_reply),
config_json: serialize_match3d_config(&next_config),
progress_percent,
stage,
updated_at_micros: current_utc_micros(),
error_message: None,
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
}
pub(super) async fn load_match3d_agent_session_response_with_persisted_assets(
state: &AppState,
owner_user_id: &str,
session: Match3DAgentSessionRecord,
) -> Match3DAgentSessionSnapshotResponse {
let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else {
return map_match3d_agent_session_response(session);
};
let assets =
get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await;
map_match3d_agent_session_response_with_assets(session, &assets)
}
fn resolve_match3d_session_existing_profile_id(
session: &Match3DAgentSessionRecord,
) -> Option<String> {
session
.draft
.as_ref()
.map(|draft| draft.profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.or_else(|| {
session
.published_profile_id
.as_deref()
.map(str::trim)
.filter(|profile_id| !profile_id.is_empty())
})
.map(str::to_string)
}
pub(super) async fn compile_match3d_draft_for_session(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
game_name: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
cover_image_src: Option<String>,
generate_click_sound: Option<bool>,
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
let owner_user_id = authenticated.claims().user_id().to_string();
let initial_session = state
.spacetime_client()
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})?;
let mut config = resolve_config_or_default(initial_session.config.as_ref());
if let Some(generate_click_sound) = generate_click_sound {
config.generate_click_sound = generate_click_sound;
}
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
let has_complete_form_config = !config.theme_text.trim().is_empty()
&& config.clear_count > 0
&& (1..=10).contains(&config.difficulty);
if !has_complete_form_config
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
{
return Err(match3d_bad_request(
request_context,
MATCH3D_AGENT_PROVIDER,
"match3d 创作配置尚未确认完成",
));
}
let requested_game_name = normalize_optional_match3d_text(game_name);
let requested_summary = normalize_optional_match3d_text(summary);
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
let initial_game_name = requested_game_name
.clone()
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
let initial_tags = requested_tags
.clone()
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
execute_billable_match3d_draft_generation(
state,
request_context,
owner_user_id.as_str(),
billing_asset_id.as_str(),
async {
let mut session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id.clone(),
owner_user_id.clone(),
profile_id.clone(),
Some(initial_game_name),
requested_summary.clone().or_else(|| Some(String::new())),
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
requested_cover_image_src.clone(),
None,
None,
)
.await?;
if session.draft.is_none() {
return Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
));
}
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
let resolved_game_name = requested_game_name
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
let resolved_summary = requested_summary
.clone()
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
let resolved_tags = match requested_tags {
Some(tags) => tags,
None => {
generate_match3d_work_tags_for_plan(
state,
resolved_game_name.as_str(),
config.theme_text.as_str(),
resolved_summary.as_str(),
&generated_work_metadata.metadata.tags,
)
.await
}
};
generated_work_metadata.metadata.tags = resolved_tags.clone();
session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session_id,
owner_user_id.clone(),
profile_id.clone(),
Some(resolved_game_name),
Some(resolved_summary),
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
requested_cover_image_src.clone(),
None,
None,
)
.await?;
let existing_assets = get_match3d_existing_generated_item_assets(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let generated_item_assets = generate_match3d_item_assets(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.items,
existing_assets,
)
.await?;
let generated_item_assets = ensure_match3d_background_asset(
state,
request_context,
authenticated,
owner_user_id.as_str(),
session.session_id.as_str(),
profile_id.as_str(),
&config,
generated_work_metadata.background_prompt.as_str(),
generated_item_assets,
)
.await?;
let existing_cover_image_src = get_match3d_existing_cover_image_src(
state,
owner_user_id.as_str(),
profile_id.as_str(),
)
.await;
let default_cover_image_src = requested_cover_image_src
.clone()
.or(existing_cover_image_src)
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
let next_session = upsert_match3d_draft_snapshot(
state,
request_context,
authenticated,
session.session_id.clone(),
owner_user_id.clone(),
profile_id,
None,
None,
None,
default_cover_image_src,
None,
serialize_match3d_generated_item_assets(&generated_item_assets),
)
.await?;
Ok((next_session, generated_item_assets))
},
)
.await
}
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
async fn execute_billable_match3d_draft_generation<T, Fut>(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
operation: Fut,
) -> Result<T, Response>
where
Fut: Future<Output = Result<T, Response>>,
{
let points_consumed = consume_match3d_draft_generation_points(
state,
request_context,
owner_user_id,
billing_asset_id,
)
.await?;
match operation.await {
Ok(value) => Ok(value),
Err(response) => {
if points_consumed {
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
.await;
}
Err(response)
}
}
}
async fn consume_match3d_draft_generation_points(
state: &AppState,
request_context: &RequestContext,
owner_user_id: &str,
billing_asset_id: &str,
) -> Result<bool, Response> {
let ledger_id = format!(
"asset_operation_consume:{}:match3d_draft_generation:{}",
owner_user_id, billing_asset_id
);
match state
.spacetime_client()
.consume_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
Ok(_) => Ok(true),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
tracing::warn!(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
);
Ok(false)
}
Err(error) => Err(match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_asset_operation_wallet_error(error),
)),
}
}
async fn refund_match3d_draft_generation_points(
state: &AppState,
owner_user_id: &str,
billing_asset_id: &str,
) {
let ledger_id = format!(
"asset_operation_refund:{}:match3d_draft_generation:{}",
owner_user_id, billing_asset_id
);
if let Err(error) = state
.spacetime_client()
.refund_profile_wallet_points(
owner_user_id.to_string(),
MATCH3D_DRAFT_GENERATION_POINTS_COST,
ledger_id,
current_utc_micros(),
)
.await
{
tracing::error!(
owner_user_id,
billing_asset_id,
error = %error,
"抓大鹅草稿生成失败后的泥点退款失败"
);
}
}
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
session
.draft
.as_ref()
.map(|draft| draft.profile_id.trim())
.filter(|profile_id| !profile_id.is_empty())
.or_else(|| {
session
.published_profile_id
.as_deref()
.map(str::trim)
.filter(|profile_id| !profile_id.is_empty())
})
.map(str::to_string)
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn upsert_match3d_draft_snapshot(
state: &AppState,
request_context: &RequestContext,
authenticated: &AuthenticatedAccessToken,
session_id: String,
owner_user_id: String,
profile_id: String,
game_name: Option<String>,
summary_text: Option<String>,
tags_json: Option<String>,
cover_image_src: Option<String>,
cover_asset_id: Option<String>,
generated_item_assets_json: Option<String>,
) -> Result<Match3DAgentSessionRecord, Response> {
state
.spacetime_client()
.compile_match3d_draft(Match3DCompileDraftRecordInput {
session_id,
owner_user_id,
profile_id,
author_display_name: resolve_author_display_name(state, authenticated),
game_name,
summary_text,
tags_json,
cover_image_src,
cover_asset_id,
compiled_at_micros: current_utc_micros(),
generated_item_assets_json,
})
.await
.map_err(|error| {
match3d_error_response(
request_context,
MATCH3D_AGENT_PROVIDER,
map_match3d_client_error(error),
)
})
}
pub(super) fn build_config_from_create_request(
payload: &CreateMatch3DAgentSessionRequest,
) -> Match3DConfigJson {
Match3DConfigJson {
theme_text: payload
.theme_text
.as_deref()
.or(payload.seed_text.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(MATCH3D_DEFAULT_THEME)
.to_string(),
reference_image_src: payload.reference_image_src.clone(),
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
difficulty: payload
.difficulty
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
.clamp(1, 10),
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
}
}
fn build_config_from_message(
session: &Match3DAgentSessionRecord,
payload: &SendMatch3DAgentMessageRequest,
) -> Match3DConfigJson {
let current = resolve_config_or_default(session.config.as_ref());
let text = payload.text.trim();
let reference_image_src = payload
.reference_image_src
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or(current.reference_image_src);
let quick_fill_requested =
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
let mut theme_text = current.theme_text;
let mut clear_count = current.clear_count.max(1);
let mut difficulty = current.difficulty.clamp(1, 10);
let asset_style_id = current.asset_style_id;
let asset_style_label = current.asset_style_label;
let asset_style_prompt = current.asset_style_prompt;
let generate_click_sound = current.generate_click_sound;
match session.current_turn {
0 => {
theme_text = if quick_fill_requested {
MATCH3D_DEFAULT_THEME.to_string()
} else {
parse_theme_answer(text).unwrap_or(theme_text)
};
}
1 => {
clear_count = if quick_fill_requested {
clear_count
} else {
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
.unwrap_or(clear_count)
}
.max(1);
}
_ => {
difficulty = if quick_fill_requested {
difficulty
} else {
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
}
.clamp(1, 10);
}
}
Match3DConfigJson {
theme_text,
reference_image_src,
clear_count,
difficulty,
asset_style_id,
asset_style_label,
asset_style_prompt,
generate_click_sound,
}
}
pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
config
.map(|config| Match3DConfigJson {
theme_text: config.theme_text.clone(),
reference_image_src: config.reference_image_src.clone(),
clear_count: config.clear_count.max(1),
difficulty: config.difficulty.clamp(1, 10),
asset_style_id: config.asset_style_id.clone(),
asset_style_label: config.asset_style_label.clone(),
asset_style_prompt: config.asset_style_prompt.clone(),
generate_click_sound: config.generate_click_sound,
})
.unwrap_or_else(|| Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
})
}
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
serde_json::to_string(config).ok()
}
pub(super) fn build_seed_text(
payload: &CreateMatch3DAgentSessionRequest,
config: &Match3DConfigJson,
) -> String {
payload
.seed_text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"{}题材,消除{}次,难度{}",
config.theme_text, config.clear_count, config.difficulty
)
})
}
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
format!(
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}",
config.theme_text,
config.clear_count,
config.clear_count.saturating_mul(3),
config.difficulty
)
}
pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
match current_turn {
0 => MATCH3D_QUESTION_THEME.to_string(),
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
_ => build_match3d_assistant_reply(config),
}
}
pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
match current_turn {
0 => 0,
1 => 33,
2 => 66,
_ => 100,
}
}
fn parse_theme_answer(text: &str) -> Option<String> {
for marker in ["题材", "主题"] {
if let Some((_, value)) = text.split_once(marker) {
let normalized = value
.trim_matches(|ch: char| ch == ':' || ch == '' || ch.is_whitespace())
.split_whitespace()
.next()
.unwrap_or_default()
.trim_matches(['。', '', ',', ';', ''])
.to_string();
if !normalized.is_empty() {
return Some(normalized);
}
}
}
let trimmed = text.trim();
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
{
return Some(trimmed.to_string());
}
None
}
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
for keyword in keywords {
if let Some(index) = text.find(keyword) {
let suffix = &text[index + keyword.len()..];
if let Some(value) = first_positive_integer(suffix) {
return Some(value);
}
}
}
first_positive_integer(text)
}
fn first_positive_integer(text: &str) -> Option<u32> {
let mut digits = String::new();
for ch in text.chars() {
if ch.is_ascii_digit() {
digits.push(ch);
} else if !digits.is_empty() {
break;
}
}
digits.parse::<u32>().ok().filter(|value| *value > 0)
}
pub(super) fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
for tag in tags {
let trimmed = normalize_match3d_tag(tag.as_str());
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
result.push(trimmed);
}
if result.len() >= 6 {
break;
}
}
result
}
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
async fn generate_match3d_draft_plan(
state: &AppState,
config: &Match3DConfigJson,
) -> Match3DGeneratedDraftPlan {
let Some(llm_client) = state
.creative_agent_gpt5_client()
.or_else(|| state.llm_client())
else {
return fallback_match3d_draft_plan(config);
};
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
let generated_item_count = resolve_match3d_generated_item_count(config);
let user_prompt = format!(
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符不要包含“作品”“游戏”summary 为 18 到 48 个中文字符的作品描述说明题材氛围和核心体验不要写规则说明tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字后续会用同一作品信息再次生成作品标签backgroundPrompt 是用于生成局内纯背景图的中文提示词只描述竖屏移动端抓大鹅题材氛围、色彩和环境不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小soundPrompt 只作为历史字段保留,可返回空字符串。",
config.theme_text, gameplay_item_count, generated_item_count
);
let response = llm_client
.request_text(
LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
])
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
.with_responses_api(),
)
.await;
match response {
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
Err(error) => {
tracing::warn!(
provider = MATCH3D_AGENT_PROVIDER,
theme_text = config.theme_text.as_str(),
error = %error,
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
);
fallback_match3d_draft_plan(config)
}
}
}
pub(super) fn parse_match3d_draft_plan(
raw: &str,
config: &Match3DConfigJson,
) -> Option<Match3DGeneratedDraftPlan> {
let raw = raw.trim();
let json_text = if let Some(start) = raw.find('{')
&& let Some(end) = raw.rfind('}')
&& end > start
{
&raw[start..=end]
} else {
raw
};
let value = serde_json::from_str::<Value>(json_text).ok()?;
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
if game_name.is_empty() {
return None;
}
let tags = value
.get("tags")
.and_then(Value::as_array)
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
.unwrap_or_default();
let fallback = fallback_match3d_draft_plan(config);
let summary = value
.get("summary")
.or_else(|| value.get("description"))
.or_else(|| value.get("workSummary"))
.or_else(|| value.get("work_summary"))
.and_then(Value::as_str)
.map(normalize_match3d_work_summary)
.filter(|value| !value.is_empty())
.unwrap_or(fallback.metadata.summary);
let items = value
.get("items")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let name =
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
if name.is_empty() {
return None;
}
let item_size = item
.get("itemSize")
.or_else(|| item.get("item_size"))
.or_else(|| item.get("size"))
.and_then(Value::as_str)
.map(normalize_match3d_item_size)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| infer_match3d_item_size(&name));
let sound_prompt = item
.get("soundPrompt")
.or_else(|| item.get("sound_prompt"))
.and_then(Value::as_str)
.map(normalize_match3d_audio_prompt)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
Some(Match3DGeneratedItemPlan {
name,
item_size,
sound_prompt,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let background_prompt = value
.get("backgroundPrompt")
.or_else(|| value.get("background_prompt"))
.and_then(Value::as_str)
.map(normalize_match3d_background_prompt)
.filter(|value| !value.is_empty())
.unwrap_or(fallback.background_prompt);
Some(Match3DGeneratedDraftPlan {
metadata: Match3DGeneratedWorkMetadata {
game_name,
summary,
tags: normalize_match3d_tag_candidates(tags),
},
items: normalize_match3d_item_plan(config, items),
background_prompt,
})
}
#[cfg(test)]
pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
let config = Match3DConfigJson {
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
reference_image_src: None,
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
asset_style_id: None,
asset_style_label: None,
asset_style_prompt: None,
generate_click_sound: false,
};
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
}
fn normalize_match3d_game_name(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”', '。', '', ',', '、'])
.chars()
.filter(|character| !character.is_control())
.take(16)
.collect::<String>()
.trim()
.to_string()
}
fn normalize_match3d_work_summary(raw: &str) -> String {
raw.trim()
.trim_matches(['"', '\'', '“', '”'])
.split_whitespace()
.collect::<Vec<_>>()
.join("")
.chars()
.filter(|character| !character.is_control())
.take(80)
.collect::<String>()
.trim()
.to_string()
}
pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
let theme = theme_text.trim();
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
Match3DGeneratedWorkMetadata {
game_name: format!("{normalized_theme}抓大鹅"),
summary: normalize_match3d_work_summary(
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
),
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
}
}
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
let items = fallback_match3d_item_names(config.theme_text.as_str())
.into_iter()
.take(resolve_match3d_generated_item_count(config))
.map(|name| Match3DGeneratedItemPlan {
item_size: infer_match3d_item_size(&name),
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
name,
})
.collect::<Vec<_>>();
Match3DGeneratedDraftPlan {
background_prompt: build_fallback_match3d_background_prompt(config),
metadata,
items,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
) -> Match3DAgentSessionSnapshotResponse { ) -> Match3DAgentSessionSnapshotResponse {
let mut response = map_match3d_agent_session_response(session); let mut response = map_match3d_agent_session_response(session);
if let Some(draft) = response.draft.as_mut() { if let Some(draft) = response.draft.as_mut() {
if generated_item_assets.is_empty() {
return response;
}
draft.generated_item_assets = generated_item_assets draft.generated_item_assets = generated_item_assets
.iter() .iter()
.cloned() .cloned()
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
pub(super) fn map_match3d_draft_response( pub(super) fn map_match3d_draft_response(
draft: Match3DResultDraftRecord, draft: Match3DResultDraftRecord,
) -> Match3DResultDraftResponse { ) -> Match3DResultDraftResponse {
Match3DResultDraftResponse { // 中文注释session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
let generated_item_assets = parse_match3d_generated_item_assets(
draft.generated_item_assets_json.as_deref(),
)
.into_iter()
.map(Match3DGeneratedItemAsset::from)
.collect::<Vec<_>>();
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
let mut response = Match3DResultDraftResponse {
profile_id: draft.profile_id, profile_id: draft.profile_id,
game_name: draft.game_name, game_name: draft.game_name,
theme_text: draft.theme_text, theme_text: draft.theme_text,
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
background_image_src: None, background_image_src: None,
background_image_object_key: None, background_image_object_key: None,
generated_background_asset: None, generated_background_asset: None,
generated_item_assets: Vec::new(), generated_item_assets: generated_item_assets
.iter()
.cloned()
.map(map_match3d_generated_item_asset_for_agent)
.collect(),
};
if response
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or_default()
.is_empty()
{
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
} }
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
response
} }
pub(super) fn map_match3d_generated_item_asset_for_agent( pub(super) fn map_match3d_generated_item_asset_for_agent(
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
item item
} }
fn match3d_text_present(value: Option<&String>) -> bool {
value.is_some_and(|value| !value.trim().is_empty())
}
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| asset.image_views.iter().any(|view| {
match3d_text_present(view.image_src.as_ref())
|| match3d_text_present(view.image_object_key.as_ref())
})
}
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
match3d_text_present(asset.image_src.as_ref())
|| match3d_text_present(asset.image_object_key.as_ref())
|| match3d_text_present(asset.container_image_src.as_ref())
|| match3d_text_present(asset.container_image_object_key.as_ref())
}
fn resolve_match3d_work_generation_status(
item: &Match3DWorkProfileRecord,
assets: &[Match3DGeneratedItemAssetJson],
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
) -> Option<String> {
if item.publication_status.eq_ignore_ascii_case("published") {
return Some("ready".to_string());
}
if assets.is_empty()
|| !assets.iter().any(match3d_item_asset_has_image)
|| !background_asset.is_some_and(match3d_background_asset_has_image)
{
return Some("generating".to_string());
}
Some("ready".to_string())
}
pub(super) fn map_match3d_message_response( pub(super) fn map_match3d_message_response(
message: Match3DAgentMessageRecord, message: Match3DAgentMessageRecord,
) -> Match3DAgentMessageResponse { ) -> Match3DAgentMessageResponse {
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
let generated_item_asset_json = let generated_item_asset_json =
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref()); parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json); let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
let generation_status = resolve_match3d_work_generation_status(
&item,
&generated_item_asset_json,
background_asset.as_ref(),
);
let generated_background_asset = background_asset let generated_background_asset = background_asset
.clone() .clone()
.map(map_match3d_background_asset_for_work); .map(map_match3d_background_asset_for_work);
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
updated_at: item.updated_at, updated_at: item.updated_at,
published_at: item.published_at, published_at: item.published_at,
publish_ready: item.publish_ready, publish_ready: item.publish_ready,
generation_status,
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()), background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
background_image_src: background_asset background_image_src: background_asset
.as_ref() .as_ref()

View File

@@ -0,0 +1,37 @@
pub(super) fn normalize_match3d_run_status(value: &str) -> &str {
match value {
"Running" => "running",
"Won" => "won",
"Failed" => "failed",
"Stopped" => "stopped",
_ => value,
}
}
pub(super) fn normalize_match3d_item_state(value: &str) -> &str {
match value {
"InBoard" => "in_board",
"InTray" => "in_tray",
"Cleared" => "cleared",
_ => value,
}
}
pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str {
match value {
"TimeUp" => "time_up",
"TrayFull" => "tray_full",
_ => value,
}
}
pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str {
match value {
"RejectedNotClickable" => "item_not_clickable",
"RejectedAlreadyMoved" => "item_not_in_board",
"RejectedTrayFull" => "tray_full",
"VersionConflict" => "snapshot_version_mismatch",
"RunFinished" => "run_not_active",
_ => value,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
use super::*;
pub(super) async fn generate_match3d_material_sheet(
state: &AppState,
config: &Match3DConfigJson,
item_names: &[String],
) -> Result<Match3DMaterialSheet, AppError> {
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
let prompt = build_match3d_material_sheet_prompt(config, item_names);
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
let generated = create_match3d_vector_engine_gemini_image_generation(
&http_client,
&settings,
prompt.as_str(),
negative_prompt.as_str(),
"抓大鹅素材图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine-gemini",
"message": "抓大鹅素材图生成失败:未返回图片",
}))
})?;
Ok(Match3DMaterialSheet {
task_id: generated.task_id,
image,
})
}
fn require_match3d_vector_engine_gemini_image_settings(
state: &AppState,
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
let base_url = state
.config
.vector_engine_base_url
.trim()
.trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "vector-engine-gemini",
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.vector_engine_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "vector-engine-gemini",
"reason": "VECTOR_ENGINE_API_KEY 未配置",
}))
})?;
Ok(Match3DVectorEngineGeminiImageSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
})
}
fn build_match3d_vector_engine_gemini_image_http_client(
settings: &Match3DVectorEngineGeminiImageSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "vector-engine-gemini",
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
}))
})
}
async fn create_match3d_vector_engine_gemini_image_generation(
http_client: &reqwest::Client,
settings: &Match3DVectorEngineGeminiImageSettings,
prompt: &str,
negative_prompt: &str,
failure_context: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let request_body = build_match3d_vector_engine_gemini_image_request_body(
prompt,
negative_prompt,
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
);
let response = http_client
.post(build_match3d_vector_engine_gemini_generate_content_url(
settings,
))
.query(&[("key", settings.api_key.as_str())])
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/json")
.json(&request_body)
.send()
.await
.map_err(|error| {
map_match3d_vector_engine_gemini_image_request_error(format!(
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_match3d_vector_engine_gemini_image_request_error(format!(
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
))
})?;
if !status.is_success() {
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
status,
response_text.as_str(),
failure_context,
));
}
let payload = parse_match3d_json_payload(
response_text.as_str(),
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
"vector-engine-gemini",
)?;
let image_urls = extract_match3d_image_urls(&payload);
if !image_urls.is_empty() {
return download_match3d_images_from_urls(
http_client,
format!("vector-engine-gemini-{}", current_utc_micros()),
image_urls,
1,
"vector-engine-gemini",
)
.await;
}
let b64_images = extract_match3d_b64_images(&payload);
if !b64_images.is_empty() {
return Ok(match3d_images_from_base64(
format!("vector-engine-gemini-{}", current_utc_micros()),
b64_images,
1,
));
}
Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine-gemini",
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
})),
)
}
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
prompt: &str,
negative_prompt: &str,
aspect_ratio: &str,
) -> Value {
json!({
"contents": [{
"role": "user",
"parts": [{
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
}],
}],
"generationConfig": {
"responseModalities": ["TEXT", "IMAGE"],
"imageConfig": {
"aspectRatio": aspect_ratio,
},
},
})
}
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
settings: &Match3DVectorEngineGeminiImageSettings,
) -> String {
let base_url = settings.base_url.trim_end_matches("/v1");
format!(
"{}/v1beta/models/{}:generateContent",
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
)
}
fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String {
let prompt = prompt.trim();
let negative_prompt = negative_prompt.trim();
if negative_prompt.is_empty() {
return prompt.to_string();
}
format!("{prompt}\n避免:{negative_prompt}")
}
async fn download_match3d_images_from_urls(
http_client: &reqwest::Client,
task_id: String,
image_urls: Vec<String>,
candidate_count: u32,
provider: &str,
) -> Result<OpenAiGeneratedImages, AppError> {
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
for image_url in image_urls
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
{
images
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
}
Ok(OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
})
}
async fn download_match3d_remote_image(
http_client: &reqwest::Client,
image_url: &str,
provider: &str,
) -> Result<DownloadedOpenAiImage, AppError> {
let response = http_client.get(image_url).send().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("下载抓大鹅生成图片失败:{error}"),
}))
})?;
let status = response.status();
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("image/png")
.to_string();
let body = response.bytes().await.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
}))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": "下载抓大鹅生成图片失败",
"status": status.as_u16(),
})),
);
}
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
Ok(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: body.to_vec(),
})
}
fn match3d_images_from_base64(
task_id: String,
b64_images: Vec<String>,
candidate_count: u32,
) -> OpenAiGeneratedImages {
let images = b64_images
.into_iter()
.take(candidate_count.clamp(1, 4) as usize)
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
.collect();
OpenAiGeneratedImages {
task_id,
actual_prompt: None,
images,
}
}
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
Some(DownloadedOpenAiImage {
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes,
})
}
fn parse_match3d_json_payload(
raw_text: &str,
failure_context: &str,
provider: &str,
) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": provider,
"message": format!("{failure_context}{error}"),
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
}))
})
}
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_match3d_strings_by_key(payload, "url", &mut urls);
collect_match3d_strings_by_key(payload, "image", &mut urls);
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
let mut values = Vec::new();
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
collect_match3d_inline_image_data(payload, &mut values);
values
}
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_match3d_inline_image_data(entry, results);
}
}
Value::Object(object) => {
for key in ["inlineData", "inline_data"] {
if let Some(Value::Object(inline_data)) = object.get(key) {
let mime_type = inline_data
.get("mimeType")
.or_else(|| inline_data.get("mime_type"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("image/png")
.to_ascii_lowercase();
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
continue;
}
if let Some(data) = inline_data
.get("data")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(data.to_string());
}
}
}
for nested_value in object.values() {
collect_match3d_inline_image_data(nested_value, results);
}
}
_ => {}
}
}
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_match3d_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_match3d_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, nested_value) in object {
if key == target_key {
match nested_value {
Value::String(text) => {
let text = text.trim();
if !text.is_empty() {
results.push(text.to_string());
}
}
Value::Array(entries) => {
for entry in entries {
if let Some(text) = entry
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
results.push(text.to_string());
}
}
}
_ => {}
}
}
collect_match3d_strings_by_key(nested_value, target_key, results);
}
}
_ => {}
}
}
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine-gemini",
"message": message,
}))
}
fn map_match3d_vector_engine_gemini_image_upstream_error(
upstream_status: reqwest::StatusCode,
raw_text: &str,
fallback_message: &str,
) -> AppError {
let message = parse_match3d_api_error_message(raw_text, fallback_message);
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
tracing::warn!(
provider = "vector-engine-gemini",
upstream_status = upstream_status.as_u16(),
message = %message,
raw_excerpt = %raw_excerpt,
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
);
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "vector-engine-gemini",
"upstreamStatus": upstream_status.as_u16(),
"message": message,
"rawExcerpt": raw_excerpt,
}))
}
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
for key in ["message", "code"] {
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
return if key == "message" {
value
} else {
format!("{fallback_message}{value}")
};
}
}
}
trimmed.to_string()
}
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
raw_text.chars().take(max_chars).collect()
}
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/png");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/png".to_string(),
}
}
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/jpeg" | "image/jpg" => "jpg",
_ => "png",
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
use std::{
sync::{Mutex, OnceLock},
time::Instant,
};
use opentelemetry::global;
use tracing::warn;
// 进程指标只描述 api-server 自身,不携带请求、用户或作品维度,避免 OTLP 指标高基数膨胀。
pub(crate) fn register_process_metrics() {
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(register_process_metrics_once);
}
fn register_process_metrics_once() {
let meter = global::meter("genarrative-api");
meter
.i64_observable_up_down_counter("process.memory.usage")
.with_unit("By")
.with_description("api-server process physical memory usage")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
observer.observe(to_i64(snapshot.rss_bytes), &[]);
})
.build();
meter
.i64_observable_up_down_counter("process.memory.virtual")
.with_unit("By")
.with_description("api-server committed virtual memory")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
if let Some(virtual_bytes) = snapshot.virtual_bytes {
observer.observe(to_i64(virtual_bytes), &[]);
}
})
.build();
meter
.i64_observable_up_down_counter("genarrative.process.memory.private")
.with_unit("By")
.with_description("api-server private memory for local diagnostics")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
if let Some(private_bytes) = snapshot.private_bytes {
observer.observe(to_i64(private_bytes), &[]);
}
})
.build();
meter
.f64_observable_counter("process.cpu.time")
.with_unit("s")
.with_description("api-server total user plus system CPU time")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
if let Some(cpu_time_seconds) = snapshot.cpu_time_seconds {
observer.observe(cpu_time_seconds, &[]);
}
})
.build();
meter
.f64_observable_gauge("genarrative.process.cpu.usage_percent")
.with_unit("%")
.with_description("api-server process CPU usage between metric collections")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
if let Some(cpu_time_seconds) = snapshot.cpu_time_seconds {
if let Some(usage_percent) =
process_cpu_usage_percent(cpu_time_seconds, Instant::now())
{
observer.observe(usage_percent, &[]);
}
}
})
.build();
meter
.i64_observable_up_down_counter("process.thread.count")
.with_unit("{thread}")
.with_description("api-server process thread count")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
observer.observe(to_i64(snapshot.thread_count), &[]);
})
.build();
meter
.i64_observable_up_down_counter("process.windows.handle.count")
.with_unit("{handle}")
.with_description("api-server process handle count on Windows")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
if let Some(handle_count) = snapshot.windows_handle_count {
observer.observe(to_i64(handle_count), &[]);
}
})
.build();
meter
.i64_observable_up_down_counter("process.unix.file_descriptor.count")
.with_unit("{file_descriptor}")
.with_description("api-server process file descriptor count on Unix")
.with_callback(|observer| {
let Some(snapshot) = ProcessMetricsSnapshot::collect() else {
return;
};
if let Some(fd_count) = snapshot.unix_fd_count {
observer.observe(to_i64(fd_count), &[]);
}
})
.build();
}
fn to_i64(value: u64) -> i64 {
value.min(i64::MAX as u64) as i64
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ProcessMetricsSnapshot {
rss_bytes: u64,
private_bytes: Option<u64>,
virtual_bytes: Option<u64>,
cpu_time_seconds: Option<f64>,
thread_count: u64,
windows_handle_count: Option<u64>,
unix_fd_count: Option<u64>,
}
impl ProcessMetricsSnapshot {
fn collect() -> Option<Self> {
collect_process_metrics()
.inspect_err(|error| {
warn!(%error, "采集 api-server 进程指标失败");
})
.ok()
}
}
#[derive(Debug, Clone, Copy)]
struct CpuUsageSample {
cpu_time_seconds: f64,
observed_at: Instant,
}
fn process_cpu_usage_percent(cpu_time_seconds: f64, observed_at: Instant) -> Option<f64> {
static LAST_SAMPLE: OnceLock<Mutex<Option<CpuUsageSample>>> = OnceLock::new();
let mut last_sample = LAST_SAMPLE.get_or_init(|| Mutex::new(None)).lock().ok()?;
let previous = *last_sample;
*last_sample = Some(CpuUsageSample {
cpu_time_seconds,
observed_at,
});
let previous = previous?;
let wall_delta_seconds = observed_at
.checked_duration_since(previous.observed_at)?
.as_secs_f64();
cpu_usage_ratio_between_samples(
previous.cpu_time_seconds,
cpu_time_seconds,
0.0,
wall_delta_seconds,
)
.map(|ratio| ratio * 100.0)
}
fn cpu_usage_ratio_between_samples(
previous_cpu_seconds: f64,
current_cpu_seconds: f64,
previous_wall_seconds: f64,
current_wall_seconds: f64,
) -> Option<f64> {
let cpu_delta_seconds = current_cpu_seconds - previous_cpu_seconds;
let wall_delta_seconds = current_wall_seconds - previous_wall_seconds;
if cpu_delta_seconds < 0.0 || wall_delta_seconds <= 0.0 {
return None;
}
Some(cpu_delta_seconds / wall_delta_seconds)
}
#[cfg(windows)]
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
use windows_sys::Win32::{
System::{
ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX},
Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount},
},
};
let handle = unsafe { GetCurrentProcess() };
let mut counters = PROCESS_MEMORY_COUNTERS_EX {
cb: std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX>() as u32,
..Default::default()
};
let ok = unsafe {
GetProcessMemoryInfo(
handle,
std::ptr::addr_of_mut!(counters).cast(),
counters.cb,
)
};
if ok == 0 {
return Err("GetProcessMemoryInfo returned false".to_string());
}
let mut handle_count = 0_u32;
let handle_count = if unsafe { GetProcessHandleCount(handle, &mut handle_count) } == 0 {
None
} else {
Some(u64::from(handle_count))
};
let cpu_time_seconds = windows_process_cpu_time_seconds(handle);
Ok(ProcessMetricsSnapshot {
rss_bytes: counters.WorkingSetSize as u64,
private_bytes: Some(counters.PrivateUsage as u64),
virtual_bytes: Some(counters.PrivateUsage as u64),
cpu_time_seconds,
thread_count: u64::from(unsafe { GetCurrentProcessId() }.thread_count()?),
windows_handle_count: handle_count,
unix_fd_count: None,
})
}
#[cfg(windows)]
fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option<f64> {
use windows_sys::Win32::{
Foundation::FILETIME,
System::Threading::GetProcessTimes,
};
let mut creation_time = FILETIME::default();
let mut exit_time = FILETIME::default();
let mut kernel_time = FILETIME::default();
let mut user_time = FILETIME::default();
let ok = unsafe {
GetProcessTimes(
handle,
&mut creation_time,
&mut exit_time,
&mut kernel_time,
&mut user_time,
)
};
if ok == 0 {
return None;
}
let total_100ns = filetime_100ns(kernel_time) + filetime_100ns(user_time);
Some(total_100ns as f64 / 10_000_000.0)
}
#[cfg(windows)]
fn filetime_100ns(filetime: windows_sys::Win32::Foundation::FILETIME) -> u64 {
((filetime.dwHighDateTime as u64) << 32) | u64::from(filetime.dwLowDateTime)
}
#[cfg(windows)]
trait WindowsProcessThreadCount {
fn thread_count(self) -> Result<u32, String>;
}
#[cfg(windows)]
impl WindowsProcessThreadCount for u32 {
fn thread_count(self) -> Result<u32, String> {
use windows_sys::Win32::{
Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32, Process32First, Process32Next,
TH32CS_SNAPPROCESS,
},
};
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Err("CreateToolhelp32Snapshot returned INVALID_HANDLE_VALUE".to_string());
}
let mut entry = PROCESSENTRY32 {
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
..Default::default()
};
let mut found = None;
let mut ok = unsafe { Process32First(snapshot, &mut entry) };
while ok != 0 {
if entry.th32ProcessID == self {
found = Some(entry.cntThreads);
break;
}
ok = unsafe { Process32Next(snapshot, &mut entry) };
}
unsafe {
CloseHandle(snapshot);
}
found.ok_or_else(|| format!("process {self} not found in ToolHelp snapshot"))
}
}
#[cfg(target_os = "linux")]
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
let status = std::fs::read_to_string("/proc/self/status")
.map_err(|error| format!("read /proc/self/status failed: {error}"))?;
let statm = std::fs::read_to_string("/proc/self/statm")
.map_err(|error| format!("read /proc/self/statm failed: {error}"))?;
let stat = std::fs::read_to_string("/proc/self/stat")
.map_err(|error| format!("read /proc/self/stat failed: {error}"))?;
let page_size = linux_page_size_bytes()?;
let rss_bytes = parse_status_kb(&status, "VmRSS:")
.map(|value| value * 1024)
.or_else(|| parse_statm_pages(&statm, 1).map(|value| value * page_size))
.ok_or_else(|| "missing VmRSS/statm resident field".to_string())?;
let virtual_bytes = parse_status_kb(&status, "VmSize:")
.map(|value| value * 1024)
.or_else(|| parse_statm_pages(&statm, 0).map(|value| value * page_size))
.ok_or_else(|| "missing VmSize/statm size field".to_string())?;
let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024);
let cpu_time_seconds = linux_cpu_time_seconds(&stat)?;
let thread_count = parse_status_u64(&status, "Threads:")
.ok_or_else(|| "missing Threads field".to_string())?;
Ok(ProcessMetricsSnapshot {
rss_bytes,
private_bytes,
virtual_bytes: Some(virtual_bytes),
cpu_time_seconds: Some(cpu_time_seconds),
thread_count,
windows_handle_count: None,
unix_fd_count: linux_fd_count(),
})
}
#[cfg(target_os = "linux")]
fn linux_cpu_time_seconds(stat: &str) -> Result<f64, String> {
let cpu_ticks = parse_linux_proc_stat_cpu_ticks(stat)
.ok_or_else(|| "missing /proc/self/stat utime/stime fields".to_string())?;
let ticks_per_second = linux_clock_ticks_per_second()?;
Ok(cpu_ticks as f64 / ticks_per_second as f64)
}
#[cfg(target_os = "linux")]
fn linux_clock_ticks_per_second() -> Result<u64, String> {
static CLOCK_TICKS_PER_SECOND: OnceLock<Result<u64, String>> = OnceLock::new();
CLOCK_TICKS_PER_SECOND
.get_or_init(|| {
let output = std::process::Command::new("getconf")
.arg("CLK_TCK")
.output()
.map_err(|error| format!("getconf CLK_TCK failed: {error}"))?;
if !output.status.success() {
return Err(format!("getconf CLK_TCK exited with {}", output.status));
}
let text = String::from_utf8(output.stdout)
.map_err(|error| format!("getconf CLK_TCK output is not utf8: {error}"))?;
text.trim()
.parse::<u64>()
.map_err(|error| format!("parse CLK_TCK failed: {error}"))
})
.clone()
}
#[cfg(target_os = "linux")]
fn parse_linux_proc_stat_cpu_ticks(stat: &str) -> Option<u64> {
let fields_after_comm = stat.rsplit_once(") ")?.1;
let mut fields = fields_after_comm.split_whitespace();
let utime = fields.nth(11)?.parse::<u64>().ok()?;
let stime = fields.next()?.parse::<u64>().ok()?;
Some(utime + stime)
}
#[cfg(target_os = "linux")]
fn linux_page_size_bytes() -> Result<u64, String> {
let output = std::process::Command::new("getconf")
.arg("PAGESIZE")
.output()
.map_err(|error| format!("getconf PAGESIZE failed: {error}"))?;
if !output.status.success() {
return Err(format!("getconf PAGESIZE exited with {}", output.status));
}
let text = String::from_utf8(output.stdout)
.map_err(|error| format!("getconf PAGESIZE output is not utf8: {error}"))?;
text.trim()
.parse::<u64>()
.map_err(|error| format!("parse PAGESIZE failed: {error}"))
}
#[cfg(target_os = "linux")]
fn linux_fd_count() -> Option<u64> {
let entries = std::fs::read_dir("/proc/self/fd").ok()?;
Some(entries.filter_map(Result::ok).count() as u64)
}
#[cfg(target_os = "linux")]
fn parse_status_kb(status: &str, key: &str) -> Option<u64> {
parse_status_u64(status, key)
}
#[cfg(target_os = "linux")]
fn parse_status_u64(status: &str, key: &str) -> Option<u64> {
status.lines().find_map(|line| {
let rest = line.strip_prefix(key)?.trim();
rest.split_whitespace().next()?.parse::<u64>().ok()
})
}
#[cfg(target_os = "linux")]
fn parse_statm_pages(statm: &str, index: usize) -> Option<u64> {
statm
.split_whitespace()
.nth(index)?
.parse::<u64>()
.ok()
}
#[cfg(not(any(windows, target_os = "linux")))]
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
Err("process metrics are only implemented for Windows and Linux".to_string())
}
#[cfg(test)]
mod tests {
use super::cpu_usage_ratio_between_samples;
#[cfg(target_os = "linux")]
use super::{
parse_linux_proc_stat_cpu_ticks, parse_statm_pages, parse_status_kb, parse_status_u64,
};
#[cfg(target_os = "linux")]
#[test]
fn parses_linux_proc_status_memory_fields() {
let status = "Name:\tapi-server\nVmSize:\t 123456 kB\nVmRSS:\t 7890 kB\nVmData:\t 3456 kB\nThreads:\t37\n";
assert_eq!(parse_status_kb(status, "VmRSS:"), Some(7890));
assert_eq!(parse_status_kb(status, "VmSize:"), Some(123456));
assert_eq!(parse_status_kb(status, "VmData:"), Some(3456));
assert_eq!(parse_status_u64(status, "Threads:"), Some(37));
}
#[cfg(target_os = "linux")]
#[test]
fn parses_linux_statm_pages() {
assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 0), Some(100));
assert_eq!(parse_statm_pages("100 20 0 0 0 0 0", 1), Some(20));
assert_eq!(parse_statm_pages("100 20", 7), None);
}
#[cfg(target_os = "linux")]
#[test]
fn parses_linux_proc_stat_cpu_ticks_with_space_in_process_name() {
let stat = "123 (api server) S 1 2 3 4 5 6 7 8 9 10 120 30 0 0 20 0 18 0 12345";
assert_eq!(parse_linux_proc_stat_cpu_ticks(stat), Some(150));
}
#[test]
fn cpu_usage_ratio_uses_cpu_time_delta_over_wall_time() {
assert_eq!(
cpu_usage_ratio_between_samples(10.0, 12.5, 100.0, 101.0),
Some(2.5)
);
assert_eq!(
cpu_usage_ratio_between_samples(10.0, 9.0, 100.0, 101.0),
None
);
assert_eq!(
cpu_usage_ratio_between_samples(10.0, 11.0, 100.0, 100.0),
None
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,264 @@
use super::*;
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
if error.code() == "UPSTREAM_ERROR" {
let body_text = error.body_text();
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图图片生成失败:{body_text}"),
}));
}
error
}
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|| is_puzzle_request_timeout_message(error.body_text().as_str())
}
pub(crate) async fn generate_puzzle_image_candidates(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
reference_image_src: Option<&str>,
use_reference_image_edit: bool,
image_model: Option<&str>,
candidate_count: u32,
candidate_start_index: usize,
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
let total_started_at = Instant::now();
let count = candidate_count.clamp(1, 1);
let resolved_model = resolve_puzzle_image_model(image_model);
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
let has_reference_image = has_puzzle_reference_image(reference_image_src);
let should_use_reference_image_edit =
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
build_puzzle_image_prompt(level_name, prompt).as_str(),
should_use_reference_image_edit,
);
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
prompt_chars = prompt.chars().count(),
actual_prompt_chars = actual_prompt.chars().count(),
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
"拼图图片生成请求已准备"
);
let reference_image_started_at = Instant::now();
let reference_image = match reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|_| should_use_reference_image_edit)
{
Some(source) => {
let resolved =
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %resolved.mime_type,
reference_bytes = resolved.bytes_len,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析完成"
);
Some(resolved)
}
None => None,
};
if !should_use_reference_image_edit {
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
has_reference_image,
use_reference_image_edit = should_use_reference_image_edit,
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
"拼图参考图解析跳过"
);
}
// 中文注释SpacetimeDB reducer 不能做外部 I/O参考图读取与外部生图都必须停留在 api-server。
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
let settings = require_puzzle_vector_engine_settings(state)?;
let vector_engine_started_at = Instant::now();
let generated = if should_use_reference_image_edit {
let reference_image = reference_image.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "puzzle",
"field": "referenceImageSrc",
"message": "AI 重绘需要提供参考图。",
}))
})?;
let edit_result = create_puzzle_vector_engine_image_edit(
&http_client,
&settings,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
reference_image,
)
.await;
match edit_result {
Ok(generated) => Ok(generated),
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
tracing::warn!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
reference_mime = %reference_image.mime_type,
reference_bytes = reference_image.bytes_len,
error = %error,
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
);
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
Some(reference_image),
)
.await
}
Err(error) => Err(error),
}
} else {
create_puzzle_vector_engine_image_generation(
&http_client,
&settings,
resolved_model,
actual_prompt.as_str(),
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
count,
None,
)
.await
}
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
generated_image_count = generated.images.len(),
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
"拼图 VectorEngine 生图与下载完成"
);
let mut items = Vec::with_capacity(generated.images.len());
for (index, image) in generated.images.into_iter().enumerate() {
let candidate_id = format!(
"{session_id}-candidate-{}",
candidate_start_index + index + 1
);
let downloaded_image = image.clone();
let persist_started_at = Instant::now();
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
session_id,
level_name,
candidate_id.as_str(),
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
.map_err(map_puzzle_generation_endpoint_error)?;
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
candidate_id = %candidate_id,
image_bytes = downloaded_image.bytes.len(),
image_mime = %downloaded_image.mime_type,
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
"拼图生成图片已写入 OSS 与资产索引"
);
items.push(GeneratedPuzzleImageCandidate {
record: PuzzleGeneratedImageCandidateRecord {
candidate_id,
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
actual_prompt: Some(actual_prompt.clone()),
source_type: resolved_model.candidate_source_type().to_string(),
// 单图生成结果总是直接成为当前正式图。
selected: index == 0,
},
downloaded_image,
});
}
tracing::info!(
provider = resolved_model.provider_name(),
image_model = resolved_model.request_model_name(),
session_id,
level_name,
candidate_count = items.len(),
has_reference_image,
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
"拼图图片候选生成完成"
);
Ok(items)
}
pub(crate) async fn generate_puzzle_ui_background_image(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
let settings = require_openai_image_settings(state)?;
let http_client = build_openai_image_http_client(&settings)?;
let generated = create_openai_image_generation(
&http_client,
&settings,
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
"9:16",
1,
&[],
"拼图 UI 背景图生成失败",
)
.await?;
let image = generated.images.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"message": "拼图 UI 背景图生成失败:未返回图片",
}))
})?;
persist_puzzle_ui_background_image(
state,
owner_user_id,
session_id,
level_name,
generated.task_id.as_str(),
image,
)
.await
}
#[cfg(test)]
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
level_name: &str,
prompt: &str,
) -> String {
build_puzzle_ui_background_generation_prompt(level_name, prompt)
}

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,7 @@ pub(super) fn map_puzzle_form_draft_response(
pub(super) fn map_puzzle_draft_level_response( pub(super) fn map_puzzle_draft_level_response(
level: PuzzleDraftLevelRecord, level: PuzzleDraftLevelRecord,
) -> PuzzleDraftLevelResponse { ) -> PuzzleDraftLevelResponse {
let generation_status = resolve_puzzle_level_generation_status(&level);
PuzzleDraftLevelResponse { PuzzleDraftLevelResponse {
level_id: level.level_id, level_id: level.level_id,
level_name: level.level_name, level_name: level.level_name,
@@ -115,7 +116,7 @@ pub(super) fn map_puzzle_draft_level_response(
selected_candidate_id: level.selected_candidate_id, selected_candidate_id: level.selected_candidate_id,
cover_image_src: level.cover_image_src, cover_image_src: level.cover_image_src,
cover_asset_id: level.cover_asset_id, cover_asset_id: level.cover_asset_id,
generation_status: level.generation_status, generation_status,
} }
} }
@@ -278,9 +279,120 @@ pub(super) fn map_puzzle_result_preview_finding_response(
} }
} }
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
let has_viewable_result = item
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| item.levels.iter().any(has_puzzle_level_image);
if has_viewable_result {
return Some("ready".to_string());
}
item.levels
.iter()
.map(resolve_puzzle_level_generation_status)
.find(|status| status.as_str() == "generating")
.or_else(|| {
item.levels
.iter()
.map(resolve_puzzle_level_generation_status)
.find(|status| status.as_str() == "ready")
})
.or_else(|| {
item.levels
.iter()
.map(resolve_puzzle_level_generation_status)
.find(|status| !status.is_empty())
})
}
fn resolve_puzzle_level_generation_status(level: &PuzzleDraftLevelRecord) -> String {
if level.generation_status.trim() == "generating" && has_puzzle_level_image(level) {
return "ready".to_string();
}
level.generation_status.trim().to_string()
}
fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
let has_cover = level
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let has_selected_candidate = level
.selected_candidate_id
.as_deref()
.and_then(|candidate_id| {
level
.candidates
.iter()
.find(|candidate| candidate.candidate_id == candidate_id)
})
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
let has_fallback_candidate = level
.candidates
.last()
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
has_cover || has_selected_candidate || has_fallback_candidate
}
pub(super) fn map_puzzle_work_summary_response( pub(super) fn map_puzzle_work_summary_response(
state: &AppState, state: &AppState,
item: PuzzleWorkProfileRecord, item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let generation_status = resolve_puzzle_work_generation_status(&item);
let author = resolve_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.work_title,
work_description: item.work_description,
level_name: item.level_name,
summary: item.summary,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: item.publication_status,
updated_at: item.updated_at,
published_at: item.published_at,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: item.point_incentive_total_half_points,
point_incentive_claimed_points: item.point_incentive_claimed_points,
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
point_incentive_claimable_points: item
.point_incentive_total_half_points
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
generation_status,
levels: item
.levels
.iter()
.map(|x| map_puzzle_draft_level_response(x.clone()))
.collect(),
}
}
pub(super) fn map_puzzle_gallery_card_response(
state: &AppState,
item: PuzzleGalleryCardRecord,
) -> PuzzleWorkSummaryResponse { ) -> PuzzleWorkSummaryResponse {
let author = resolve_work_author_by_user_id( let author = resolve_work_author_by_user_id(
state, state,
@@ -316,6 +428,7 @@ pub(super) fn map_puzzle_work_summary_response(
.saturating_div(2) .saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points), .saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready, publish_ready: item.publish_ready,
generation_status: item.generation_status,
levels: Vec::new(), levels: Vec::new(),
} }
} }

View File

@@ -0,0 +1,898 @@
use super::*;
#[test]
fn puzzle_generated_image_size_is_square_1_1() {
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
assert_eq!(PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, "1024x1024");
}
#[test]
fn puzzle_vector_engine_request_uses_gpt_image_2_all_and_reference_images() {
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::Gemini31FlashPreview,
"一只猫在雨夜灯牌下回头。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
4,
None,
);
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
assert_eq!(body["size"], PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE);
assert_eq!(body["n"], 1);
assert!(body.get("official_fallback").is_none());
assert!(body.get("image").is_none());
assert!(
body["prompt"]
.as_str()
.unwrap_or_default()
.contains("文字水印")
);
}
#[test]
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let reference_image = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: cursor.get_ref().len(),
bytes: cursor.into_inner(),
};
let body = build_puzzle_vector_engine_image_request_body(
PuzzleImageModel::GptImage2,
"参考图里的小猫做成拼图主图。",
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
1,
Some(&reference_image),
);
let images = body["image"]
.as_array()
.expect("fallback generation should include reference image array");
assert_eq!(images.len(), 1);
assert!(
images[0]
.as_str()
.unwrap_or_default()
.starts_with("data:image/png;base64,")
);
}
#[test]
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
let settings = PuzzleVectorEngineSettings {
base_url: "https://vector.example/v1".to_string(),
api_key: "test-key".to_string(),
};
assert_eq!(
puzzle_vector_engine_images_edit_url(&settings),
"https://vector.example/v1/images/edits"
);
}
#[test]
fn puzzle_vector_engine_edit_response_decodes_b64_image() {
let images = puzzle_images_from_base64(
"edit-1".to_string(),
vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")],
1,
);
assert_eq!(images.images.len(), 1);
assert_eq!(images.images[0].mime_type, "image/png");
assert_eq!(images.images[0].extension, "png");
}
#[test]
fn puzzle_vector_engine_prompt_strongly_uses_reference_image() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true);
assert!(prompt.contains("参考图作为第一优先级"));
assert!(prompt.contains("严格保留参考图的主要主体、构图关系、视角、姿态、配色和光影氛围"));
assert!(prompt.contains("请生成雨夜猫街。"));
}
#[test]
fn puzzle_vector_engine_prompt_keeps_text_only_prompt_unchanged() {
let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", false);
assert_eq!(prompt, "请生成雨夜猫街。");
}
#[test]
fn puzzle_reference_image_edit_requires_ai_redraw() {
assert!(!should_use_puzzle_reference_image_edit(None, true));
assert!(!should_use_puzzle_reference_image_edit(
Some("data:image/png;base64,abcd"),
false
));
assert!(should_use_puzzle_reference_image_edit(
Some("data:image/png;base64,abcd"),
true
));
}
#[test]
fn puzzle_reference_image_sources_are_deduped_and_limited() {
let sources = collect_puzzle_reference_image_sources(
Some("data:image/png;base64,a"),
&[
"data:image/png;base64,a".to_string(),
"data:image/png;base64,b".to_string(),
"data:image/png;base64,c".to_string(),
"data:image/png;base64,d".to_string(),
"data:image/png;base64,e".to_string(),
"data:image/png;base64,f".to_string(),
],
);
assert_eq!(sources.len(), 5);
assert_eq!(sources[0], "data:image/png;base64,a");
assert_eq!(sources[1], "data:image/png;base64,b");
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
}
#[test]
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_request_error(
"创建拼图 VectorEngine 图片生成任务失败operation timed out".to_string(),
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
let error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
let response = error.into_response();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
}
#[test]
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
let timeout_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::GATEWAY_TIMEOUT,
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(should_fallback_puzzle_reference_edit_to_generation(
&timeout_error
));
let auth_error = map_puzzle_vector_engine_upstream_error(
reqwest::StatusCode::UNAUTHORIZED,
r#"{"error":{"message":"invalid api key"}}"#,
"创建拼图 VectorEngine 图片编辑任务失败",
);
assert!(!should_fallback_puzzle_reference_edit_to_generation(
&auth_error
));
}
#[test]
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
let error = match reqwest::Client::new().get("http://[::1").build() {
Ok(_) => panic!("invalid url should fail request build"),
Err(error) => error,
};
let app_error = map_puzzle_vector_engine_reqwest_error(
"创建拼图 VectorEngine 图片编辑任务失败",
"https://api.vectorengine.ai/v1/images/edits",
error,
);
let response = app_error.into_response();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[test]
fn puzzle_compile_error_preserves_vector_engine_unavailable_status() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
"VECTOR_ENGINE_API_KEY 未配置".to_string(),
));
let response = error.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() {
let error = map_puzzle_compile_error(SpacetimeClientError::Runtime(
"APIMart 图片生成密钥未配置".to_string(),
));
let response = error.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response.into_body();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.expect("body bytes should read");
let payload: Value =
serde_json::from_slice(&bytes).expect("error response should be valid json");
assert_eq!(
payload["error"]["details"]["provider"],
Value::String(VECTOR_ENGINE_PROVIDER.to_string())
);
assert_eq!(
payload["error"]["details"]["message"],
Value::String("VectorEngine 图片生成密钥未配置".to_string())
);
}
#[test]
fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "雨夜猫街",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": null,
"cover_asset_id": null,
"generation_status": "idle",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
should_auto_name_level: None,
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("暖灯猫街作品".to_string()),
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
picture_description: None,
level_name: None,
summary: Some("当前关卡画面。".to_string()),
theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
let draft = session.draft.expect("draft");
assert_eq!(session.stage, "ready_to_publish");
assert_eq!(draft.work_title, "暖灯猫街作品");
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
assert_eq!(
draft.levels[0].picture_description,
"一只猫在雨夜灯牌下回头。"
);
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街"}"#),
Some("雨夜猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text("1. 《暖灯猫街》"),
Some("暖灯猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelName":"雨夜猫街画面"}"#),
Some("雨夜猫街".to_string())
);
assert_eq!(
parse_puzzle_first_level_name_from_text(r#"{"levelNam"#),
None
);
}
#[test]
fn puzzle_level_naming_parser_accepts_metadata_and_ui_background_prompt() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","workDescription":"在湿润灯牌与猫影之间完成一套雨夜街角拼图。","workTags":["雨夜","猫咪","灯牌","街角","暖色","插画"],"uiBackgroundPrompt":"雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次"}"#,
)
.expect("naming should parse");
assert_eq!(naming.level_name, "雨夜猫街");
assert_eq!(
naming.work_description.as_deref(),
Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图")
);
assert_eq!(naming.work_tags.len(), module_puzzle::PUZZLE_MAX_TAG_COUNT);
assert!(naming.work_tags.contains(&"雨夜".to_string()));
assert!(naming.work_tags.contains(&"猫咪".to_string()));
assert!(naming.work_tags.contains(&"灯牌".to_string()));
assert_eq!(
naming.ui_background_prompt.as_deref(),
Some("雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次")
);
}
#[test]
fn puzzle_level_naming_parser_filters_forbidden_ui_prompt_words() {
let naming = parse_puzzle_level_naming_from_text(
r#"{"levelName":"雨夜猫街","uiBackgroundPrompt":"雨夜老街背景中央不要出现拼图槽、棋盘、HUD、按钮、文字或水印保留暖色灯光"}"#,
)
.expect("naming should parse");
let prompt = naming
.ui_background_prompt
.as_deref()
.expect("prompt should parse");
assert!(!prompt.contains("拼图槽"));
assert!(!prompt.contains("棋盘"));
assert!(!prompt.contains("HUD"));
assert!(!prompt.contains("按钮"));
assert!(!prompt.contains("文字"));
assert!(!prompt.contains("水印"));
}
#[test]
fn puzzle_first_level_name_fallback_uses_picture_keywords() {
assert_eq!(
build_fallback_puzzle_first_level_name("一只猫在雨夜灯牌下回头。"),
"雨夜猫街"
);
assert_eq!(
build_fallback_puzzle_first_level_name("看不出关键词的抽象色块。"),
"奇境初见"
);
}
#[test]
fn puzzle_level_name_image_data_url_downsizes_generated_image() {
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
let mut cursor = std::io::Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.expect("test image should encode");
let downloaded = PuzzleDownloadedImage {
extension: "png".to_string(),
mime_type: "image/png".to_string(),
bytes: cursor.into_inner(),
};
let data_url =
build_puzzle_level_name_image_data_url(&downloaded).expect("data url should be generated");
assert!(data_url.starts_with("data:image/png;base64,"));
assert!(data_url.len() > "data:image/png;base64,".len());
}
#[test]
fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
let resolved = PuzzleResolvedReferenceImage {
mime_type: "image/png".to_string(),
bytes_len: 8,
bytes: b"pngbytes".to_vec(),
};
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
assert_eq!(downloaded.extension, "png");
assert_eq!(downloaded.mime_type, "image/png");
assert_eq!(downloaded.bytes, b"pngbytes");
}
#[test]
fn puzzle_first_level_name_snapshot_defaults_work_title() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "猫画面",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": null,
"cover_asset_id": null,
"generation_status": "idle",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
should_auto_name_level: None,
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("猫画面".to_string()),
work_description: None,
picture_description: None,
level_name: None,
summary: None,
theme_tags: Some(vec![]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
let renamed = apply_generated_puzzle_first_level_name_to_session_snapshot(
session,
"puzzle-level-1",
"雨夜猫街",
"猫画面",
1_713_686_401_234_568,
);
let draft = renamed.draft.expect("draft");
assert_eq!(draft.level_name, "雨夜猫街");
assert_eq!(draft.work_title, "雨夜猫街");
assert_eq!(draft.levels[0].level_name, "雨夜猫街");
}
#[test]
fn puzzle_initial_metadata_defaults_empty_work_description_and_tags() {
let mut session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
current_turn: 1,
progress_percent: 94,
stage: "ready_to_publish".to_string(),
anchor_pack: test_puzzle_anchor_pack_record(),
draft: Some(test_puzzle_draft_record()),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
{
let draft = session.draft.as_mut().expect("draft");
draft.work_title = "猫画面".to_string();
draft.work_description = String::new();
draft.summary = String::new();
draft.theme_tags = Vec::new();
}
let metadata = PuzzleLevelNaming {
level_name: "雨夜猫街".to_string(),
work_description: Some("在湿润灯牌与猫影之间完成一套雨夜街角拼图".to_string()),
work_tags: vec![
"插画".to_string(),
"灯牌".to_string(),
"街角".to_string(),
"猫咪".to_string(),
"暖色".to_string(),
"雨夜".to_string(),
],
ui_background_prompt: None,
};
let session = apply_generated_puzzle_initial_metadata_to_session_snapshot(
session,
&metadata,
"猫画面",
1_713_686_401_234_568,
);
let draft = session.draft.expect("draft");
assert_eq!(draft.work_title, "雨夜猫街");
assert_eq!(
draft.work_description,
"在湿润灯牌与猫影之间完成一套雨夜街角拼图"
);
assert_eq!(draft.summary, draft.work_description);
assert_eq!(draft.theme_tags, metadata.work_tags);
}
#[test]
fn puzzle_level_audio_asset_roundtrips_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: Some(CreationAudioAsset {
task_id: "suno-task-1".to_string(),
provider: "vector-engine-suno".to_string(),
asset_object_id: Some("assetobj_1".to_string()),
asset_kind: Some("puzzle_background_music".to_string()),
audio_src: "/generated-puzzle-assets/audio.mp3".to_string(),
prompt: Some("轻快拼图音乐".to_string()),
title: Some("雨夜猫街背景音乐".to_string()),
updated_at: Some("2026-05-11T00:00:00Z".to_string()),
}),
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "ready".to_string(),
};
let request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["background_music"]["audio_src"],
Value::String("/generated-puzzle-assets/audio.mp3".to_string())
);
assert!(payload[0]["background_music"].get("audioSrc").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
let music = records[0]
.background_music
.as_ref()
.expect("background music should exist");
assert_eq!(music.audio_src, "/generated-puzzle-assets/audio.mp3");
assert_eq!(music.asset_kind.as_deref(), Some("puzzle_background_music"));
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response
.background_music
.as_ref()
.map(|asset| asset.audio_src.as_str()),
Some("/generated-puzzle-assets/audio.mp3")
);
}
#[test]
fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
let level = PuzzleDraftLevelResponse {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: Some("雨夜猫街竖屏拼图UI背景".to_string()),
ui_background_image_src: Some(
"/generated-puzzle-assets/session/ui/background.png".to_string(),
),
ui_background_image_object_key: Some(
"generated-puzzle-assets/session/ui/background.png".to_string(),
),
background_music: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "ready".to_string(),
};
let request_context = RequestContext::new(
"test-request".to_string(),
"PUT /api/runtime/puzzle/works/test".to_string(),
Duration::ZERO,
false,
);
let levels_json = serialize_puzzle_levels_response(&request_context, &[level])
.expect("levels should serialize");
let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse");
assert_eq!(
payload[0]["ui_background_prompt"],
Value::String("雨夜猫街竖屏拼图UI背景".to_string())
);
assert!(payload[0].get("uiBackgroundPrompt").is_none());
let records = parse_puzzle_level_records_from_module_json(&levels_json)
.expect("levels should map back into records");
assert_eq!(
records[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
let response = map_puzzle_draft_level_response(records[0].clone());
assert_eq!(
response.ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
#[test]
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
let level = PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "雨夜猫街".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: vec![PuzzleGeneratedImageCandidateRecord {
candidate_id: "candidate-1".to_string(),
image_src: "/generated-puzzle-assets/session/candidate-1.png".to_string(),
asset_id: "asset-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: None,
source_type: "generated".to_string(),
selected: true,
}],
selected_candidate_id: Some("candidate-1".to_string()),
cover_image_src: Some("/generated-puzzle-assets/session/cover.png".to_string()),
cover_asset_id: Some("asset-1".to_string()),
generation_status: "generating".to_string(),
};
let response = map_puzzle_work_summary_response(
&state,
PuzzleWorkProfileRecord {
work_id: "puzzle-work-1".to_string(),
profile_id: "puzzle-profile-1".to_string(),
owner_user_id: "user-1".to_string(),
source_session_id: Some("puzzle-session-1".to_string()),
author_display_name: "玩家".to_string(),
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "雨夜猫街".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec!["".to_string()],
cover_image_src: None,
cover_asset_id: None,
publication_status: "draft".to_string(),
updated_at: "2026-05-08T00:00:00.000Z".to_string(),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
publish_ready: false,
anchor_pack: test_puzzle_anchor_pack_record(),
levels: vec![level],
},
);
assert_eq!(response.levels.len(), 1);
assert_eq!(response.generation_status.as_deref(), Some("ready"));
assert_eq!(response.levels[0].generation_status, "ready");
assert_eq!(
response.levels[0].cover_image_src.as_deref(),
Some("/generated-puzzle-assets/session/cover.png")
);
assert_eq!(
response.levels[0].candidates[0].image_src,
"/generated-puzzle-assets/session/candidate-1.png"
);
}
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
assert!(prompt.contains("9:16"));
assert!(prompt.contains("纯背景图"));
assert!(prompt.contains("不得出现拼图槽"));
assert!(prompt.contains("默认拼图槽"));
assert!(prompt.contains("文字"));
}
#[test]
fn puzzle_initial_ui_background_prompt_prefers_ai_generated_prompt() {
let mut draft = test_puzzle_draft_record();
draft.work_title = "模板作品名".to_string();
draft.work_description = "模板作品描述".to_string();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let ai_prompt = "雨夜老街延展成竖屏空间,湿润石板路倒映暖色灯牌,远处屋檐和薄雾形成柔和层次";
target_level.ui_background_prompt = Some(ai_prompt.to_string());
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert_eq!(prompt, ai_prompt);
assert!(!prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_initial_ui_background_prompt_falls_back_to_context_template() {
let draft = test_puzzle_draft_record();
let target_level = draft.levels[0].clone();
let prompt = resolve_puzzle_initial_ui_background_prompt(&draft, &target_level);
assert!(prompt.contains("雨夜猫街"));
assert!(prompt.contains(PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER));
}
#[test]
fn puzzle_ui_background_initial_attach_updates_first_level_fields() {
let draft = test_puzzle_draft_record();
let generated = GeneratedPuzzleUiBackgroundResponse {
image_src: "/generated-puzzle-assets/session/ui/background.png".to_string(),
object_key: "generated-puzzle-assets/session/ui/background.png".to_string(),
};
let mut levels = draft.levels.clone();
attach_puzzle_level_ui_background(
&mut levels,
"puzzle-level-1",
"雨夜猫街移动端拼图UI背景".to_string(),
generated,
);
assert_eq!(
levels[0].ui_background_prompt.as_deref(),
Some("雨夜猫街移动端拼图UI背景")
);
assert_eq!(
levels[0].ui_background_image_src.as_deref(),
Some("/generated-puzzle-assets/session/ui/background.png")
);
assert_eq!(
levels[0].ui_background_image_object_key.as_deref(),
Some("generated-puzzle-assets/session/ui/background.png")
);
}
#[test]
fn puzzle_initial_draft_assets_must_include_ui_background() {
let mut draft = test_puzzle_draft_record();
let missing_all = ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect_err("缺少自动生成资产时不能把草稿标记为完成");
assert_eq!(missing_all.status_code(), StatusCode::BAD_GATEWAY);
assert!(missing_all.body_text().contains("UI背景图"));
draft.levels[0].ui_background_image_src =
Some("/generated-puzzle-assets/session/ui/background.png".to_string());
ensure_puzzle_initial_level_assets_ready(&draft.levels[0])
.expect("UI 背景存在时即可完成自动草稿资源检查");
}
fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord {
let item = PuzzleAnchorItemRecord {
key: "visualSubject".to_string(),
label: "画面".to_string(),
value: "雨夜猫街".to_string(),
status: "confirmed".to_string(),
};
PuzzleAnchorPackRecord {
theme_promise: item.clone(),
visual_subject: item.clone(),
visual_mood: item.clone(),
composition_hooks: item.clone(),
tags_and_forbidden: item,
}
}
fn test_puzzle_draft_record() -> PuzzleResultDraftRecord {
let anchor_pack = test_puzzle_anchor_pack_record();
PuzzleResultDraftRecord {
work_title: "雨夜猫街".to_string(),
work_description: "一只猫在雨夜灯牌下回头。".to_string(),
level_name: "猫画面".to_string(),
summary: "一只猫在雨夜灯牌下回头。".to_string(),
theme_tags: vec![],
forbidden_directives: vec![],
creator_intent: None,
anchor_pack,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels: vec![PuzzleDraftLevelRecord {
level_id: "puzzle-level-1".to_string(),
level_name: "猫画面".to_string(),
picture_description: "一只猫在雨夜灯牌下回头。".to_string(),
picture_reference: None,
ui_background_prompt: None,
ui_background_image_src: None,
ui_background_image_object_key: None,
background_music: None,
candidates: vec![],
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
}],
form_draft: None,
}
}
#[test]
fn puzzle_primary_level_update_preserves_reference_for_regeneration() {
let draft = test_puzzle_draft_record();
let mut target_level = draft.levels[0].clone();
target_level.level_name = "雨夜猫街".to_string();
let levels = build_puzzle_levels_with_primary_update(
&draft,
&target_level,
Some("data:image/png;base64,abcd"),
);
assert_eq!(levels[0].level_name, "雨夜猫街");
assert_eq!(
levels[0].picture_reference.as_deref(),
Some("data:image/png;base64,abcd")
);
}
#[test]
fn puzzle_generated_fallback_snapshot_preserves_picture_reference() {
let anchor_pack = test_puzzle_anchor_pack_record();
let session = PuzzleAgentSessionRecord {
session_id: "puzzle-session-1".to_string(),
seed_text: "雨夜猫街".to_string(),
current_turn: 1,
progress_percent: 0,
stage: "draft_ready".to_string(),
anchor_pack: anchor_pack.clone(),
draft: Some(test_puzzle_draft_record()),
messages: Vec::new(),
last_assistant_reply: None,
published_profile_id: None,
suggested_actions: Vec::new(),
result_preview: None,
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: "puzzle-session-1-candidate-1".to_string(),
image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(),
asset_id: "puzzle-cover-1".to_string(),
prompt: "雨夜猫街".to_string(),
actual_prompt: Some("雨夜猫街".to_string()),
source_type: "generated:gpt-image-2".to_string(),
selected: true,
};
let session = apply_generated_puzzle_candidates_to_session_snapshot(
session,
"puzzle-level-1",
vec![candidate],
Some("data:image/png;base64,abcd"),
1_713_686_401_234_568,
);
let draft = session.draft.expect("draft");
assert_eq!(
draft.levels[0].picture_reference.as_deref(),
Some("data:image/png;base64,abcd")
);
}
#[test]
fn freeze_boundary_sync_only_matches_freeze_invalid_operation() {
let invalid_operation = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "操作不合法",
}));
let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "泥点余额不足",
}));
assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true));
assert!(!should_sync_puzzle_freeze_boundary(
&invalid_operation,
false
));
assert!(!should_sync_puzzle_freeze_boundary(&other_error, true));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
use std::{
sync::Arc,
time::{Duration, Instant},
};
use axum::response::Response;
use bytes::Bytes;
use shared_contracts::{
puzzle_gallery::{PuzzleGalleryResponse, PuzzleGalleryWorkRefResponse},
puzzle_works::PuzzleWorkSummaryResponse,
};
use tokio::{
sync::{Mutex, MutexGuard, OwnedMutexGuard, RwLock},
time,
};
use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext};
const PUZZLE_GALLERY_PRIMARY_ITEM_COUNT: usize = 10;
const PUZZLE_GALLERY_PREVIEW_REF_COUNT: usize = 10;
const PUZZLE_GALLERY_CACHE_TTL: Duration = Duration::from_secs(5);
const PUZZLE_GALLERY_CACHE_MAX_IDLE: Duration = Duration::from_secs(300);
const PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
#[derive(Clone, Debug)]
pub struct PuzzleGalleryCache {
inner: Arc<RwLock<Option<PuzzleGalleryCacheEntry>>>,
rebuild_lock: Arc<Mutex<()>>,
}
#[derive(Clone, Debug)]
struct PuzzleGalleryCacheEntry {
data_json: Bytes,
built_at: Instant,
}
#[derive(Clone, Debug)]
pub struct PuzzleGalleryCachedResponse {
data_json: Bytes,
}
impl PuzzleGalleryCachedResponse {
pub fn data_json_len(&self) -> usize {
self.data_json.len()
}
}
impl PuzzleGalleryCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
rebuild_lock: Arc::new(Mutex::new(())),
}
}
pub async fn acquire_rebuild_guard(&self) -> MutexGuard<'_, ()> {
self.rebuild_lock.lock().await
}
pub async fn read_fresh_response(&self) -> Option<PuzzleGalleryCachedResponse> {
let guard = self.inner.read().await;
let entry = guard.as_ref()?;
let now = Instant::now();
if now.duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_TTL {
return None;
}
Some(PuzzleGalleryCachedResponse {
data_json: entry.data_json.clone(),
})
}
pub async fn read_stale_response(&self) -> Option<PuzzleGalleryCachedResponse> {
let guard = self.inner.read().await;
let entry = guard.as_ref()?;
Some(PuzzleGalleryCachedResponse {
data_json: entry.data_json.clone(),
})
}
pub fn try_acquire_owned_rebuild_guard(&self) -> Option<OwnedMutexGuard<()>> {
self.rebuild_lock.clone().try_lock_owned().ok()
}
pub async fn store_response(
&self,
response: PuzzleGalleryResponse,
) -> Result<PuzzleGalleryCachedResponse, serde_json::Error> {
let now = Instant::now();
let cached = PuzzleGalleryCachedResponse {
data_json: Bytes::from(serde_json::to_vec(&response)?),
};
*self.inner.write().await = Some(PuzzleGalleryCacheEntry {
data_json: cached.data_json.clone(),
built_at: now,
});
Ok(cached)
}
pub fn spawn_cleanup_task(&self) {
let cache = self.clone();
tokio::spawn(async move {
let mut interval = time::interval(PUZZLE_GALLERY_CACHE_CLEANUP_INTERVAL);
loop {
interval.tick().await;
cache.cleanup_idle_entry().await;
}
});
}
async fn cleanup_idle_entry(&self) {
let mut guard = self.inner.write().await;
if let Some(entry) = guard.as_ref()
&& Instant::now().duration_since(entry.built_at) > PUZZLE_GALLERY_CACHE_MAX_IDLE
{
*guard = None;
}
}
}
pub fn build_puzzle_gallery_window_response(
items: Vec<PuzzleWorkSummaryResponse>,
) -> PuzzleGalleryResponse {
let total_count = items.len().min(u32::MAX as usize) as u32;
let preview_refs = items
.iter()
.skip(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT)
.take(PUZZLE_GALLERY_PREVIEW_REF_COUNT)
.map(|item| PuzzleGalleryWorkRefResponse {
work_id: item.work_id.clone(),
profile_id: item.profile_id.clone(),
})
.collect::<Vec<_>>();
let next_cursor = items
.get(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT)
.map(|item| item.profile_id.clone());
let has_more =
items.len() > PUZZLE_GALLERY_PRIMARY_ITEM_COUNT + PUZZLE_GALLERY_PREVIEW_REF_COUNT;
PuzzleGalleryResponse {
items: items
.into_iter()
.take(PUZZLE_GALLERY_PRIMARY_ITEM_COUNT)
.collect(),
preview_refs,
has_more,
next_cursor,
total_count,
}
}
pub fn puzzle_gallery_cached_json(
request_context: &RequestContext,
response: PuzzleGalleryCachedResponse,
) -> Response {
json_success_data_bytes_response(Some(request_context), response.data_json)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_summary(index: usize) -> PuzzleWorkSummaryResponse {
PuzzleWorkSummaryResponse {
work_id: format!("work-{index}"),
profile_id: format!("profile-{index}"),
owner_user_id: "user-1".to_string(),
source_session_id: None,
author_display_name: "作者".to_string(),
work_title: format!("作品 {index}"),
work_description: "描述".to_string(),
level_name: "第一关".to_string(),
summary: "摘要".to_string(),
theme_tags: Vec::new(),
cover_image_src: None,
cover_asset_id: None,
publication_status: "published".to_string(),
updated_at: "2026-05-01T00:00:00Z".to_string(),
published_at: None,
play_count: 0,
remix_count: 0,
like_count: 0,
recent_play_count_7d: 0,
point_incentive_total_half_points: 0,
point_incentive_claimed_points: 0,
point_incentive_total_points: 0.0,
point_incentive_claimable_points: 0,
publish_ready: true,
generation_status: Some("ready".to_string()),
levels: Vec::new(),
}
}
#[test]
fn build_window_returns_primary_cards_preview_refs_and_cursor() {
let response =
build_puzzle_gallery_window_response((0..25).map(build_summary).collect::<Vec<_>>());
assert_eq!(response.total_count, 25);
assert_eq!(response.items.len(), 10);
assert_eq!(response.preview_refs.len(), 10);
assert_eq!(response.items[0].profile_id, "profile-0");
assert_eq!(response.items[9].profile_id, "profile-9");
assert_eq!(response.preview_refs[0].profile_id, "profile-10");
assert_eq!(response.preview_refs[9].profile_id, "profile-19");
assert!(response.has_more);
assert_eq!(response.next_cursor.as_deref(), Some("profile-20"));
}
#[test]
fn build_window_handles_short_gallery_without_more_cursor() {
let response =
build_puzzle_gallery_window_response((0..8).map(build_summary).collect::<Vec<_>>());
assert_eq!(response.total_count, 8);
assert_eq!(response.items.len(), 8);
assert!(response.preview_refs.is_empty());
assert!(!response.has_more);
assert_eq!(response.next_cursor, None);
}
#[tokio::test]
async fn stale_response_remains_readable_after_fresh_ttl() {
let cache = PuzzleGalleryCache::new();
let response =
build_puzzle_gallery_window_response((0..8).map(build_summary).collect::<Vec<_>>());
cache
.store_response(response)
.await
.expect("cache response should serialize");
{
let mut guard = cache.inner.write().await;
let entry = guard.as_mut().expect("cache entry should exist");
entry.built_at = Instant::now() - PUZZLE_GALLERY_CACHE_TTL - Duration::from_secs(1);
}
assert!(cache.read_fresh_response().await.is_none());
assert!(cache.read_stale_response().await.is_some());
}
#[tokio::test]
async fn try_owned_rebuild_guard_allows_only_one_refresher() {
let cache = PuzzleGalleryCache::new();
let first_guard = cache.try_acquire_owned_rebuild_guard();
assert!(first_guard.is_some());
assert!(cache.try_acquire_owned_rebuild_guard().is_none());
drop(first_guard);
assert!(cache.try_acquire_owned_rebuild_guard().is_some());
}
}

View File

@@ -6,6 +6,7 @@ use std::{
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
use axum::extract::FromRef;
use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{ use module_auth::{
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
@@ -27,20 +28,126 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::sync::Semaphore;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::config::AppConfig; use crate::config::AppConfig;
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
use crate::tracking_outbox::TrackingOutbox;
use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error}; use crate::wechat_pay::{WechatPayClient, map_wechat_pay_init_error};
use crate::wechat_provider::build_wechat_provider; use crate::wechat_provider::build_wechat_provider;
const ADMIN_ROLE: &str = "admin"; const ADMIN_ROLE: &str = "admin";
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。 pub type HttpRequestPermitPool = Semaphore;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HttpRequestPermitPoolKind {
Default,
Gallery,
Detail,
Admin,
}
impl HttpRequestPermitPoolKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Gallery => "gallery",
Self::Detail => "detail",
Self::Admin => "admin",
}
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AppState { pub struct HttpRequestPermitPools {
default: Option<Arc<HttpRequestPermitPool>>,
gallery: Option<Arc<HttpRequestPermitPool>>,
detail: Option<Arc<HttpRequestPermitPool>>,
admin: Option<Arc<HttpRequestPermitPool>>,
}
impl HttpRequestPermitPools {
fn from_config(config: &AppConfig) -> Self {
Self {
default: config
.max_concurrent_requests
.map(HttpRequestPermitPool::new)
.map(Arc::new),
gallery: config
.gallery_max_concurrent_requests
.map(HttpRequestPermitPool::new)
.map(Arc::new),
detail: config
.detail_max_concurrent_requests
.map(HttpRequestPermitPool::new)
.map(Arc::new),
admin: config
.admin_max_concurrent_requests
.map(HttpRequestPermitPool::new)
.map(Arc::new),
}
}
pub fn pool(
&self,
kind: HttpRequestPermitPoolKind,
) -> Option<(HttpRequestPermitPoolKind, Arc<HttpRequestPermitPool>)> {
let selected = match kind {
HttpRequestPermitPoolKind::Default => self.default.clone(),
HttpRequestPermitPoolKind::Gallery => self.gallery.clone(),
HttpRequestPermitPoolKind::Detail => self.detail.clone(),
HttpRequestPermitPoolKind::Admin => self.admin.clone(),
};
selected.map(|pool| (kind, pool)).or_else(|| {
self.default
.clone()
.map(|pool| (HttpRequestPermitPoolKind::Default, pool))
})
}
}
#[derive(Clone, Debug)]
pub struct BackpressureState {
permit_pools: HttpRequestPermitPools,
}
impl BackpressureState {
pub fn request_permit_pool(
&self,
kind: HttpRequestPermitPoolKind,
) -> Option<(HttpRequestPermitPoolKind, Arc<HttpRequestPermitPool>)> {
self.permit_pools.pool(kind)
}
}
#[derive(Clone, Debug)]
pub struct AppState(Arc<AppStateInner>);
impl std::ops::Deref for AppState {
type Target = AppStateInner;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl FromRef<AppState> for BackpressureState {
fn from_ref(state: &AppState) -> Self {
Self {
permit_pools: state.http_request_permit_pools(),
}
}
}
// Axum/Hyper 会在路由树和连接 service 上频繁 clone stateAppState 外层必须保持浅拷贝。
#[derive(Debug)]
pub struct AppStateInner {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。 // 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)] #[allow(dead_code)]
pub config: AppConfig, pub config: AppConfig,
http_request_permit_pools: HttpRequestPermitPools,
auth_jwt_config: JwtConfig, auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>, admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig, refresh_cookie_config: RefreshCookieConfig,
@@ -60,6 +167,8 @@ pub struct AppState {
#[cfg_attr(not(test), allow(dead_code))] #[cfg_attr(not(test), allow(dead_code))]
ai_task_service: AiTaskService, ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient, spacetime_client: SpacetimeClient,
puzzle_gallery_cache: PuzzleGalleryCache,
tracking_outbox: Option<Arc<TrackingOutbox>>,
llm_client: Option<LlmClient>, llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>, creative_agent_gpt5_client: Option<LlmClient>,
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>, creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
@@ -190,11 +299,14 @@ impl AppState {
pool_size: config.spacetime_pool_size, pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout, procedure_timeout: config.spacetime_procedure_timeout,
}); });
let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone());
let llm_client = build_llm_client(&config)?; let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
Ok(Self { Ok(Self(Arc::new(AppStateInner {
config, config,
http_request_permit_pools,
auth_jwt_config, auth_jwt_config,
admin_runtime, admin_runtime,
refresh_cookie_config, refresh_cookie_config,
@@ -214,13 +326,15 @@ impl AppState {
wechat_pay_client, wechat_pay_client,
ai_task_service, ai_task_service,
spacetime_client, spacetime_client,
puzzle_gallery_cache: PuzzleGalleryCache::new(),
tracking_outbox,
llm_client, llm_client,
creative_agent_gpt5_client, creative_agent_gpt5_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
#[cfg(test)] #[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
}) })))
} }
pub fn auth_jwt_config(&self) -> &JwtConfig { pub fn auth_jwt_config(&self) -> &JwtConfig {
@@ -235,6 +349,10 @@ impl AppState {
&self.refresh_cookie_config &self.refresh_cookie_config
} }
pub fn http_request_permit_pools(&self) -> HttpRequestPermitPools {
self.http_request_permit_pools.clone()
}
pub async fn upsert_creation_entry_type_config( pub async fn upsert_creation_entry_type_config(
&self, &self,
input: module_runtime::CreationEntryTypeAdminUpsertInput, input: module_runtime::CreationEntryTypeAdminUpsertInput,
@@ -464,6 +582,14 @@ impl AppState {
&self.spacetime_client &self.spacetime_client
} }
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
&self.puzzle_gallery_cache
}
pub fn tracking_outbox(&self) -> Option<Arc<TrackingOutbox>> {
self.tracking_outbox.clone()
}
pub fn llm_client(&self) -> Option<&LlmClient> { pub fn llm_client(&self) -> Option<&LlmClient> {
self.llm_client.as_ref() self.llm_client.as_ref()
} }

View File

@@ -0,0 +1,512 @@
use axum::{
body::Body,
extract::State,
http::{HeaderMap, Request, Response},
middleware::Next,
};
use http_body_util::BodyExt;
use opentelemetry::{KeyValue, global, metrics::Counter};
use std::sync::{
Arc, OnceLock,
atomic::{AtomicI64, Ordering},
};
use tracing::{info, warn};
use crate::{
request_context::resolve_request_id,
state::{AppState, HttpRequestPermitPoolKind},
};
static HTTP_RESPONSE_BODY_IN_FLIGHT: AtomicI64 = AtomicI64::new(0);
static TRACKING_OUTBOX_PENDING_BYTES: AtomicI64 = AtomicI64::new(0);
static TRACKING_OUTBOX_PENDING_FILES: AtomicI64 = AtomicI64::new(0);
static HTTP_REQUEST_PERMITS_AVAILABLE: OnceLock<HttpRequestPermitsAvailableGauges> =
OnceLock::new();
// 集中维护 api-server HTTP 观测,避免在 handler 中散落高基数字段或重复创建 instrument。
pub async fn record_http_observability(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response<Body> {
let method = request.method().as_str().to_string();
let route = observability_route(request.uri().path());
let scheme = resolve_request_scheme(request.headers());
let path = request.uri().path().to_string();
let request_id = resolve_request_id(&request).unwrap_or_else(|| "unknown".to_string());
let base_labels = http_base_labels(method.clone(), route.clone());
let metrics = http_metrics();
metrics.in_flight.add(1, &base_labels);
let started_at = std::time::Instant::now();
let response = next.run(request).await;
let status = response.status().as_u16();
let status_class = status_class(status);
let latency_ms = started_at.elapsed().as_millis().min(u64::MAX as u128) as u64;
let slow_request = latency_ms >= state.config.slow_request_threshold_ms;
let labels = http_response_labels(base_labels, status);
metrics.requests.add(1, &labels);
metrics
.duration
.record(started_at.elapsed().as_secs_f64(), &labels);
metrics.in_flight.add(-1, &labels[..2]);
if slow_request {
warn!(
request_id = %request_id,
http.request.method = %method,
http.route = %route,
url.scheme = %scheme,
url.path = %path,
http.response.status_code = status,
status,
status_class,
latency_ms,
slow_request = true,
"http request completed slowly"
);
} else {
info!(
request_id = %request_id,
http.request.method = %method,
http.route = %route,
url.scheme = %scheme,
url.path = %path,
http.response.status_code = status,
status,
status_class,
latency_ms,
slow_request = false,
"http request completed"
);
}
track_response_body_in_flight(response)
}
pub(crate) fn update_http_request_permits_available(
pool: HttpRequestPermitPoolKind,
available: usize,
) {
HTTP_REQUEST_PERMITS_AVAILABLE
.get_or_init(register_http_request_permits_available_metric)
.store(pool, available);
}
pub(crate) fn record_puzzle_gallery_cache_hit() {
puzzle_gallery_cache_metrics().hits.add(1, &[]);
}
pub(crate) fn record_puzzle_gallery_cache_stale_hit() {
puzzle_gallery_cache_metrics().stale_hits.add(1, &[]);
}
pub(crate) fn record_puzzle_gallery_cache_miss() {
puzzle_gallery_cache_metrics().misses.add(1, &[]);
}
pub(crate) fn record_puzzle_gallery_cache_refresh_started() {
puzzle_gallery_cache_metrics().refreshes_started.add(1, &[]);
}
pub(crate) fn record_puzzle_gallery_cache_refresh_failed() {
puzzle_gallery_cache_metrics().refreshes_failed.add(1, &[]);
}
pub(crate) fn record_puzzle_gallery_cache_rebuild(
duration: std::time::Duration,
data_bytes: usize,
) {
let metrics = puzzle_gallery_cache_metrics();
metrics.rebuilds.add(1, &[]);
metrics.rebuild_duration.record(duration.as_secs_f64(), &[]);
metrics
.data_json_bytes
.record(data_bytes.min(u64::MAX as usize) as u64, &[]);
}
pub(crate) fn record_tracking_outbox_enqueued() {
tracking_outbox_metrics().enqueued.add(1, &[]);
}
pub(crate) fn record_tracking_outbox_dropped(reason: &'static str) {
tracking_outbox_metrics()
.dropped
.add(1, &[KeyValue::new("reason", reason)]);
}
pub(crate) fn record_tracking_outbox_sealed(reason: &'static str) {
tracking_outbox_metrics()
.sealed_files
.add(1, &[KeyValue::new("reason", reason)]);
}
pub(crate) fn record_tracking_outbox_corrupt_file() {
tracking_outbox_metrics().corrupt_files.add(1, &[]);
}
pub(crate) fn record_tracking_outbox_flush(
duration: std::time::Duration,
accepted_count: u32,
file_bytes: u64,
failed: bool,
) {
let status_class = if failed { "error" } else { "ok" };
let labels = [KeyValue::new("status_class", status_class)];
let metrics = tracking_outbox_metrics();
metrics.flushes.add(1, &labels);
metrics
.flush_duration
.record(duration.as_secs_f64(), &labels);
metrics
.flushed_events
.add(u64::from(accepted_count), &labels);
metrics.flushed_bytes.add(file_bytes, &labels);
}
pub(crate) fn update_tracking_outbox_pending_bytes(bytes: u64) {
TRACKING_OUTBOX_PENDING_BYTES.store(bytes.min(i64::MAX as u64) as i64, Ordering::Relaxed);
}
pub(crate) fn update_tracking_outbox_pending_files(files: usize) {
TRACKING_OUTBOX_PENDING_FILES.store(files.min(i64::MAX as usize) as i64, Ordering::Relaxed);
}
fn track_response_body_in_flight(response: Response<Body>) -> Response<Body> {
response.map(|body| {
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_add(1, Ordering::Relaxed);
let guard = ResponseBodyInFlightGuard;
Body::new(body.map_frame(move |frame| {
let _guard = &guard;
frame
}))
})
}
struct HttpMetrics {
requests: Counter<u64>,
in_flight: opentelemetry::metrics::UpDownCounter<i64>,
duration: opentelemetry::metrics::Histogram<f64>,
}
struct PuzzleGalleryCacheMetrics {
hits: Counter<u64>,
stale_hits: Counter<u64>,
misses: Counter<u64>,
refreshes_started: Counter<u64>,
refreshes_failed: Counter<u64>,
rebuilds: Counter<u64>,
rebuild_duration: opentelemetry::metrics::Histogram<f64>,
data_json_bytes: opentelemetry::metrics::Histogram<u64>,
}
struct TrackingOutboxMetrics {
enqueued: Counter<u64>,
dropped: Counter<u64>,
sealed_files: Counter<u64>,
corrupt_files: Counter<u64>,
flushes: Counter<u64>,
flush_duration: opentelemetry::metrics::Histogram<f64>,
flushed_events: Counter<u64>,
flushed_bytes: Counter<u64>,
}
struct HttpRequestPermitsAvailableGauges {
default: Arc<AtomicI64>,
gallery: Arc<AtomicI64>,
detail: Arc<AtomicI64>,
admin: Arc<AtomicI64>,
}
impl HttpRequestPermitsAvailableGauges {
fn new() -> Self {
Self {
default: Arc::new(AtomicI64::new(0)),
gallery: Arc::new(AtomicI64::new(0)),
detail: Arc::new(AtomicI64::new(0)),
admin: Arc::new(AtomicI64::new(0)),
}
}
fn store(&self, pool: HttpRequestPermitPoolKind, available: usize) {
let value = available.min(i64::MAX as usize) as i64;
match pool {
HttpRequestPermitPoolKind::Default => &self.default,
HttpRequestPermitPoolKind::Gallery => &self.gallery,
HttpRequestPermitPoolKind::Detail => &self.detail,
HttpRequestPermitPoolKind::Admin => &self.admin,
}
.store(value, Ordering::Relaxed);
}
}
struct ResponseBodyInFlightGuard;
impl Drop for ResponseBodyInFlightGuard {
fn drop(&mut self) {
HTTP_RESPONSE_BODY_IN_FLIGHT.fetch_sub(1, Ordering::Relaxed);
}
}
fn http_metrics() -> &'static HttpMetrics {
static METRICS: std::sync::OnceLock<HttpMetrics> = std::sync::OnceLock::new();
METRICS.get_or_init(|| {
let meter = global::meter("genarrative-api");
HttpMetrics {
requests: meter
.u64_counter("genarrative.http.server.requests")
.with_description("HTTP request count grouped by route and status class")
.build(),
in_flight: meter
.i64_up_down_counter("http.server.active_requests")
.with_unit("{request}")
.with_description("Number of active HTTP server requests")
.build(),
duration: meter
.f64_histogram("http.server.request.duration")
.with_unit("s")
.with_description("Duration of HTTP server requests")
.build(),
}
})
}
fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics {
static METRICS: std::sync::OnceLock<PuzzleGalleryCacheMetrics> = std::sync::OnceLock::new();
METRICS.get_or_init(|| {
let meter = global::meter("genarrative-api");
PuzzleGalleryCacheMetrics {
hits: meter
.u64_counter("genarrative.puzzle_gallery.cache.hits")
.with_description("Puzzle gallery response cache hits")
.build(),
stale_hits: meter
.u64_counter("genarrative.puzzle_gallery.cache.stale_hits")
.with_description("Puzzle gallery stale response cache hits")
.build(),
misses: meter
.u64_counter("genarrative.puzzle_gallery.cache.misses")
.with_description("Puzzle gallery response cache misses")
.build(),
refreshes_started: meter
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_started")
.with_description("Puzzle gallery background refresh start count")
.build(),
refreshes_failed: meter
.u64_counter("genarrative.puzzle_gallery.cache.refreshes_failed")
.with_description("Puzzle gallery background refresh failure count")
.build(),
rebuilds: meter
.u64_counter("genarrative.puzzle_gallery.cache.rebuilds")
.with_description("Puzzle gallery response cache rebuild count")
.build(),
rebuild_duration: meter
.f64_histogram("genarrative.puzzle_gallery.cache.rebuild.duration")
.with_unit("s")
.with_description("Puzzle gallery response cache rebuild duration")
.build(),
data_json_bytes: meter
.u64_histogram("genarrative.puzzle_gallery.cache.data_json_bytes")
.with_unit("By")
.with_description("Serialized puzzle gallery data JSON size")
.build(),
}
})
}
fn tracking_outbox_metrics() -> &'static TrackingOutboxMetrics {
static METRICS: std::sync::OnceLock<TrackingOutboxMetrics> = std::sync::OnceLock::new();
METRICS.get_or_init(|| {
let meter = global::meter("genarrative-api");
TrackingOutboxMetrics {
enqueued: meter
.u64_counter("genarrative.tracking_outbox.events.enqueued")
.with_description("Tracking events appended to the local outbox")
.build(),
dropped: meter
.u64_counter("genarrative.tracking_outbox.events.dropped")
.with_description("Tracking events dropped by local outbox protection")
.build(),
sealed_files: meter
.u64_counter("genarrative.tracking_outbox.files.sealed")
.with_description("Tracking outbox active files sealed for flushing")
.build(),
corrupt_files: meter
.u64_counter("genarrative.tracking_outbox.files.corrupt")
.with_description(
"Tracking outbox sealed files quarantined because they could not be parsed",
)
.build(),
flushes: meter
.u64_counter("genarrative.tracking_outbox.flushes")
.with_description("Tracking outbox sealed file flush attempts")
.build(),
flush_duration: meter
.f64_histogram("genarrative.tracking_outbox.flush.duration")
.with_unit("s")
.with_description("Tracking outbox sealed file flush duration")
.build(),
flushed_events: meter
.u64_counter("genarrative.tracking_outbox.events.flushed")
.with_description("Tracking events accepted by SpacetimeDB batch procedure")
.build(),
flushed_bytes: meter
.u64_counter("genarrative.tracking_outbox.bytes.flushed")
.with_unit("By")
.with_description("Tracking outbox bytes removed after successful flush")
.build(),
}
})
}
fn register_http_request_permits_available_metric() -> HttpRequestPermitsAvailableGauges {
let gauges = HttpRequestPermitsAvailableGauges::new();
let meter = global::meter("genarrative-api");
let default_gauge = gauges.default.clone();
let gallery_gauge = gauges.gallery.clone();
let detail_gauge = gauges.detail.clone();
let admin_gauge = gauges.admin.clone();
meter
.i64_observable_up_down_counter("genarrative.http.server.request_permits.available")
.with_unit("{permit}")
.with_description("Available api-server HTTP backpressure permits")
.with_callback(move |observer| {
observer.observe(
default_gauge.load(Ordering::Relaxed),
&[KeyValue::new(
"pool",
HttpRequestPermitPoolKind::Default.as_str(),
)],
);
observer.observe(
gallery_gauge.load(Ordering::Relaxed),
&[KeyValue::new(
"pool",
HttpRequestPermitPoolKind::Gallery.as_str(),
)],
);
observer.observe(
detail_gauge.load(Ordering::Relaxed),
&[KeyValue::new(
"pool",
HttpRequestPermitPoolKind::Detail.as_str(),
)],
);
observer.observe(
admin_gauge.load(Ordering::Relaxed),
&[KeyValue::new(
"pool",
HttpRequestPermitPoolKind::Admin.as_str(),
)],
);
})
.build();
gauges
}
pub(crate) fn register_http_runtime_metrics() {
static REGISTERED: OnceLock<()> = OnceLock::new();
REGISTERED.get_or_init(|| {
let meter = global::meter("genarrative-api");
meter
.i64_observable_up_down_counter("genarrative.http.server.response_bodies.in_flight")
.with_unit("{response}")
.with_description("HTTP response bodies still owned by Axum/Hyper")
.with_callback(|observer| {
observer.observe(HTTP_RESPONSE_BODY_IN_FLIGHT.load(Ordering::Relaxed), &[]);
})
.build();
meter
.i64_observable_up_down_counter("genarrative.tracking_outbox.pending.bytes")
.with_unit("By")
.with_description("Tracking outbox bytes waiting on local disk")
.with_callback(|observer| {
observer.observe(TRACKING_OUTBOX_PENDING_BYTES.load(Ordering::Relaxed), &[]);
})
.build();
meter
.i64_observable_up_down_counter("genarrative.tracking_outbox.pending.files")
.with_unit("{file}")
.with_description("Tracking outbox sealed files waiting for flush")
.with_callback(|observer| {
observer.observe(TRACKING_OUTBOX_PENDING_FILES.load(Ordering::Relaxed), &[]);
})
.build();
});
}
fn http_base_labels(method: String, route: String) -> Vec<KeyValue> {
vec![
KeyValue::new("http.request.method", method),
KeyValue::new("http.route", route),
]
}
fn http_response_labels(mut labels: Vec<KeyValue>, status: u16) -> Vec<KeyValue> {
labels.push(KeyValue::new("status_class", status_class(status)));
labels
}
fn status_class(status: u16) -> &'static str {
match status {
100..=199 => "1xx",
200..=299 => "2xx",
300..=399 => "3xx",
400..=499 => "4xx",
500..=599 => "5xx",
_ => "unknown",
}
}
pub(crate) fn observability_route(path: &str) -> String {
if path.starts_with("/api/runtime/puzzle/gallery") {
"/api/runtime/puzzle/gallery".to_string()
} else if path.starts_with("/api/runtime/custom-world-gallery") {
"/api/runtime/custom-world-gallery".to_string()
} else if path.starts_with("/admin/api/") {
"/admin/api/*".to_string()
} else if path.starts_with("/api/") {
"/api/*".to_string()
} else {
"other".to_string()
}
}
pub(crate) fn resolve_request_scheme(headers: &HeaderMap) -> String {
headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http")
.to_string()
}
#[cfg(test)]
mod tests {
use axum::http::{HeaderMap, HeaderValue};
use super::{observability_route, resolve_request_scheme};
#[test]
fn observability_route_keeps_metrics_labels_low_cardinality() {
assert_eq!(
observability_route("/api/runtime/puzzle/gallery?cursor=abc"),
"/api/runtime/puzzle/gallery"
);
assert_eq!(
observability_route("/api/runtime/puzzle/runs/run-123/history"),
"/api/*"
);
assert_eq!(observability_route("/admin/api/debug/http"), "/admin/api/*");
}
#[test]
fn resolve_request_scheme_uses_forwarded_proto_first_value() {
let mut headers = HeaderMap::new();
headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
assert_eq!(resolve_request_scheme(&headers), "https");
}
}

View File

@@ -85,7 +85,7 @@ pub async fn record_route_tracking_event_after_success(
draft.owner_user_id = draft.user_id.clone(); draft.owner_user_id = draft.user_id.clone();
} }
record_tracking_event_after_success(state, request_context, draft).await; record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await;
} }
fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrackingSpec> { fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option<RouteTrackingSpec> {
@@ -524,26 +524,101 @@ pub async fn record_tracking_event_after_success(
request_context: &RequestContext, request_context: &RequestContext,
draft: TrackingEventDraft, draft: TrackingEventDraft,
) { ) {
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; record_tracking_event_input_after_success(
let event_id = build_tracking_event_id(&draft, occurred_at_micros); state,
let event_key = draft.event_key.to_string(); request_context,
let scope_kind = draft.scope_kind; build_tracking_event_input(draft),
let scope_id = draft.scope_id; )
let metadata_json = draft.metadata.to_string(); .await;
}
async fn record_route_tracking_event_via_outbox_after_success(
state: &AppState,
request_context: &RequestContext,
draft: TrackingEventDraft,
) {
let event = build_tracking_event_input(draft);
let event_key = event.event_key.clone();
let scope_kind = event.scope_kind;
let scope_id = event.scope_id.clone();
if let Some(outbox) = state.tracking_outbox() {
match outbox.enqueue(event.clone()).await {
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Enqueued) => {
tracing::debug!(
request_id = request_context.request_id(),
operation = request_context.operation(),
event_key = %event_key,
scope_kind = %scope_kind.as_str(),
scope_id = %scope_id,
"后端 route 埋点已写入本机 outbox"
);
return;
}
Ok(crate::tracking_outbox::TrackingOutboxEnqueueOutcome::Dropped { reason }) => {
tracing::warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
event_key = %event_key,
scope_kind = %scope_kind.as_str(),
scope_id = %scope_id,
reason,
"后端 route 埋点因 outbox 保护阈值被丢弃,主业务流程继续"
);
return;
}
Err(error) => {
tracing::warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
event_key = %event_key,
scope_kind = %scope_kind.as_str(),
scope_id = %scope_id,
error = %error,
"后端 route 埋点写入 outbox 失败,回退同步直写 SpacetimeDB"
);
}
}
}
record_tracking_event_input_after_success(state, request_context, event).await;
}
async fn record_tracking_event_input_after_success(
state: &AppState,
request_context: &RequestContext,
event: module_runtime::RuntimeTrackingEventInput,
) {
let event_key = event.event_key.clone();
let log_scope_kind = event.scope_kind;
let scope_id = event.scope_id.clone();
let module_runtime::RuntimeTrackingEventInput {
event_id,
event_key: procedure_event_key,
scope_kind: procedure_scope_kind,
scope_id: procedure_scope_id,
user_id,
owner_user_id,
profile_id,
module_key,
metadata_json,
occurred_at_micros,
} = event;
match state match state
.spacetime_client() .spacetime_client()
.record_tracking_event( .record_tracking_event(
event_id, event_id,
event_key.clone(), procedure_event_key,
scope_kind, procedure_scope_kind,
scope_id.clone(), procedure_scope_id,
draft.user_id, user_id,
draft.owner_user_id, owner_user_id,
draft.profile_id, profile_id,
draft.module_key.map(str::to_string), module_key,
metadata_json, metadata_json,
occurred_at_micros as i64, occurred_at_micros,
) )
.await .await
{ {
@@ -551,7 +626,7 @@ pub async fn record_tracking_event_after_success(
request_id = request_context.request_id(), request_id = request_context.request_id(),
operation = request_context.operation(), operation = request_context.operation(),
event_key = %event_key, event_key = %event_key,
scope_kind = %scope_kind.as_str(), scope_kind = %log_scope_kind.as_str(),
scope_id = %scope_id, scope_id = %scope_id,
"后端埋点已记录" "后端埋点已记录"
), ),
@@ -559,7 +634,7 @@ pub async fn record_tracking_event_after_success(
request_id = request_context.request_id(), request_id = request_context.request_id(),
operation = request_context.operation(), operation = request_context.operation(),
event_key = %event_key, event_key = %event_key,
scope_kind = %scope_kind.as_str(), scope_kind = %log_scope_kind.as_str(),
scope_id = %scope_id, scope_id = %scope_id,
error = %error, error = %error,
"后端埋点记录失败,主业务流程继续" "后端埋点记录失败,主业务流程继续"
@@ -567,6 +642,26 @@ pub async fn record_tracking_event_after_success(
} }
} }
fn build_tracking_event_input(
draft: TrackingEventDraft,
) -> module_runtime::RuntimeTrackingEventInput {
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
module_runtime::RuntimeTrackingEventInput {
event_id,
event_key: draft.event_key.to_string(),
scope_kind: draft.scope_kind,
scope_id: draft.scope_id,
user_id: draft.user_id,
owner_user_id: draft.owner_user_id,
profile_id: draft.profile_id,
module_key: draft.module_key.map(str::to_string),
metadata_json: draft.metadata.to_string(),
occurred_at_micros: occurred_at_micros as i64,
}
}
fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String { fn build_tracking_event_id(draft: &TrackingEventDraft, occurred_at_micros: i128) -> String {
if draft.event_key == "daily_login" if draft.event_key == "daily_login"
&& draft.scope_kind == RuntimeTrackingScopeKind::User && draft.scope_kind == RuntimeTrackingScopeKind::User

View File

@@ -0,0 +1,621 @@
use std::{
fmt,
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use module_runtime::RuntimeTrackingEventInput;
use serde::{Deserialize, Serialize};
use spacetime_client::{SpacetimeClient, SpacetimeClientError};
use tokio::{
fs::{self, File, OpenOptions},
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
sync::{Mutex, Notify},
time::sleep,
};
use tracing::{debug, warn};
use crate::config::AppConfig;
const ACTIVE_FILE_NAME: &str = "active.ndjson";
const SEALED_FILE_PREFIX: &str = "sealed-";
const CORRUPT_FILE_PREFIX: &str = "corrupt-";
const SEALED_FILE_EXTENSION: &str = ".ndjson";
#[derive(Clone)]
pub struct TrackingOutbox {
dir: PathBuf,
batch_size: usize,
flush_interval: Duration,
max_bytes: u64,
spacetime_client: SpacetimeClient,
inner: Arc<Mutex<TrackingOutboxInner>>,
flush_notify: Arc<Notify>,
}
struct TrackingOutboxInner {
initialized: bool,
active_file: Option<File>,
active_count: usize,
active_bytes: u64,
total_bytes: u64,
last_sealed_at: Instant,
}
#[derive(Debug)]
pub enum TrackingOutboxEnqueueOutcome {
Enqueued,
Dropped { reason: &'static str },
}
#[derive(Debug)]
pub enum TrackingOutboxError {
Io(std::io::Error),
Json(serde_json::Error),
Spacetime(SpacetimeClientError),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct TrackingOutboxRecord {
event: RuntimeTrackingEventInput,
}
impl TrackingOutbox {
pub fn from_config(config: &AppConfig, spacetime_client: SpacetimeClient) -> Option<Arc<Self>> {
if !config.tracking_outbox_enabled {
return None;
}
let total_bytes = directory_size_if_exists(&config.tracking_outbox_dir).unwrap_or(0);
let outbox = Self {
dir: config.tracking_outbox_dir.clone(),
batch_size: config.tracking_outbox_batch_size.max(1),
flush_interval: config.tracking_outbox_flush_interval,
max_bytes: config.tracking_outbox_max_bytes,
spacetime_client,
inner: Arc::new(Mutex::new(TrackingOutboxInner {
initialized: false,
active_file: None,
active_count: 0,
active_bytes: 0,
total_bytes,
last_sealed_at: Instant::now(),
})),
flush_notify: Arc::new(Notify::new()),
};
crate::telemetry::update_tracking_outbox_pending_bytes(total_bytes);
Some(Arc::new(outbox))
}
pub async fn enqueue(
&self,
event: RuntimeTrackingEventInput,
) -> Result<TrackingOutboxEnqueueOutcome, TrackingOutboxError> {
let record = TrackingOutboxRecord { event };
let mut line = serde_json::to_vec(&record)?;
line.push(b'\n');
let line_bytes = line.len().min(u64::MAX as usize) as u64;
let mut inner = self.inner.lock().await;
self.ensure_initialized_locked(&mut inner).await?;
if inner.total_bytes.saturating_add(line_bytes) > self.max_bytes {
crate::telemetry::record_tracking_outbox_dropped("max_bytes");
return Ok(TrackingOutboxEnqueueOutcome::Dropped {
reason: "max_bytes",
});
}
let active_path = self.active_path();
if inner.active_file.is_none() {
inner.active_file = Some(
OpenOptions::new()
.create(true)
.append(true)
.open(&active_path)
.await?,
);
}
let file = inner
.active_file
.as_mut()
.expect("active file should be open before append");
file.write_all(&line).await?;
inner.active_count = inner.active_count.saturating_add(1);
inner.active_bytes = inner.active_bytes.saturating_add(line_bytes);
inner.total_bytes = inner.total_bytes.saturating_add(line_bytes);
crate::telemetry::record_tracking_outbox_enqueued();
crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes);
if inner.active_count >= self.batch_size {
self.seal_active_locked(&mut inner, "batch_size").await?;
self.flush_notify.notify_one();
}
Ok(TrackingOutboxEnqueueOutcome::Enqueued)
}
pub fn spawn_worker(self: Arc<Self>) {
tokio::spawn(async move {
loop {
tokio::select! {
_ = sleep(self.flush_interval) => {
if let Err(error) = self.seal_active_if_due().await {
warn!(error = %error, "tracking outbox 定时封存 active 文件失败");
}
if let Err(error) = self.flush_sealed_files_once().await {
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试");
}
}
_ = self.flush_notify.notified() => {
if let Err(error) = self.flush_sealed_files_once().await {
warn!(error = %error, "tracking outbox 批量写入 SpacetimeDB 失败,将保留 sealed 文件等待重试");
}
}
}
}
});
}
async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> {
let mut inner = self.inner.lock().await;
self.ensure_initialized_locked(&mut inner).await?;
if inner.active_count == 0 || inner.last_sealed_at.elapsed() < self.flush_interval {
return Ok(());
}
self.seal_active_locked(&mut inner, "flush_interval").await
}
async fn flush_sealed_files_once(&self) -> Result<(), TrackingOutboxError> {
self.ensure_initialized().await?;
let sealed_files = self.list_sealed_files().await?;
crate::telemetry::update_tracking_outbox_pending_files(sealed_files.len());
for path in sealed_files {
let started_at = Instant::now();
let metadata = fs::metadata(&path).await?;
let file_bytes = metadata.len();
let events = match read_outbox_events(&path).await {
Ok(events) => events,
Err(error) if error.is_data_corruption() => {
let corrupt_path = self.corrupt_path_for(&path);
fs::rename(&path, &corrupt_path).await?;
self.subtract_total_bytes(file_bytes).await;
crate::telemetry::record_tracking_outbox_corrupt_file();
warn!(
error = %error,
source = %path.display(),
target = %corrupt_path.display(),
"tracking outbox sealed 文件含无法解析的记录,已隔离并继续处理后续文件"
);
continue;
}
Err(error) => return Err(error),
};
if events.is_empty() {
fs::remove_file(&path).await?;
self.subtract_total_bytes(file_bytes).await;
continue;
}
match self.spacetime_client.record_tracking_events(events).await {
Ok(accepted_count) => {
fs::remove_file(&path).await?;
self.subtract_total_bytes(file_bytes).await;
crate::telemetry::record_tracking_outbox_flush(
started_at.elapsed(),
accepted_count,
file_bytes,
false,
);
debug!(
accepted_count,
file_bytes,
path = %path.display(),
"tracking outbox sealed 文件已批量入库并删除"
);
}
Err(error) => {
crate::telemetry::record_tracking_outbox_flush(
started_at.elapsed(),
0,
file_bytes,
true,
);
return Err(TrackingOutboxError::Spacetime(error));
}
}
}
Ok(())
}
async fn ensure_initialized(&self) -> Result<(), TrackingOutboxError> {
let mut inner = self.inner.lock().await;
self.ensure_initialized_locked(&mut inner).await
}
async fn ensure_initialized_locked(
&self,
inner: &mut TrackingOutboxInner,
) -> Result<(), TrackingOutboxError> {
if inner.initialized {
return Ok(());
}
fs::create_dir_all(&self.dir).await?;
self.seal_existing_active_file().await?;
inner.total_bytes = directory_size(&self.dir).await?;
inner.initialized = true;
inner.last_sealed_at = Instant::now();
crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes);
Ok(())
}
async fn seal_active_locked(
&self,
inner: &mut TrackingOutboxInner,
reason: &'static str,
) -> Result<(), TrackingOutboxError> {
if inner.active_count == 0 && inner.active_bytes == 0 {
return Ok(());
}
if let Some(mut file) = inner.active_file.take() {
file.flush().await?;
file.sync_data().await?;
drop(file);
}
let active_path = self.active_path();
match fs::metadata(&active_path).await {
Ok(metadata) if metadata.len() > 0 => {
let sealed_path = self.next_sealed_path();
fs::rename(&active_path, &sealed_path).await?;
crate::telemetry::record_tracking_outbox_sealed(reason);
debug!(
reason,
event_count = inner.active_count,
file_bytes = metadata.len(),
path = %sealed_path.display(),
"tracking outbox active 文件已封存"
);
}
Ok(_) => {
let _ = fs::remove_file(&active_path).await;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
inner.active_count = 0;
inner.active_bytes = 0;
inner.last_sealed_at = Instant::now();
Ok(())
}
async fn seal_existing_active_file(&self) -> Result<(), TrackingOutboxError> {
let active_path = self.active_path();
match fs::metadata(&active_path).await {
Ok(metadata) if metadata.len() > 0 => {
fs::rename(&active_path, self.next_sealed_path()).await?;
crate::telemetry::record_tracking_outbox_sealed("startup");
}
Ok(_) => {
let _ = fs::remove_file(&active_path).await;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
Ok(())
}
async fn list_sealed_files(&self) -> Result<Vec<PathBuf>, TrackingOutboxError> {
let mut entries = fs::read_dir(&self.dir).await?;
let mut files = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if name.starts_with(SEALED_FILE_PREFIX) && name.ends_with(SEALED_FILE_EXTENSION) {
files.push(path);
}
}
files.sort();
Ok(files)
}
async fn subtract_total_bytes(&self, bytes: u64) {
let mut inner = self.inner.lock().await;
inner.total_bytes = inner.total_bytes.saturating_sub(bytes);
crate::telemetry::update_tracking_outbox_pending_bytes(inner.total_bytes);
}
fn active_path(&self) -> PathBuf {
self.dir.join(ACTIVE_FILE_NAME)
}
fn next_sealed_path(&self) -> PathBuf {
self.dir.join(format!(
"{SEALED_FILE_PREFIX}{}-{uuid}{SEALED_FILE_EXTENSION}",
current_unix_micros(),
uuid = uuid::Uuid::new_v4()
))
}
fn corrupt_path_for(&self, path: &Path) -> PathBuf {
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("unknown.ndjson");
self.dir.join(format!(
"{CORRUPT_FILE_PREFIX}{}-{uuid}-{name}",
current_unix_micros(),
uuid = uuid::Uuid::new_v4()
))
}
}
impl fmt::Debug for TrackingOutbox {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TrackingOutbox")
.field("dir", &self.dir)
.field("batch_size", &self.batch_size)
.field("flush_interval", &self.flush_interval)
.field("max_bytes", &self.max_bytes)
.finish()
}
}
impl fmt::Display for TrackingOutboxError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
Self::Json(error) => write!(f, "{error}"),
Self::Spacetime(error) => write!(f, "{error}"),
}
}
}
impl From<std::io::Error> for TrackingOutboxError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for TrackingOutboxError {
fn from(value: serde_json::Error) -> Self {
Self::Json(value)
}
}
impl TrackingOutboxError {
fn is_data_corruption(&self) -> bool {
matches!(self, Self::Json(_))
}
}
async fn read_outbox_events(
path: &Path,
) -> Result<Vec<RuntimeTrackingEventInput>, TrackingOutboxError> {
let file = File::open(path).await?;
let mut lines = BufReader::new(file).lines();
let mut events = Vec::new();
while let Some(line) = lines.next_line().await? {
if line.trim().is_empty() {
continue;
}
let record = serde_json::from_str::<TrackingOutboxRecord>(&line)?;
events.push(record.event);
}
Ok(events)
}
async fn directory_size(path: &Path) -> Result<u64, TrackingOutboxError> {
let mut total = 0u64;
let mut entries = fs::read_dir(path).await?;
while let Some(entry) = entries.next_entry().await? {
if !is_pending_outbox_file_name(&entry.file_name()) {
continue;
}
let metadata = entry.metadata().await?;
if metadata.is_file() {
total = total.saturating_add(metadata.len());
}
}
Ok(total)
}
fn directory_size_if_exists(path: &Path) -> Result<u64, std::io::Error> {
if !path.is_dir() {
return Ok(0);
}
let mut total = 0u64;
for entry in std::fs::read_dir(path)? {
let entry = entry?;
if !is_pending_outbox_file_name(&entry.file_name()) {
continue;
}
let metadata = entry.metadata()?;
if metadata.is_file() {
total = total.saturating_add(metadata.len());
}
}
Ok(total)
}
fn current_unix_micros() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_micros()
}
fn is_pending_outbox_file_name(name: &std::ffi::OsStr) -> bool {
name.to_str().is_some_and(|value| {
value == ACTIVE_FILE_NAME
|| (value.starts_with(SEALED_FILE_PREFIX) && value.ends_with(SEALED_FILE_EXTENSION))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_event(event_id: &str) -> RuntimeTrackingEventInput {
RuntimeTrackingEventInput {
event_id: event_id.to_string(),
event_key: "puzzle_route_success".to_string(),
scope_kind: module_runtime::RuntimeTrackingScopeKind::Site,
scope_id: "site".to_string(),
user_id: None,
owner_user_id: None,
profile_id: None,
module_key: Some("puzzle".to_string()),
metadata_json: "{}".to_string(),
occurred_at_micros: 1_713_680_000_000_000,
}
}
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"genarrative-tracking-outbox-{name}-{}",
current_unix_micros()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn test_outbox(dir: PathBuf, batch_size: usize, max_bytes: u64) -> Arc<TrackingOutbox> {
let config = AppConfig {
tracking_outbox_dir: dir,
tracking_outbox_batch_size: batch_size,
tracking_outbox_max_bytes: max_bytes,
tracking_outbox_flush_interval: Duration::from_secs(60),
..AppConfig::default()
};
TrackingOutbox::from_config(
&config,
SpacetimeClient::new(spacetime_client::SpacetimeClientConfig {
server_url: "http://127.0.0.1:1".to_string(),
database: "missing".to_string(),
token: None,
pool_size: 1,
procedure_timeout: Duration::from_millis(10),
}),
)
.expect("outbox should be enabled")
}
#[tokio::test]
async fn enqueue_seals_active_file_when_batch_size_reached_and_rotates_active() {
let dir = test_dir("batch");
let outbox = test_outbox(dir.clone(), 2, 1024 * 1024);
outbox.enqueue(sample_event("event-1")).await.unwrap();
outbox.enqueue(sample_event("event-2")).await.unwrap();
assert!(!dir.join(ACTIVE_FILE_NAME).exists());
let sealed_count = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
})
.count();
assert_eq!(sealed_count, 1);
outbox.enqueue(sample_event("event-3")).await.unwrap();
let active_contents = std::fs::read_to_string(dir.join(ACTIVE_FILE_NAME)).unwrap();
assert!(active_contents.contains("event-3"));
let sealed_count_after_rotate = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(SEALED_FILE_PREFIX))
})
.count();
assert_eq!(sealed_count_after_rotate, 1);
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn enqueue_drops_when_outbox_exceeds_max_bytes() {
let dir = test_dir("max-bytes");
let outbox = test_outbox(dir.clone(), 500, 1);
let outcome = outbox.enqueue(sample_event("event-1")).await.unwrap();
assert!(matches!(
outcome,
TrackingOutboxEnqueueOutcome::Dropped {
reason: "max_bytes"
}
));
assert!(!dir.join(ACTIVE_FILE_NAME).exists());
let _ = std::fs::remove_dir_all(dir);
}
#[tokio::test]
async fn flush_quarantines_corrupt_sealed_file() {
let dir = test_dir("corrupt");
std::fs::create_dir_all(&dir).unwrap();
let sealed_path = dir.join(format!("{SEALED_FILE_PREFIX}bad{SEALED_FILE_EXTENSION}"));
std::fs::write(&sealed_path, b"{not-json}\n").unwrap();
let outbox = test_outbox(dir.clone(), 500, 1024 * 1024);
outbox.flush_sealed_files_once().await.unwrap();
assert!(!sealed_path.exists());
let corrupt_count = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(CORRUPT_FILE_PREFIX))
})
.count();
assert_eq!(corrupt_count, 1);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn directory_size_excludes_quarantined_corrupt_files() {
let dir = test_dir("directory-size");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(ACTIVE_FILE_NAME), b"active").unwrap();
std::fs::write(
dir.join(format!("{SEALED_FILE_PREFIX}one{SEALED_FILE_EXTENSION}")),
b"sealed",
)
.unwrap();
std::fs::write(
dir.join(format!("{CORRUPT_FILE_PREFIX}one{SEALED_FILE_EXTENSION}")),
b"corrupt",
)
.unwrap();
let total = directory_size_if_exists(&dir).unwrap();
assert_eq!(total, 12);
let _ = std::fs::remove_dir_all(dir);
}
}

View File

@@ -9,6 +9,7 @@ platform-auth = { workspace = true }
shared-kernel = { workspace = true } shared-kernel = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
time = { workspace = true, features = ["formatting", "parsing"] } time = { workspace = true, features = ["formatting", "parsing"] }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -18,10 +18,11 @@ use std::{
}; };
use platform_auth::{ use platform_auth::{
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password,
verify_password, verify_password,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use shared_kernel::{ use shared_kernel::{
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
normalize_optional_string, normalize_required_string, parse_rfc3339, normalize_optional_string, normalize_required_string, parse_rfc3339,
@@ -77,6 +78,7 @@ struct StoredRefreshSession {
struct StoredPhoneCode { struct StoredPhoneCode {
phone_number: String, phone_number: String,
scene: PhoneAuthScene, scene: PhoneAuthScene,
verify_code_hash: String,
expires_at: String, expires_at: String,
last_sent_at: String, last_sent_at: String,
failed_attempts: u32, failed_attempts: u32,
@@ -117,6 +119,7 @@ pub struct AuthUserService {
pub struct PhoneAuthService { pub struct PhoneAuthService {
store: InMemoryAuthStore, store: InMemoryAuthStore,
sms_provider: SmsAuthProvider, sms_provider: SmsAuthProvider,
verify_code_salt: String,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -431,6 +434,7 @@ impl PhoneAuthService {
Self { Self {
store, store,
sms_provider, sms_provider,
verify_code_salt: new_uuid_simple_string(),
} }
} }
@@ -442,6 +446,7 @@ impl PhoneAuthService {
let scene = input.scene.clone(); let scene = input.scene.clone();
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
let verify_code = self.generate_phone_verify_code();
info!( info!(
scene = scene.as_str(), scene = scene.as_str(),
provider = self.sms_provider.kind().as_str(), provider = self.sms_provider.kind().as_str(),
@@ -457,12 +462,19 @@ impl PhoneAuthService {
let expires_at = format_rfc3339(expires_at).map_err(|message| { let expires_at = format_rfc3339(expires_at).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}")) PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}"))
})?; })?;
let verify_code_hash = hash_phone_verify_code(
&self.verify_code_salt,
&normalized_phone.e164,
&scene,
&verify_code,
);
let provider_result = self let provider_result = self
.sms_provider .sms_provider
.send_code(SmsSendCodeRequest { .send_code(SmsSendCodeRequest {
national_phone_number, national_phone_number,
scene: input.scene.as_str().to_string(), scene: input.scene.as_str().to_string(),
verify_code,
}) })
.await .await
.map_err(map_sms_provider_error_to_phone_error)?; .map_err(map_sms_provider_error_to_phone_error)?;
@@ -488,6 +500,7 @@ impl PhoneAuthService {
StoredPhoneCode { StoredPhoneCode {
phone_number: normalized_phone.e164.clone(), phone_number: normalized_phone.e164.clone(),
scene, scene,
verify_code_hash,
expires_at, expires_at,
last_sent_at: format_rfc3339(now).map_err(|message| { last_sent_at: format_rfc3339(now).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
@@ -516,28 +529,12 @@ impl PhoneAuthService {
) -> Result<PhoneLoginResult, PhoneAuthError> { ) -> Result<PhoneLoginResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?; verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active( let provider_out_id = self.verify_phone_code(
&normalized_phone.e164, &normalized_phone.e164,
&PhoneAuthScene::Login, &PhoneAuthScene::Login,
&input.verify_code,
now, now,
)?; )?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id: provider_out_id.clone(),
})
.await
{
Ok(()) => self
.store
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::Login)?,
Err(SmsProviderError::InvalidVerifyCode) => self
.store
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::Login)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
if let Some(user) = self if let Some(user) = self
.store .store
@@ -582,30 +579,12 @@ impl PhoneAuthService {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?; verify_sms_code_format(&input.verify_code)?;
validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?;
let provider_out_id = self.store.assert_phone_code_active( let provider_out_id = self.verify_phone_code(
&normalized_phone.e164, &normalized_phone.e164,
&PhoneAuthScene::ResetPassword, &PhoneAuthScene::ResetPassword,
&input.verify_code,
now, now,
)?; )?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id: provider_out_id.clone(),
})
.await
{
Ok(()) => self.store.consume_phone_code_success(
&normalized_phone.e164,
&PhoneAuthScene::ResetPassword,
)?,
Err(SmsProviderError::InvalidVerifyCode) => self.store.consume_phone_code_failure(
&normalized_phone.e164,
&PhoneAuthScene::ResetPassword,
)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
self.store self.store
.find_by_phone_number(&normalized_phone.e164)? .find_by_phone_number(&normalized_phone.e164)?
@@ -632,28 +611,12 @@ impl PhoneAuthService {
) -> Result<BindWechatPhoneResult, PhoneAuthError> { ) -> Result<BindWechatPhoneResult, PhoneAuthError> {
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
verify_sms_code_format(&input.verify_code)?; verify_sms_code_format(&input.verify_code)?;
let provider_out_id = self.store.assert_phone_code_active( self.verify_phone_code(
&normalized_phone.e164, &normalized_phone.e164,
&PhoneAuthScene::BindPhone, &PhoneAuthScene::BindPhone,
&input.verify_code,
now, now,
)?; )?;
match self
.sms_provider
.verify_code(SmsVerifyCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
verify_code: input.verify_code.trim().to_string(),
provider_out_id,
})
.await
{
Ok(()) => self
.store
.consume_phone_code_success(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
Err(SmsProviderError::InvalidVerifyCode) => self
.store
.consume_phone_code_failure(&normalized_phone.e164, &PhoneAuthScene::BindPhone)?,
Err(other) => return Err(map_sms_provider_error_to_phone_error(other)),
}
let current_user = self let current_user = self
.store .store
@@ -677,6 +640,35 @@ impl PhoneAuthService {
}) })
} }
fn verify_phone_code(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
verify_code: &str,
now: OffsetDateTime,
) -> Result<Option<String>, PhoneAuthError> {
let stored = self.store.get_active_phone_code(phone_number, scene, now)?;
let expected_hash =
hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code);
if stored.verify_code_hash != expected_hash {
self.store.consume_phone_code_failure(phone_number, scene)?;
return Err(PhoneAuthError::InvalidVerifyCode);
}
self.store.consume_phone_code_success(phone_number, scene)?;
Ok(stored.provider_out_id)
}
fn generate_phone_verify_code(&self) -> String {
match self.sms_provider.kind() {
SmsAuthProviderKind::Mock => self
.sms_provider
.mock_verify_code()
.map(str::to_string)
.unwrap_or_else(|| "123456".to_string()),
SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(),
}
}
pub async fn bind_wechat_verified_phone( pub async fn bind_wechat_verified_phone(
&self, &self,
input: BindWechatVerifiedPhoneInput, input: BindWechatVerifiedPhoneInput,
@@ -1518,12 +1510,12 @@ impl InMemoryAuthStore {
}) })
} }
fn assert_phone_code_active( fn get_active_phone_code(
&self, &self,
phone_number: &str, phone_number: &str,
scene: &PhoneAuthScene, scene: &PhoneAuthScene,
now: OffsetDateTime, now: OffsetDateTime,
) -> Result<Option<String>, PhoneAuthError> { ) -> Result<StoredPhoneCode, PhoneAuthError> {
let mut state = self let mut state = self
.inner .inner
.lock() .lock()
@@ -1543,7 +1535,7 @@ impl InMemoryAuthStore {
state.phone_codes_by_key.remove(&key); state.phone_codes_by_key.remove(&key);
return Err(PhoneAuthError::VerifyCodeExpired); return Err(PhoneAuthError::VerifyCodeExpired);
} }
Ok(stored.provider_out_id) Ok(stored)
} }
fn consume_phone_code_success( fn consume_phone_code_success(
@@ -2069,6 +2061,7 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr
SmsProviderError::InvalidConfig(message) => { SmsProviderError::InvalidConfig(message) => {
PhoneAuthError::SmsProviderInvalidConfig(message) PhoneAuthError::SmsProviderInvalidConfig(message)
} }
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message), SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message),
} }
} }
@@ -2139,6 +2132,36 @@ fn build_random_password_seed() -> String {
) )
} }
fn generate_random_phone_verify_code() -> String {
let digest = Sha256::digest(new_uuid_simple_string().as_bytes());
let mut digits = digest
.iter()
.take(SMS_CODE_LENGTH)
.map(|byte| char::from(b'0' + (*byte % 10)))
.collect::<String>();
while digits.len() < SMS_CODE_LENGTH {
digits.push('0');
}
digits
}
fn hash_phone_verify_code(
salt: &str,
phone_number: &str,
scene: &PhoneAuthScene,
verify_code: &str,
) -> String {
let content = format!(
"{}:{}:{}:{}",
salt,
phone_number.trim(),
scene.as_str(),
verify_code.trim()
);
let digest = Sha256::digest(content.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> { fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value) format_shared_rfc3339(value)
} }
@@ -2655,6 +2678,14 @@ mod tests {
assert!(bind_result.await.is_ok()); assert!(bind_result.await.is_ok());
} }
#[test]
fn random_phone_verify_code_is_six_digits() {
let code = generate_random_phone_verify_code();
assert_eq!(code.len(), SMS_CODE_LENGTH);
assert!(code.chars().all(|character| character.is_ascii_digit()));
}
#[tokio::test] #[tokio::test]
async fn phone_login_expires_code_after_too_many_wrong_attempts() { async fn phone_login_expires_code_after_too_many_wrong_attempts() {
let service = build_phone_service(build_store()); let service = build_phone_service(build_store());

View File

@@ -0,0 +1 @@
//! 中文注释:汪汪声浪领域应用服务预留落位,当前规则仍集中在 domain/scoring。

View File

@@ -0,0 +1 @@
//! 中文注释:汪汪声浪命令归一化预留落位,当前无独立命令构造。

View File

@@ -0,0 +1 @@
//! 中文注释:汪汪声浪领域错误预留落位,当前复用调用方错误文本。

View File

@@ -0,0 +1 @@
//! 中文注释:汪汪声浪领域事件预留落位,当前不导出独立事件类型。

View File

@@ -1,4 +1,8 @@
mod application;
mod commands;
pub mod domain; pub mod domain;
mod errors;
mod events;
pub mod scoring; pub mod scoring;
pub use domain::*; pub use domain::*;

View File

@@ -68,7 +68,7 @@ pub struct BigFishWorkRemixInput {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult { pub struct BigFishWorksProcedureResult {
pub ok: bool, pub ok: bool,
pub items_json: Option<String>, pub items: Vec<BigFishWorkSummarySnapshot>,
pub error_message: Option<String>, pub error_message: Option<String>,
} }
@@ -188,9 +188,9 @@ pub struct BigFishInputSubmitInput {
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult { pub struct BigFishRunProcedureResult {
pub ok: bool, pub ok: bool,
pub run_json: Option<String>, pub run: Option<BigFishRuntimeSnapshot>,
pub error_message: Option<String>, pub error_message: Option<String>,
} }

View File

@@ -0,0 +1 @@
//! 中文注释:创意 Agent 领域事件预留落位,当前流程不导出独立事件类型。

View File

@@ -2,6 +2,7 @@ mod application;
mod commands; mod commands;
mod domain; mod domain;
mod errors; mod errors;
mod events;
pub use application::*; pub use application::*;
pub use commands::*; pub use commands::*;

Some files were not shown because too many files have changed in this diff Show More