merge: master into codex/bark-battle
This commit is contained in:
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@@ -0,0 +1,36 @@
|
||||
.git
|
||||
.codex-temp
|
||||
.codex-logs
|
||||
.codex-runlogs
|
||||
.idea
|
||||
.vite
|
||||
node_modules
|
||||
target
|
||||
dist
|
||||
coverage
|
||||
logs
|
||||
tmp
|
||||
*.log
|
||||
/*.png
|
||||
/*.jpg
|
||||
/*.jpeg
|
||||
/*.webp
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.secrets.local
|
||||
.env.secrets.*
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
|
||||
server-rs/target
|
||||
server-rs/target-*
|
||||
server-rs/.data
|
||||
server-rs/.spacetimedb
|
||||
|
||||
public/generated-*
|
||||
|
||||
scripts/loadtest/data/*.local.json
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
23
.env.local
23
.env.local
@@ -16,21 +16,10 @@ JWT_EXPIRES_IN="7d"
|
||||
|
||||
SMS_AUTH_ENABLED="true"
|
||||
SMS_AUTH_PROVIDER="aliyun"
|
||||
ALIYUN_SMS_ACCESS_KEY_ID="LTAI5tM6VjoixveLUNQ7x6z9"
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET="w8Z8JlQKI1juGPSeirWwlvJfHp9frD"
|
||||
ALIYUN_SMS_ENDPOINT="dypnsapi.aliyuncs.com"
|
||||
ALIYUN_SMS_SIGN_NAME="速通互联验证码"
|
||||
ALIYUN_SMS_TEMPLATE_CODE="100001"
|
||||
ALIYUN_SMS_ENDPOINT="dysmsapi.aliyuncs.com"
|
||||
ALIYUN_SMS_SIGN_NAME="北京亓盒网络科技"
|
||||
ALIYUN_SMS_TEMPLATE_CODE="SMS_506245486"
|
||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY="code"
|
||||
ALIYUN_SMS_COUNTRY_CODE="86"
|
||||
ALIYUN_SMS_SCHEME_NAME=""
|
||||
ALIYUN_SMS_CODE_LENGTH="6"
|
||||
ALIYUN_SMS_CODE_TYPE="1"
|
||||
ALIYUN_SMS_VALID_TIME_SECONDS="300"
|
||||
ALIYUN_SMS_INTERVAL_SECONDS="60"
|
||||
ALIYUN_SMS_DUPLICATE_POLICY="1"
|
||||
ALIYUN_SMS_CASE_AUTH_POLICY="1"
|
||||
ALIYUN_SMS_RETURN_VERIFY_CODE="false"
|
||||
|
||||
VITE_AUTH_ALLOW_DEV_GUEST="false"
|
||||
|
||||
@@ -70,3 +59,9 @@ GENARRATIVE_SPACETIME_TOKEN=""
|
||||
GENARRATIVE_ADMIN_USERNAME=admin
|
||||
GENARRATIVE_ADMIN_PASSWORD=123456
|
||||
ADMIN_API_TARGET=http://127.0.0.1:3100
|
||||
|
||||
# OTLP
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_SERVICE_NAME=genarrative-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,service.namespace=genarrative
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ temp*build*/
|
||||
.worktrees/
|
||||
.env.secrets.local
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
|
||||
# Local load-test data extracted from private migration files
|
||||
scripts/loadtest/data/*.local.json
|
||||
|
||||
@@ -24,6 +24,117 @@
|
||||
- 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单;点击生成草稿进入结果页;结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime,发布成功后再进入正式 runtime。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
|
||||
|
||||
- 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。
|
||||
- 决策:手写 Rust 模块统一使用同名入口文件,例如 `puzzle.rs`、`match3d.rs`、`gameplay.rs`,子模块继续放在同名目录下;不要再为手写模块新增 `mod.rs`。SpacetimeDB CLI 生成的 bindings 也由生成脚本同步为 `module_bindings.rs` 加 `module_bindings/` 子目录,避免仓库里继续出现 `mod.rs`。
|
||||
- 边界:本决策只规范文件布局,不改变 module path、HTTP route、DTO、SpacetimeDB schema、生成绑定内容或运行时行为。
|
||||
- 影响范围:`server-rs/crates/api-server/src/`、`server-rs/crates/spacetime-module/src/`、`server-rs/crates/spacetime-client/src/module_bindings.rs`、`scripts/generate-spacetime-bindings.mjs`。
|
||||
- 验证方式:执行 `Get-ChildItem server-rs -Recurse -Filter mod.rs` 应无结果;再执行对应 `cargo check` / 定向测试 / 编码检查。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-05-18 大文件拆分继续按聚合入口加领域子模块推进
|
||||
|
||||
- 背景:完成拼图 `api-server` 拆分后,`match3d.rs`、`spacetime-client/src/mapper.rs` 与 `PlatformEntryFlowShellImpl.tsx` 仍是后续迭代和评审的高噪音大文件。
|
||||
- 决策:抓大鹅 Match3D 的 `api-server` 单文件改为同名入口 `src/match3d.rs` 加 `src/match3d/` 子模块目录,`handlers.rs`、`draft.rs`、`works.rs`、`item_assets.rs`、`runtime.rs`、`vector_engine_gemini.rs`、`mappers.rs`、`tags.rs`、`tests.rs` 分担原实现;`spacetime-client/src/mapper.rs` 改为聚合入口,具体 mapper 按领域落到 `src/mapper/*.rs`;平台入口继续以 `PlatformEntryFlowShellImpl.tsx` 为编排壳,独立 UI 片段优先拆到 `PlatformEntryFlowShellImpl/` 子目录,本次已抽出 `PuzzleOnboardingView.tsx`。
|
||||
- 边界:这些拆分只改变文件组织,不改变 HTTP route、DTO、error envelope、SpacetimeDB schema、生成绑定、procedure result、入口配置事实源、前端行为、VectorEngine / OSS 副作用或计费语义。后续要下沉领域规则时另行讨论并更新设计。
|
||||
- 影响范围:`server-rs/crates/api-server/src/match3d/`、`server-rs/crates/spacetime-client/src/mapper/`、`src/components/platform-entry/PlatformEntryFlowShellImpl/`、后端架构文档和玩法链路文档。
|
||||
- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml --no-run`、`cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml`、前端 typecheck 或定向 tsc、`git diff --check` 与 `npm run check:encoding`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-18 api-server 拼图能力按 HTTP/BFF 子模块拆分
|
||||
|
||||
- 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试;继续在单文件内迭代会降低定位和评审效率。
|
||||
- 决策:原超大 `puzzle.rs` 改为同名入口 `server-rs/crates/api-server/src/puzzle.rs` 加 `server-rs/crates/api-server/src/puzzle/` 子模块目录。`puzzle.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。
|
||||
- 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`,SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`。
|
||||
- 影响范围:`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。
|
||||
- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-05-18 Windows Jenkins PowerShell 统一改为显式 powershell.exe 启动
|
||||
|
||||
- 背景:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 本地环境里调用裸 `powershell` step 时触发 `CreateProcess error=5, 拒绝访问`,而 `powershell.exe` 本体与 workspace ACL 都正常。
|
||||
- 决策:Windows Jenkins 上凡是需要执行 PowerShell 逻辑的流水线,优先通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...`,不要再依赖 Jenkins `powershell` step 的隐式启动器。
|
||||
- 追加决策:`Genarrative-Stdb-Module-Build` 的 Checkout 逻辑应复用 Jenkins GitSCM 已完成的工作区状态。`COMMIT_HASH` 为空或已与当前 `HEAD` 一致时,不再额外执行 `git clean` / `git checkout`;只有需要切到指定且不同的 commit 时才补 fetch、校验和切换,避免在 Windows workspace 里二次清理触发权限拒绝。
|
||||
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。
|
||||
- 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。
|
||||
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
|
||||
|
||||
- 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。
|
||||
- 决策:`api-server` tracking outbox 达到 `BATCH_SIZE` 时立即封存当前 active 文件并切新 active,sealed 文件交给后台 worker 异步 flush;`FLUSH_INTERVAL_MS` 只做长时间未满批的兜底封存;`MAX_BYTES` 只做磁盘保护阈值;成功后删除 sealed,失败保留重试,坏文件隔离为 `corrupt-*`。
|
||||
- 影响范围:`api-server` tracking outbox、埋点文档、压测口径和后续排障记忆。
|
||||
- 验证方式:HTTP route 请求在 SpacetimeDB 短暂不可用时仍可返回;恢复后 sealed 文件会被批量写入并清理。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 2026-05-19 OTLP 默认开启但日志本地输出保留
|
||||
|
||||
- 背景:生产和容器环境需要默认把 OTLP 接到本机 Collector,但压测或排障时也要能显式关闭。
|
||||
- 决策:生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;生产 endpoint 用 `http://127.0.0.1:4318`,容器 endpoint 用 `http://otelcol:4318`;`OTEL_EXPORTER_OTLP_ENDPOINT` 只填 Collector HTTP base endpoint,不填 gRPC `4317` 或 Rider 端口;本地日志、Nginx 日志和 `GENARRATIVE_API_LOG` / `RUST_LOG` 仍保留。
|
||||
- 影响范围:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。
|
||||
- 验证方式:检查 env 模板默认值与端点口径;压测若要关闭 OTLP,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/run-otelcol.mjs`。
|
||||
|
||||
## 2026-05-19 容器 collector 可切 Grafana Cloud
|
||||
|
||||
- 背景:容器隔离压测时除了本地 debug exporter,还需要临时把 traces / metrics / logs 转发到 Grafana Cloud 做可视化验证。
|
||||
- 决策:`deploy/container/docker-compose.loadtest.yml` 里的 `otelcol` 支持通过 `GENARRATIVE_CONTAINER_OTELCOL_CONFIG=./otelcol.grafana.yaml` 切换配置;`deploy/container/otelcol.grafana.yaml` 同时保留 debug exporter,并通过 `GRAFANA_CLOUD_OTLP_ENDPOINT` 和 `GRAFANA_CLOUD_BASIC_AUTH_HEADER` 转发到 Grafana Cloud。
|
||||
- 影响范围:`deploy/container/docker-compose.loadtest.yml`、`deploy/container/otelcol.grafana.yaml`、`deploy/container/README.md`。
|
||||
- 验证方式:容器 `otelcol` 启动日志应能看到 OTLP receiver ready,debug exporter 仍可输出本地链路;Grafana Cloud 转发凭据只通过当前 shell 环境变量传入,不写入 Git。
|
||||
- 关联文档:`deploy/container/README.md`、`scripts/loadtest/README.md`。
|
||||
|
||||
## 2026-05-17 容器化方案只作为隔离压测与预发模拟路径
|
||||
|
||||
- 背景:Windows 本机直连极高 VU 压测会放大本地连接与发送缓冲行为,和线上 Linux + Nginx + systemd 拓扑不一致;需要一个更接近生产网络层的模拟方案,但不能扰动当前生产发布链路。
|
||||
- 决策:新增 `deploy/container/` 容器化方案,使用 Docker Compose 组合 Linux release `api-server`、容器 SpacetimeDB、容器 Nginx、`otelcol-contrib` debug exporter 和可选 k6。该方案只用于本机或预发压测模拟,不替换当前生产 `systemd + Nginx + Jenkins` 路径。
|
||||
- 服务器模拟参数:2026-05-18 通过 `ssh genarrative-release` 采样,目标机器为 2 vCPU / 约 2 GiB RAM / Ubuntu 24.04 / Nginx `worker_connections=768`;容器方案按待发布运行口径使用 `nofile=4096`,并在 compose 中限制 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`;Collector 镜像默认使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||
- 隔离边界:容器方案使用独立 `deploy/container/api-server.env`、独立 Nginx 配置、独立 compose 命令和默认 `18080` 端口;真实 token 不进入镜像、不提交 Git;生产 systemd 单元、Jenkins 发布脚本和 `deploy/nginx/` 模板仍是正式线上来源。
|
||||
- 生产 Collector:server-provision 可安装 `otelcol-contrib.service` 和本机 debug exporter 配置,但二进制由 Jenkins 构建机先准备 `provision-tools/otelcol-contrib` 再上传到 release 部署 agent,目标机不从 GitHub 下载;api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制。
|
||||
- 影响范围:`deploy/container/`、`scripts/container-compose.mjs`、`package.json` 容器命令、开发运维文档和容器 build context 排除规则。
|
||||
- 验证方式:执行 `npm run container:config` 展开 compose 配置;需要真实运行时再执行 `npm run container:build`、`npm run container:up`、`npm run container:k6`,并结合容器 Nginx log 与 OTLP debug exporter 判断瓶颈。
|
||||
- 关联文档:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-05-18 生产 provision 改为构建机准备工具包再上传安装
|
||||
|
||||
- 背景:目标 release 服务器无法访问 GitHub,之前的 server provision 默认仍假设 `spacetime` 和 `otelcol-contrib` 已经存在于目标机本地路径,和真实运维条件不符。
|
||||
- 决策:Jenkins 新增 `Prepare Provision Tools` 阶段,在 `linux && genarrative-build` 构建机执行 `scripts/prepare-server-provision-tools.sh`,通过官方 SpacetimeDB 安装入口和 OpenTelemetry release 包生成 `provision-tools/`,再用 `stash/unstash` 带到 release 部署 agent;`scripts/jenkins-server-provision.sh` 只从工作区工具包复制安装,不再要求目标机自己下载或预装二进制。
|
||||
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-server-provision.sh`、生产运维文档。
|
||||
- 验证方式:Jenkins 构建机可完成工具包准备,release 部署 agent 只消费工作区文件;目标机不再依赖 GitHub 外网下载。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-05-19 otelcol-contrib 改为 Jenkins 手动上传归档再解包
|
||||
|
||||
- 背景:Genarrative-Server-Provision 中 `otelcol-contrib` 的构建机下载步骤耗时较长,且本机已经提前准备好安装包。
|
||||
- 决策:`jenkins/Jenkinsfile.production-server-provision` 新增 `OTELCOL_CONTRIB_ARCHIVE` 手动上传参数,默认要求上传 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`;`scripts/prepare-server-provision-tools.sh` 优先从上传归档解包生成 `provision-tools/otelcol-contrib`,不再默认联网下载 OpenTelemetry release 包。
|
||||
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
- 验证方式:Jenkins 日志应显示使用手动上传的 otelcol 包,`MANIFEST.txt` 记录 source 为 manual archive;当 `ENABLE_OTELCOL=false` 时可以跳过 collector 工具包准备。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-05-19 公开 gallery 入口发布限流以快拒绝保护后端
|
||||
|
||||
- 背景:容器 2C / 2G 压测中,公开作品列表在约 5000 HTTP req/s 目标下可以保持 200 请求低延迟,但 SpacetimeDB 内存会随 api-server 重连和高压请求累积到容器上限附近。
|
||||
- 决策:发布配置采用公开 gallery list 专用入口限流:Nginx `genarrative_gallery_rps rate=5000r/s`、`burst=4096`、gallery list `limit_conn=320`;api-server 对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`,公开详情维持更低的 `GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`。超过容量时接受明确 `429`,不继续扩大入口并发。
|
||||
- 影响范围:`deploy/nginx/` 发布模板、`deploy/env/api-server.env.example`、`deploy/container/` 隔离压测模板和生产运维文档。
|
||||
- 验证方式:容器连续 10 轮不重启 SpacetimeDB 压测,`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,总计 `0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;同时观察 SpacetimeDB 内存高水位,后续优化先处理连接 / 订阅 / tracking 下游状态。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`deploy/container/README.md`。
|
||||
|
||||
## 2026-05-16 公开作品列表短期由 BFF 订阅读模型缓存
|
||||
|
||||
- 背景:作品列表压测和实时性讨论中,曾考虑让浏览器前端直接订阅公开作品列表,减少 HTTP 拉取和 BFF 压力。
|
||||
- 决策:本轮不直接把作品列表整体交给前端订阅。短期继续由 `api-server` / BFF 通过 `spacetime-client` 长期订阅 SpacetimeDB 公开 read model 并读取本地 cache,维持首屏、排序、字段归一、权限降级和 HTTP fallback。中期可以新增或统一稳定的专用公开作品列表 read model,例如 `public_work_gallery_entry`,作为前端可选直连订阅对象。
|
||||
- 边界:未来前端直订阅只允许面向稳定、低基数、公开的专用 read model。前端不得直接订阅 `puzzle_work_profile`、`custom_world_profile` 等领域源表,也不得在前端自行 join、聚合或执行公开权限逻辑;这些逻辑必须先沉到后端投影 / read model。
|
||||
- 后续准入:若要落地前端直订阅,必须先完成并验收权限边界、字段契约、排序 / 分页、埋点和 BFF 回退策略;缺任一项时继续走 `api-server` / BFF 订阅缓存方案。
|
||||
- 影响范围:发现页、推荐流、各玩法公开广场、`api-server` 公开列表缓存、SpacetimeDB public view / public 读模型设计。
|
||||
- 验证方式:新增公开作品列表订阅能力时,检查前端只消费专用 public read model 或 BFF HTTP DTO;检查源表 row shape、权限判断和跨玩法聚合没有下沉到前端页面。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
- 背景:压测与运行观测需要把 HTTP、SpacetimeDB 调用和应用日志串起来,同时保留本地 `journalctl` / 文件日志做故障排障。
|
||||
- 决策:`api-server` 通过 OTLP HTTP base endpoint 发送 traces、metrics 和 logs;Collector 统一用 `otelcol-contrib`,`npm run otel:debug` 负责 debug 采集,`npm run otel:rider` 负责转发到 Rider;Rider 只是接收与可视化端,不直接替代 Collector。
|
||||
- 日志口径:Rider Logs 面板只展示 log event 自身字段,请求完成日志需要直接携带 `request_id`、HTTP method、规范化 route、scheme、path、status、status_class、latency 和 slow_request;更完整的 request attributes 仍以 trace/span 为准。
|
||||
- 影响范围:`server-rs/crates/shared-logging`、`server-rs/crates/api-server`、`scripts/run-otelcol.mjs`、压测与运维文档。
|
||||
- 验证方式:`cargo test -p shared-logging --manifest-path server-rs/Cargo.toml generic_otlp_http_endpoint_expands_to_signal_paths`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml observability_route_keeps_metrics_labels_low_cardinality`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml resolve_request_scheme_uses_forwarded_proto_first_value`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`scripts/loadtest/README.md`。
|
||||
|
||||
## 2026-05-14 创作页图像输入统一封装为图像组件
|
||||
|
||||
- 背景:拼图创作页已经具备“画面描述生图 / 多参考图生图 / 上传主图后 AI 重绘 / 上传主图后不重绘”四条路径,抓大鹅封面和后续创作页也会复用同一套交互;继续在页面内复制会导致参考图、预览、删除确认和重绘开关漂移。
|
||||
@@ -141,7 +252,8 @@
|
||||
## 2026-05-12 拼图 UI 背景图复用 levels_json 持久化
|
||||
|
||||
- 背景:拼图草稿结果页需要像抓大鹅一样支持 UI 背景生成,但首版只需要作品级/首关背景,不应为图片生成结果新增 SpacetimeDB 表结构。
|
||||
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段在首图完成后自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
|
||||
- 决策:拼图 UI 背景字段存入首关 `levels_json`,字段为 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`;`compile_puzzle_draft` 草稿编译阶段自动生成首关 UI 背景,自动草稿阶段必须拿到 `uiBackgroundImageSrc` 或 `uiBackgroundImageObjectKey` 才能返回成功;结果页新增 `UI` Tab,可编辑提示词并触发 `generate_puzzle_ui_background`,手动生成失败只展示在当前面板。`api-server` 读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 参考图,调用 VectorEngine `gpt-image-2-all` 生成 9:16 背景并要求中央正方形拼图区与外部 UI 背景边界清晰。SpacetimeDB 只保存结果,不做外部 I/O。
|
||||
- 2026-05-18 追加:为缩短首版草稿等待,`compile_puzzle_draft` 在首关命名和 `uiBackgroundPrompt` 稳定后并行启动首关关卡图生成与 UI 背景生成;上传主图且关闭 AI 重绘时,并行执行上传图持久化与 UI 背景生成。生成页预计完成时间按 5 分钟展示。
|
||||
- 影响范围:拼图结果页、拼图运行态背景渲染、拼图 agent action、`module-puzzle` / `spacetime-module` / `spacetime-client` 的拼图关卡 JSON 映射、拼图流程技术文档。
|
||||
- 验证方式:执行 `npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_ui_background --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md`。
|
||||
@@ -516,6 +628,14 @@
|
||||
- 验证方式:生产发布、服务器配置、Jenkins Job 重建或回滚时,先看 `PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
- 关联文档:`PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。
|
||||
|
||||
## 2026-05-19 release server provision 需预装 Nginx Brotli 动态模块
|
||||
|
||||
- 背景:release 服务器的 Nginx 站点配置已经预留 Brotli 指令占位,但当前 provision 流程只装了基础构建依赖,没有把 Ubuntu apt 下的 brotli 动态模块一起装上,导致 release 机器即使模板支持也可能无法启用 Brotli。
|
||||
- 决策:`scripts/jenkins-server-provision.sh` 在 apt 系统上额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,然后继续用会先 `include /etc/nginx/modules-enabled/*.conf` 的临时 `nginx -t` 配置做能力探测;非 apt 系统仍只做探测不强制安装。不要用 `nginx -V` 判断该动态模块是否可用。
|
||||
- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、`deploy/nginx/README.md`、release 服务器 Nginx 初始化。
|
||||
- 验证方式:server provision 跑过后,目标机应同时具备 Brotli 模块包与 `nginx -t` 可接受的 brotli 指令;再由 Nginx 模板启用对应指令。
|
||||
- 关联文档:`deploy/nginx/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 个人任务与埋点首版边界冻结
|
||||
|
||||
- 背景:“我的”Tab、任务、奖励、钱包和埋点涉及用户、运营、分析多条链路,需要避免范围泛化。
|
||||
@@ -523,3 +643,11 @@
|
||||
- 影响范围:用户侧任务中心、后台任务配置、运营查询、埋点查询、钱包流水。
|
||||
- 验证方式:非 `user` scope 的个人任务配置应被 API 和领域构造层拒绝;任务查询与埋点查询分别放在 `docs/operations/` 和 `docs/tracking/`。
|
||||
- 关联文档:`PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md`、`RUNTIME_PROFILE_TASK_SCOPE_2026-05-04.md`、`ANALYTICS_DATE_DIMENSION_IMPLEMENTATION_2026-05-04.md`。
|
||||
|
||||
## 普通 route tracking 先写本机 outbox 再批量入库
|
||||
|
||||
- 背景:公开作品列表压测中,成功响应后的全局 route tracking 会逐条调用 SpacetimeDB,导致数据库内存和事务压力先到边界。
|
||||
- 决策:普通 HTTP route tracking 先写入 `api-server` 本机 NDJSON outbox,后台按数量或时间阈值批量调用 SpacetimeDB;`daily_login`、`work_play_start`、支付、任务领奖、钱包等关键事件保持同步直写。
|
||||
- 默认阈值:每批 500 条或 1 秒 flush 一次;outbox 磁盘上限 256 MiB,超过后丢弃低价值 route 事件并记录指标 / 日志。
|
||||
- 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。
|
||||
- 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。
|
||||
|
||||
@@ -195,6 +195,13 @@ npm run check:server-rs-ddd
|
||||
- `docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
|
||||
- `docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`
|
||||
|
||||
## 生产压测与观测默认口径
|
||||
|
||||
- 作品列表 50 HTTP req/s 压测使用 `scripts/loadtest/README.md` 中的 K6 命令;当前脚本一次 iteration 请求两个公开列表接口,因此目标 50 HTTP req/s 对应 `PEAK_RPS=25`。
|
||||
- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、systemd 限制、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。
|
||||
- OpenTelemetry 现阶段可选发送 traces / metrics / logs,但不会取代本地 `journalctl -u genarrative-api.service`、`logs/api-server/` 与 `/var/log/nginx/genarrative.*.log`。
|
||||
- 指标 label 不写 raw URI、userId、profileId 或 request_id;request_id 只用于 trace/log 串联。
|
||||
|
||||
## 前端相关默认验证
|
||||
|
||||
前端修改后,应根据修改范围选择:
|
||||
|
||||
@@ -22,6 +22,22 @@
|
||||
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
|
||||
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。
|
||||
|
||||
## OTLP 端点只填 Collector HTTP base endpoint
|
||||
|
||||
- 现象:生产或容器 env 里把 `OTEL_EXPORTER_OTLP_ENDPOINT` 填成 `4317`、Rider 端口或别的非 HTTP base endpoint 后,api-server 发不出 OTLP,或者链路被错误转发。
|
||||
- 原因:api-server 当前走 OTLP HTTP,不是 gRPC;Collector 才是接收和转发边界。
|
||||
- 处理:生产模板用 `http://127.0.0.1:4318`,容器模板用 `http://otelcol:4318`;需要关闭时显式设 `GENARRATIVE_OTEL_ENABLED=false`,不要通过改 endpoint 绕开 Collector 语义。
|
||||
- 验证:检查 env 模板和运行态配置都指向 Collector HTTP base endpoint,日志仍通过 `journalctl` / 文件日志保留。
|
||||
- 关联:`deploy/env/api-server.env.example`、`deploy/container/api-server.env.example`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## tracking outbox 到批量阈值后先封存再异步 flush
|
||||
|
||||
- 现象:route tracking 高峰时如果主请求线程要等 SpacetimeDB 批量入库,接口延迟会被 outbox 写入链路拖长。
|
||||
- 原因:outbox 的职责是把普通 HTTP route tracking 从请求线程切走,不能把 flush 结果回写成同步阻塞。
|
||||
- 处理:达到 `BATCH_SIZE` 立即封存 active 文件并切新 active,`FLUSH_INTERVAL_MS` 只做兜底封存,后台 worker 异步 flush sealed 文件;成功删文件,失败保留重试,坏文件隔离为 `corrupt-*`,`MAX_BYTES` 只做磁盘保护。
|
||||
- 验证:普通 route 请求在 SpacetimeDB 不可用时仍能返回,恢复后 sealed 文件会继续被清理。
|
||||
- 关联:`server-rs/crates/api-server/src/tracking_outbox.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 汪汪声浪入口不要再回到独立配置阶段
|
||||
|
||||
- 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。
|
||||
@@ -44,7 +60,7 @@
|
||||
- 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。
|
||||
- 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets`、`clearCount` 和 `difficulty`。
|
||||
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
## OSS V4 签名时间和 bucket/object_key 兼容
|
||||
|
||||
@@ -83,6 +99,62 @@
|
||||
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
|
||||
- 关联:`AGENTS.md`、`npm run check:encoding`。
|
||||
|
||||
## SpacetimeDB 运行态查询不要绕过已有索引或用 procedure JSON 回传
|
||||
|
||||
- 现象:运行态接口看起来只查当前用户、作品或任务,却在 `spacetime-module` 中使用 `ctx.db.<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 MiB,cgroup peak 约 47 MiB。
|
||||
- 关联:`server-rs/crates/api-server/src/state.rs`、`deploy/container/README.md`、`deploy/container/api-server.Dockerfile`。
|
||||
|
||||
## Gallery 压测延迟升高先查入口过量放行和 TTL 边界刷新
|
||||
|
||||
- 现象:公开作品列表在 500-1000 HTTP req/s 附近可能吞吐没有明显提升,但 p95 变高、VU 上升,甚至出现排队和 dropped iterations。
|
||||
- 原因:Nginx、Axum 和缓存刷新边界如果同时允许过多请求进入,压力会先堆在连接、service 和 cache rebuild 周围;这类延迟不等同于数据库连接池不足。
|
||||
- 处理:Nginx 按 endpoint 使用 `limit_req` 快拒绝,api-server 按 `default/gallery/detail/admin` 分组 semaphore 快拒绝;拼图广场 TTL 过期时已有缓存先返回 stale 响应,只允许一个后台 refresh 任务重建,冷启动无缓存时才同步构建。
|
||||
- 验证:OTLP 看 `genarrative.http.server.request_permits.available{pool=...}`、`genarrative.puzzle_gallery.cache.stale_hits`、`refreshes_started`、`refreshes_failed`,Nginx access log 看 `request_time` 与 `upstream_response_time` 是否同步收敛;超过容量时应明确 429,而不是长时间排队或新增 502。
|
||||
- 关联:`deploy/nginx/genarrative.conf`、`deploy/container/nginx.conf`、`server-rs/crates/api-server/src/backpressure.rs`、`server-rs/crates/api-server/src/puzzle_gallery_cache.rs`。
|
||||
|
||||
## 多玩法公开广场列表优先订阅 public view / read model
|
||||
|
||||
- 现象:抓大鹅、方洞挑战、视觉小说、大鱼吃小鱼等公开列表如果沿用 `list_*_works` procedure,即使只读已发布作品,也会在每个 HTTP 请求里回到 SpacetimeDB WASM 侧扫描、反序列化配置并组装列表,50RPS 以上容易变成热点。
|
||||
- 原因:个人作品列表和公开广场列表复用了同一套 procedure 输入,导致公开列表为了通过 owner 校验传固定占位 owner,并把可长期同步的公开读模型当成请求期查询。
|
||||
- 处理:每个公开广场新增或复用专用 public view / public read model:`match_3_d_gallery_view`、`square_hole_gallery_view`、`visual_novel_gallery_view`、`big_fish_gallery_view`。`spacetime-client` 建连接后订阅这些 view 和对应 `public_work_play_daily_stat` source_type 桶,HTTP gallery 只读本地 cache。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍走原有 procedure / reducer。
|
||||
- 验证:搜索 `server-rs/crates/spacetime-client/src/{match3d,square_hole,visual_novel,big_fish}.rs`,公开 gallery 主路径应读取 `connection.db().*_gallery_view()`,不应调用 `list_*_works_with_input`;执行 `npm run spacetime:generate`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/match3d.rs`、`server-rs/crates/spacetime-module/src/square_hole.rs`、`server-rs/crates/spacetime-module/src/visual_novel.rs`、`server-rs/crates/spacetime-module/src/big_fish/session.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 自定义世界广场和创作入口配置不要每次 HTTP 请求调用只读 procedure
|
||||
|
||||
- 现象:`/api/runtime/custom-world-gallery` 每次请求调用 `list_custom_world_gallery_entries` procedure;入口熔断中间件每个玩法请求调用 `get_creation_entry_config` procedure,50RPS 以上会把 SpacetimeDB procedure 调用变成热点。
|
||||
- 原因:`custom_world_gallery_entry`、`creation_entry_config` 和 `creation_entry_type_config` 已经是可订阅读模型或配置表,但 HTTP 路径仍按“请求到来再查 procedure”处理。
|
||||
- 处理:`spacetime-client` 长连接订阅 `custom_world_gallery_entry`、`public_work_play_daily_stat` 的 `custom-world` 桶、`creation_entry_config` 和 `creation_entry_type_config`;custom-world gallery 从本地 cache 排序并聚合 7 日播放数;入口配置优先读订阅 cache,cache 缺失时用最近一次成功内存快照,再兜底调用 `get_creation_entry_config` 完成旧库兼容。旧 `list_custom_world_gallery_entries` procedure 只允许作为旧库缺少 gallery 行时的一次性同步兜底。
|
||||
- 验证:搜索 `server-rs/crates/spacetime-client/src/custom_world.rs`,gallery 主路径应是 `read_after_connect` 读取 `custom_world_gallery_entry()`;搜索 `server-rs/crates/spacetime-client/src/runtime.rs`,`get_creation_entry_config` 应优先读取 `creation_entry_config()` 和 `creation_entry_type_config()`。执行 `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/spacetime-client/src/custom_world.rs`、`server-rs/crates/spacetime-client/src/runtime.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
|
||||
|
||||
- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
|
||||
@@ -390,6 +462,14 @@
|
||||
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
||||
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
||||
|
||||
## `npm run build` 因 Vite warning 被 build-gate 判失败
|
||||
|
||||
- 现象:主站或后台 Vite 已经输出 `built in ...`,但根命令最后仍失败并打印 `Build gate failed because warnings were emitted`。
|
||||
- 原因:`scripts/build-gate.mjs` 会收集 stdout / stderr 中的 warning 行并作为硬失败;常见触发是产物 chunk 超过 `vite.config.ts` 或 `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`。
|
||||
- 处理:先看 warning 原文确认来源。若是合理的入口级 chunk 体积增长,调整对应 Vite 配置阈值或做真实拆包;不要把这类失败按 Rust / SpacetimeDB 编译错误排查。
|
||||
- 验证:重新执行 `npm run build`,主站与后台均构建完成且没有 build-gate warning 汇总。
|
||||
- 关联:`scripts/build-gate.mjs`、`vite.config.ts`、`apps/admin-web/vite.config.ts`。
|
||||
|
||||
## 反馈页清空 file input 前必须先拷贝 FileList
|
||||
|
||||
- 现象:点击上传凭证会打开文件选择框,但选择图片后页面没有展示预览,提交时也没有携带图片凭证。
|
||||
@@ -410,8 +490,8 @@
|
||||
|
||||
- 现象:`POST /api/assets/hyper3d/text-to-model` 在本地返回 503,详情里提示 `HYPER3D_API_KEY 未配置`,但开发者明明已经在本地私密文件里写了 key。
|
||||
- 原因:`scripts/dev-utils.mjs` 之前按 `.env.secrets.local → .env.local → .env` 合并,结果仓库里的 `.env` 空示例值会把前面已经设置好的私密 key 覆盖掉。
|
||||
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。
|
||||
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,且 shell 变量仍然最高优先级。
|
||||
- 处理:`npm run dev:api-server` / `npm run dev:spacetime` / `npm run dev` 统一按“外层 shell 变量优先,其后 `.env`、`.env.local`、`.env.secrets.local` 逐层覆盖”的顺序加载;真实密钥优先放 `.env.secrets.local`。本地认证开关例外:`SMS_AUTH_ENABLED`、`SMS_AUTH_PROVIDER` 等以本地 env 文件为准,避免父进程继承的旧开关值长期压过 `.env.local`。
|
||||
- 验证:本地加入临时测试后,`HYPER3D_API_KEY` 应能被 `.env.secrets.local` 覆盖,真实密钥 shell 变量仍然最高优先级;`mergeApiServerEnv(..., { SMS_AUTH_ENABLED: "false" })` 在 `.env.local` 写 `SMS_AUTH_ENABLED=true` 时应返回 true。
|
||||
- 关联:`scripts/dev-utils.mjs`、`server-rs/crates/api-server/src/hyper3d_generation.rs`、`docs/technical/HYPER3D_RODIN_GEN2_MODEL_GENERATION_2026-05-08.md`。
|
||||
|
||||
## OSS 密钥键名不要把字母 O 写成数字 0
|
||||
@@ -440,28 +520,28 @@
|
||||
## 本地短信登录页签突然消失
|
||||
|
||||
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
|
||||
- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类:
|
||||
- 原因:历史实现曾根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;接口返回空、失败或只返回 `["password"]` 时,`AuthGate` 会降级成只显示密码。
|
||||
- 本地启动脚本没有让 `.env.local` 覆盖 `.env`,`SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`。
|
||||
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500`,`AuthGate` 降级成 `["password"]`。
|
||||
- 3000 端口被旧 `dev:web` 占用后,新的完整栈 Vite 自动漂移到 3001/3002;浏览器仍打开旧 3000 页面,旧页面继续代理到已经下线的端口。
|
||||
- 生成页 UI 改动看起来“完全没变化”时,也要先确认当前浏览器打开的 Vite 进程正在返回最新源码;例如直接请求 `http://127.0.0.1:3000/src/components/CustomWorldGenerationView.tsx` 检查是否包含本次新增类名或关键字。
|
||||
- 单独 `npm run dev:web` 启动瞬间另一个临时 API 端口可用,脚本若自动切过去,之后临时 API 停掉也会让 3000 继续代理到空端口。
|
||||
- 处理:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local` 的 `SMS_AUTH_ENABLED` 与代理端口;若 3001/3002 才返回正确结果,说明当前 3000 是旧前端进程,应清理旧进程后重启。
|
||||
- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。
|
||||
- 关联:`scripts/dev-utils.mjs`、`scripts/dev.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。
|
||||
- 处理:当前口径是登录弹窗永远展示 `短信登录` 与 `密码登录` 两个核心入口;`login-options` 只补充微信等环境相关入口,不能隐藏短信或密码页签。如果“获取验证码”点击后失败,再按短信 provider / API 代理问题排查:优先用 `npm run dev:api-server`、`npm run dev:spacetime` 或 `npm run dev` 启动,确认 `.env.local` 覆盖 `.env`、`RUST_SERVER_TARGET` 没有指向旧端口,并分别请求 3000 域名和 Rust API 目标。
|
||||
- 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。
|
||||
- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。
|
||||
|
||||
## 本地短信收不到验证码先查 provider
|
||||
|
||||
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
|
||||
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;另外 `npm run dev:api-server` 过去曾让 `.env` 覆盖 `.env.local`,导致本地真实短信配置被错误压回默认值。
|
||||
- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_PROVIDER` 显式设为 `aliyun`,然后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。
|
||||
- 验证:`GET /api/auth/login-options` 返回 `["phone","password"]`,`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。
|
||||
- 关联:`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。
|
||||
- 原因:本地 `.env.local` 里如果是 `SMS_AUTH_PROVIDER="mock"`,后端不会发真实短信,只会返回固定 mock 验证码;真实阿里云链路已经改为普通短信 `SendSms`,验证码由当前 `api-server` 进程本地生成、哈希存储和校验,旧 `SendSmsVerifyCode` / `CheckSmsVerifyCode` 托管验证码参数不再参与真实校验。若接口直接返回“手机号登录暂未启用”,说明当前运行中的 `api-server` 进程内 `sms_auth_enabled=false`:常见原因是修改 `.env.local` 后没有重启后端,或外层 shell 已经设置了非空 `SMS_AUTH_ENABLED` 导致 dotenv 不覆盖。历史上 cmd 里 `set SMS_AUTH_ENABLED="true"` 会把引号也传进进程,Rust bool 解析失败后保持默认 false。
|
||||
- 处理:真实短信联调时把 `.env.local` 的 `SMS_AUTH_ENABLED=true`、`SMS_AUTH_PROVIDER=aliyun` 显式打开,并确认 `ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com`、`ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技`、`ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486`、`ALIYUN_SMS_TEMPLATE_PARAM_KEY=code` 后重启 `api-server`;如果只想验证 UI 和账号链路,则保留 `mock` 并使用 `SMS_AUTH_MOCK_VERIFY_CODE`。Shell 临时覆盖时 PowerShell 用 `$env:SMS_AUTH_ENABLED="true"`,cmd 用 `set SMS_AUTH_ENABLED=true`,不要把引号作为值的一部分。`api-server` 重启会清掉未校验的本地验证码。
|
||||
- 验证:分别请求浏览器域名和 Rust API 直连的 `/api/auth/login-options`,都应返回 `["phone","password"]`;`api-server` 日志里 `provider=aliyun` 才说明真实短信链路已生效。需要直接确认平台层真实调用阿里云时,配置 `ALIYUN_SMS_ACCESS_KEY_ID`、`ALIYUN_SMS_ACCESS_KEY_SECRET` 和 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 后手动执行 `cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture`。
|
||||
- 关联:`server-rs/crates/api-server/src/config.rs`、`scripts/dev-utils.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`、`docs/technical/PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`。
|
||||
|
||||
## 手机验证码登录 500 先查短信 provider 语义
|
||||
|
||||
- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。
|
||||
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。
|
||||
- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。当前验证码校验已经改成本地哈希校验,登录阶段的验证码错误不会再调用阿里云校验接口;若登录前的发送阶段失败,应优先看 `SendSms` 返回的 `Code/Message`。
|
||||
- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run dev:api-server`。
|
||||
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
|
||||
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
|
||||
@@ -656,6 +736,14 @@
|
||||
- 验证:扫描 `jenkins/Jenkinsfile.production-database-export` 与 `jenkins/Jenkinsfile.production-database-import`,确认 `INCLUDE_TABLES`、`CHUNK_SIZE`、`SERVER_BACKUP_DIRECTORY`、`SMOKE_HEALTH_URL` 等可选参数不再裸读。
|
||||
- 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`。
|
||||
|
||||
## Jenkins 二次 checkout 后脚本执行位会被 Git 还原
|
||||
|
||||
- 现象:`Genarrative-Server-Provision` 已在 shell 块前面对脚本执行 `chmod +x`,但进入 `Prepare Provision Tools` 后仍报 `scripts/prepare-server-provision-tools.sh: Permission denied` / `exit code 126`。
|
||||
- 原因:该阶段会先运行 `scripts/jenkins-checkout-source.sh`,脚本内部执行 `git reset --hard HEAD` 和 `git clean -fd`,会把前面临时 `chmod` 的执行位还原为 Git 记录的 mode;若被直接执行的脚本在仓库里是 `100644`,二次 checkout 后仍不可执行。
|
||||
- 处理:需要直接以 `scripts/*.sh` 方式执行的 Jenkins 脚本应提交为 Git `100755`;如果只想临时授权,必须放在 `scripts/jenkins-checkout-source.sh` 完成之后。
|
||||
- 验证:运行 `git ls-files --stage scripts/prepare-server-provision-tools.sh`,确认 mode 为 `100755`;重新跑 `Genarrative-Server-Provision` 时应进入工具下载/打包日志,而不是停在 `Permission denied`。
|
||||
- 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`scripts/jenkins-checkout-source.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 个人任务 scope 不得扩成 work/site/module
|
||||
|
||||
- 现象:个人任务配置为 `work` / `site` / `module` 后进度串桶或静默按 0 处理。
|
||||
@@ -776,6 +864,22 @@
|
||||
- 验证:执行 `cargo test -p api-server jsapi_order_request_sets_wechat_required_http_headers --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/api-server/src/wechat_pay.rs`、`docs/technical/MY_TAB_ACCOUNT_RECHARGE_IMPLEMENTATION_2026-04-25.md`。
|
||||
|
||||
## 容器公开列表压测不要靠继续抬并发吃满 CPU
|
||||
|
||||
- 现象:2C / 2G 容器压测公开 gallery list 时,`api-server` CPU 仍有余量,看起来像可以继续提高 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS` 或 Nginx `limit_conn`。
|
||||
- 原因:当前瓶颈不是 Tokio worker 线程数。`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery` 成功响应后会走全局 route tracking,继续向 SpacetimeDB 写 `record_tracking_event_and_return`;入口并发从 320 抬到 336 / 352 时,SpacetimeDB 内存先逼近 `896m` 容器上限,200 请求 p95 变差,429 比例没有改善。
|
||||
- 处理:2C / 2G 容器模拟里公开 gallery list 暂以 `limit_conn=320`、`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 作为稳定上限。若要继续提升吞吐,优先减少高频公开 GET 的 tracking 写入、做采样或改成批量/异步聚合;不要单纯放大入口并发。
|
||||
- 验证:宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=1000` 等价约 2000 HTTP req/s;320 档无 dropped iterations、无 5xx、无 OOM,200 请求 `request_time p95` 约 0.292s。336 / 352 档 p95 升到约 0.31s / 0.32s,SpacetimeDB 内存尾部可到约 `880MiB / 896MiB`。
|
||||
- 关联:`deploy/container/nginx.conf`、`deploy/container/api-server.env.example`、`deploy/container/README.md`、`server-rs/crates/api-server/src/tracking.rs`。
|
||||
|
||||
## tracking outbox 成功入库后删除 sealed 文件
|
||||
|
||||
- 现象:普通 route tracking 改为本机 outbox 后,容易误以为入库成功只需要清空文件内容。
|
||||
- 原因:清空文件会扩大崩溃窗口,进程在 truncate 和确认之间异常退出时可能丢失未确认事件。
|
||||
- 处理:当前 active NDJSON 达到数量或时间阈值后原子 rename 为 sealed 文件;后台批量 flush sealed 文件,SpacetimeDB 返回成功后直接删除该文件,失败则保留文件等待重试。sealed 文件如果出现无法解析的坏行,重命名为 `corrupt-*` 隔离并记录指标,避免阻塞后续批量入库。该路径是至少一次投递,重复事件由 `tracking_event.event_id` 幂等跳过。
|
||||
- 验证:模拟 SpacetimeDB 不可用时 sealed 文件保留;恢复后批量 procedure 成功,sealed 文件消失,`tracking_event` 与 `tracking_daily_stat` 均更新。
|
||||
- 关联:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`。
|
||||
|
||||
## 后台表查询展示 SpacetimeDB 枚举时不要套用 Option 解码
|
||||
|
||||
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind` 和 `status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
|
||||
@@ -798,7 +902,7 @@
|
||||
- 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。
|
||||
- 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21;历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。
|
||||
- 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。
|
||||
|
||||
## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉
|
||||
|
||||
@@ -888,6 +992,22 @@
|
||||
- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。
|
||||
- 关联:`src/services/puzzle-works/puzzleHistoryAsset.ts`、`src/components/puzzle-agent/PuzzleHistoryAssetPickerDialog.tsx`、`docs/technical/ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md`。
|
||||
|
||||
## 拼图历史图关闭 AI 重绘不要强制 Data URL
|
||||
|
||||
- 现象:拼图创作页从历史生成图片中选择主图,再关闭 AI 重绘生成草稿时,后端报“上传图必须是图片 Data URL”。
|
||||
- 原因:历史图 `imageSrc` 是 `/generated-puzzle-assets/...` 私有兼容路径;AI 重绘开启时后端参考图分支会解析该路径,但关闭 AI 重绘的“直用上传图”分支旧实现只调用 `parse_puzzle_image_data_url`。
|
||||
- 处理:关闭 AI 重绘时也复用拼图参考图解析入口,允许 Data URL 与 `/generated-*` 历史路径统一转成 `PuzzleDownloadedImage` 后持久化;前端不需要下载历史图再转 base64。
|
||||
- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle_uploaded_cover_can_reuse_resolved_history_image --manifest-path server-rs\Cargo.toml`、`npm run dev:api-server` 后检查 `/healthz`。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle/draft.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx`。
|
||||
|
||||
## 拼图结果页局部生图不要污染草稿生成态
|
||||
|
||||
- 现象:拼图草稿已经生成完成后,在结果页重新生成 UI 背景或追加关卡生成图片,草稿页仍显示整卡“生成中”,点击草稿会回到生成过程页,无法查看已有结果;UI 背景生成中还会禁用“新增关卡”和关卡图生成。
|
||||
- 原因:结果页局部 action 复用了全局 `isPuzzleBusy` / 持久化 `generationStatus=generating` 语义,作品架没有区分“初始草稿不可查看”和“已有结果上的局部关卡生成”。
|
||||
- 处理:作品架只在拼图没有可用封面、首关候选图或任一可查看关卡时才把 `generationStatus=generating` 解释为初始草稿生成;结果页 UI 背景和关卡图走 background action,不设置全局 busy,UI 背景只禁用自己的按钮;SpacetimeDB/API mapper 读写时把已有图片但状态仍是 `generating` 的历史关卡归一为 `ready`。
|
||||
- 验证:`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p api-server puzzle --manifest-path server-rs\Cargo.toml`。
|
||||
- 关联:`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle/mappers.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。
|
||||
|
||||
## Jenkins 数据库导入导出脚本先补 Node 工具链 PATH
|
||||
|
||||
- 现象:`Genarrative-Database-Import` 或 `Genarrative-Database-Export` 运行到迁移脚本时,`bash` 报 `node: command not found`,常见在日志里表现为某个 `sh` 块内第 61 行直接调用 `node` 失败。
|
||||
@@ -895,3 +1015,27 @@
|
||||
- 处理:导入 / 导出流水线在调用迁移脚本前先 `source scripts/jenkins-prepare-toolchain-env.sh`;该脚本会把 `GENARRATIVE_JENKINS_TOOL_PATHS`、`/var/lib/jenkins/.nvm/versions/node/v22.22.2/bin`、`/var/lib/jenkins/.cargo/bin`、`/var/lib/jenkins/.local/bin` 和系统 PATH 前缀统一补齐,并在缺少 `node` 时尽早报错。
|
||||
- 验证:重新跑 `Genarrative-Database-Import` 或 `Genarrative-Database-Export`,日志应先打印 `jenkins-toolchain` 的 `node=...` 解析结果,而不是在迁移中途报 `node: command not found`。
|
||||
- 关联:`scripts/jenkins-prepare-toolchain-env.sh`、`jenkins/Jenkinsfile.production-database-import`、`jenkins/Jenkinsfile.production-database-export`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Windows Jenkins `powershell` step 在 Stdb module 构建里曾触发 CreateProcess error=5
|
||||
|
||||
- 现象:`Genarrative-Stdb-Module-Build` 在 Windows Jenkins 节点上报 `java.io.IOException: Cannot run program "powershell" (in directory "C:\\Users\\DSK\\.jenkins-local\\workspace\\Genarrative-Stdb-Module-Build"): CreateProcess error=5, 拒绝访问。`;日志里能看到 `durable-task` 已写出 `powershellWrapper.ps1`,但在真正启动裸 `powershell` 子进程时失败。
|
||||
- 原因:Jenkins durable-task 的 `powershell` step 依赖一个隐式命令解析/启动路径,在这台 Windows 本地 Jenkins 环境里会被拒绝。`powershell.exe` 本体和 workspace ACL 都是正常的,问题出在 Jenkins step 的启动方式,而不是 PowerShell 脚本内容。修复后若日志能打印 `[jenkins-powershell] exe:`,但随后仅报 `拒绝访问` / `script returned exit code 5`,通常已经不是 PowerShell 启动失败,而是 Checkout 脚本内部命令在 Windows workspace 里触发权限拒绝。若 `.jenkins-*.ps1` 里中文 `throw '[stdb-build] ...'` 报 `MissingArrayIndexExpression`,则是 Windows PowerShell 5.1 用 `-File` 解析无 BOM UTF-8 脚本时按本地 ANSI 误解码。
|
||||
- 处理:把 `jenkins/Jenkinsfile.production-stdb-module-build` 的 `Checkout` 和 `Build Stdb Module` 两处 `powershell` step 收口成 `runWindowsPowerShell(...)` helper,先用 `writeFile` 写出临时 `.ps1`,再用显式 `powershell.exe` 把脚本重写成 UTF-8 with BOM,最后通过 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ...` 执行。这个 helper 写在 Groovy GString 里时,PowerShell 的 `$path` / `$text` / `$true` 必须写成 `\$path` / `\$text` / `\$true`,否则 Jenkinsfile 会在 Groovy 编译阶段报 `unexpected token: true`。Checkout 阶段优先复用 Jenkins GitSCM 已完成的工作区结果;`COMMIT_HASH` 为空或已经等于当前 `HEAD` 时不再重复 `git fetch` / `git checkout` / `git clean`,只有确实要切到另一个指定 commit 时才补 fetch、归属校验和 checkout。
|
||||
- 验证:检查 Jenkins build log 中是否出现 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,以及 `[stdb-checkout] current HEAD:`。上游 Full Build 传下来的 `COMMIT_HASH` 若已等于当前 GitSCM checkout,日志应显示 `requested commit already matches Jenkins GitSCM checkout` 并继续进入构建阶段;同时确认 `builds/<n>/log` 不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或 Checkout 内部 exit code 5。
|
||||
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## QQ 浏览器发现页推荐封面全不显示先查 aspect-ratio 兜底
|
||||
|
||||
- 现象:发现页的“推荐”子频道作品卡标题、作者和数据正常,但所有封面图不显示,常见于 QQ 浏览器 / X5 等旧移动内核。
|
||||
- 原因:公开作品卡封面内部图片是绝对铺满,容器原本主要依赖 Tailwind `aspect-video` / CSS `aspect-ratio` 撑高;旧内核不支持或实现异常时封面容器高度会坍缩为 0。若封面还是 `/generated-*` 私有资源,换签失败后没有玩法参考图兜底时会进一步表现成黑卡。
|
||||
- 处理:`.platform-public-work-card__cover::before` 使用 `padding-top: 56.25%` 保留 16:9 高度,沉浸式卡片单独覆盖比例;公开作品卡通过 `resolvePlatformWorldFallbackCoverImage(...)` 给 `ResolvedAssetImage` 传入玩法参考图兜底,签名失败或图片加载失败时仍有可见封面。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联:`src/index.css`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/rpgEntryWorldPresentation.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 生成中草稿刷新后不要只恢复作品架遮罩
|
||||
|
||||
- 现象:拼图或抓大鹅草稿生成中刷新网页后,作品架卡片能显示等待遮罩,但点击卡片会走普通草稿恢复,可能进入空白结果页或未完成工作区。
|
||||
- 原因:前端只把内存 notice 当作“生成中点击恢复”的判断条件,没有把后端摘要里的 `generationStatus=generating` 纳入同一路径。
|
||||
- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。
|
||||
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
27
.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md
Normal file
27
.hermes/todos/【后端架构】前端直订阅公开作品列表准入待办-2026-05-16.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 前端直订阅公开作品列表准入待办
|
||||
|
||||
## 背景
|
||||
|
||||
未来可以考虑让前端直接订阅公开作品列表,以减少列表读取链路中的 HTTP 往返,并复用 SpacetimeDB 的实时同步能力。
|
||||
|
||||
## 当前结论
|
||||
|
||||
短期仍由 `api-server` / BFF 订阅 SpacetimeDB public read model,并从本地 cache 读取后对外提供 HTTP 列表接口。前端不直接订阅作品源表,也不把正式列表排序、分页、权限裁剪逻辑下放到 UI。
|
||||
|
||||
## 落地前置条件
|
||||
|
||||
- 建立专用、稳定、低基数的 public read model,例如 `public_work_gallery_entry`。
|
||||
- 明确权限边界,只暴露公开列表所需字段,不泄露作者私有信息、审核内部状态或运营字段。
|
||||
- 固化字段契约,明确字段含义、默认值、兼容策略和生成绑定更新流程。
|
||||
- 明确排序与分页语义,避免依赖自增 ID 顺序,优先使用时间戳或显式排序字段。
|
||||
- 补齐埋点方案,能区分直订阅首屏、增量更新、分页加载和 fallback 命中。
|
||||
- 保留 BFF HTTP fallback,用于低版本客户端、订阅失败、权限策略调整和灰度回滚。
|
||||
- 禁止前端订阅 `puzzle_work_profile`、`custom_world_profile` 等作品源表。
|
||||
|
||||
## 建议验收
|
||||
|
||||
- 文档确认直订阅只面向专用 public read model,不绕过 BFF 读取源表。
|
||||
- schema、绑定、字段契约、排序分页和权限说明同步更新。
|
||||
- 前端具备订阅失败后的 BFF HTTP fallback。
|
||||
- 自动测试覆盖公开字段裁剪、排序分页稳定性和 fallback 路径。
|
||||
- 监控可观察直订阅成功率、首屏耗时、增量更新延迟和 fallback 比例。
|
||||
10
.idea/.gitignore
generated
vendored
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
||||
mod.rs
|
||||
59
.idea/codeStyles/Project.xml
generated
59
.idea/codeStyles/Project.xml
generated
@@ -1,59 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<HTMLCodeStyleSettings>
|
||||
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
||||
</HTMLCodeStyleSettings>
|
||||
<JSCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</JSCodeStyleSettings>
|
||||
<TypeScriptCodeStyleSettings version="0">
|
||||
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
|
||||
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
|
||||
<option name="SPACES_WITHIN_IMPORTS" value="true" />
|
||||
</TypeScriptCodeStyleSettings>
|
||||
<VueCodeStyleSettings>
|
||||
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
|
||||
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
|
||||
</VueCodeStyleSettings>
|
||||
<codeStyleSettings language="HTML">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="JavaScript">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="TypeScript">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="2" />
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
<option name="TAB_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="Vue">
|
||||
<option name="SOFT_MARGINS" value="80" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="2" />
|
||||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
248
.idea/editor.xml
generated
248
.idea/editor.xml
generated
@@ -1,248 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="BackendCodeEditorSettings">
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CDeclarationWithImplicitIntType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CommentTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=ConstevalIfIsAlwaysConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractClassWithoutSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAbstractVirtualFunctionCallInCtor/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAccessSpecifierWithNoDeclarations/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppAwaiterTypeIsNotClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBooleanIncrementExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatBadCode/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatLegacyCode/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatMixedArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooFewArgs/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCompileTimeConstantCanBeReplacedWithBooleanConstant/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConceptNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConditionalExpressionCanBeSimplified/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppConstValueFunctionReturnType/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCoroutineCallResolveError/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAArrayIndexOutOfBounds/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantConditions/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantFunctionResult/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAConstantParameter/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFADeletedPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAEndlessLoop/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInfiniteRecursion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAInvalidatedMemory/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALocalValueEscapesScope/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFALoopConditionNotUpdated/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAMemoryLeak/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANotInitializedField/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFANullDereference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFATimeOver/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableCode/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreachableFunctionCall/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnreadVariable/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDFAUnusedValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesLocal/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationHidesUncapturedLocal/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclarationSpecifierWithoutDeclarators/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorDisambiguatedAsFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeclaratorUsedBeforeInitialization/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultCaseNotHandledInSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedEntity/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedOverridenMethod/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeprecatedRegisterStorageClassSpecifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDereferenceOperatorLimitExceeded/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDiscardedPostfixOperatorResult/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenSyntaxError/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUndocumentedParameter/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDoxygenUnresolvedReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEmptyDeclaration/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersOrder/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceCVQualifiersPlacement/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceDoStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceForStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceFunctionDeclarationStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceIfStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceNestedNamespacesStyle/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingDestructorStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceOverridingFunctionStyle/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceTypeAliasCodeStyle/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnforceWhileStatementBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityAssignedButNoRead/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEntityUsedOnlyInUnevaluatedContext/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEnumeratorNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEqualOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppEvaluationFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExplicitSpecializationInNonNamespaceScope/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppExpressionWithoutSideEffects/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalFunctionInFinalClass/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFinalNonOverridingVirtualFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForLoopCanBeReplacedWithWhile/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppForwardEnumDeclarationWithoutUnderlyingType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionDoesntReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionResultShouldBeUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppFunctionalStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHeaderHasBeenAlreadyIncluded/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHiddenFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppHidingFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIdenticalOperandsInBinaryExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIfCanBeReplacedByConstexprIf/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppImplicitDefaultConstructorNotAvailable/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompatiblePointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIncompleteSwitchStatement/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInconsistentNaming/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppIntegralToPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppInvalidLineContinuation/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppJoinDeclarationAndAssignment/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLambdaCaptureNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLocalVariableWithNonTrivialDtorIsNeverUsed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppLongFloat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeConst/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberFunctionMayBeStatic/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMemberInitializersOrder/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMismatchedClassTags/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingIncludeGuard/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMissingKeywordThrow/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppModulePartitionWithSeveralPartitionUnits/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtAddressOfClassRValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtBindingRValueToLvalueReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtCopyElisionInCopyInitDeclarator/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtDoubleUserConversionInCopyInit/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtNotInitializedStaticConstLocalVar/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMsExtReinterpretCastFromNullptr/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMultiCharacterWideLiteral/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMustBePublicVirtualToImplementInterface/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppMutableSpecifierOnReferenceMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNoDiscardExpression/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNodiscardFunctionWithoutReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExceptionSafeResourceAcquisition/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConversionOperator/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonExplicitConvertingConstructor/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineFunctionDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNonInlineVariableDefinitionInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppNotAllPathsReturnValue/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppObjectMemberMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppOutParameterMustBeWritten/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConst/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterMayBeConstPtrOrRef/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNamesMismatch/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPassValueParameterByConstReference/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerConversionDropsQualifiers/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPointerToIntegralConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPolymorphicClassWithNonVirtualPublicDestructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyErroneousEmptyStatements/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPossiblyUnintendedObjectSlicing/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderIsNotIncluded/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrecompiledHeaderNotFound/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfBadFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfExtraArg/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfMissedArg/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrintfRiskyFormat/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppPrivateSpecialMemberFunctionIsNotImplemented/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRangeBasedForIncompatibleReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedefinitionOfDefaultArgumentInOverrideFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassAccessSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBaseClassInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantBooleanExpressionArgument/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantCastExpression/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantComplexityInComparison/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConditionalExpression/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantConstSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantControlFlowJump/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantDereferencingAndTakingAddress/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElaboratedTypeSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeyword/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantElseKeywordInsideCompoundStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyDeclaration/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantEmptyStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantExportKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantFwdClassOrEnumSpecifier/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantInlineSpecifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantLambdaParameterList/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantMemberInitializer/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantNamespaceDefinition/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantParentheses/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifier/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantQualifierADL/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnMemberAllocationFunction/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantStaticSpecifierOnThreadLocalLocalVariable/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateArguments/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantVoidArgumentList/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRedundantZeroInitializerInAggregateInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReinterpretCastFromVoidPtr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppRemoveRedundantBraces/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceMemsetWithZeroInitialization/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReplaceTieWithStructuredBinding/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppReturnNoValueInNonVoidFunction/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSmartPointerVsMakeFunction/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSomeObjectMembersMightNotBeInitialized/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppSpecialFunctionWithoutNoexceptSpecification/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticAssertFailure/@EntryIndexedValue" value="ERROR" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticDataMemberInUnnamedStruct/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStaticSpecifierOnAnonymousNamespaceMember/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppStringLiteralToCharPointerConversion/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTabsAreDisallowed/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateArgumentsCanBeDeduced/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterNeverUsed/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTemplateParameterShadowing/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppThrowExpressionCanBeReplacedWithRethrow/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScope/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTooWideScopeInitStatement/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppTypeAliasNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedDependentBaseClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUninitializedNonStaticDataMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnionMemberOfReferenceType/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaEndRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnmatchedPragmaRegionDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnamedNamespaceInHeaderFile/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnnecessaryWhitespace/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnsignedZeroComparison/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUnusedIncludeDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAlgorithmWithCount/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAssociativeContains/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAuto/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseAutoForNumeric/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseElementsView/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseEraseAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseFamiliarTemplateSyntaxForGenericLambdas/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseRangeAlgorithm/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStdSize/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseStructuredBinding/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUseTypeTraitAlias/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUserDefinedLiteralSuffixDoesNotStartWithUnderscore/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppUsingResultOfAssignmentAsCondition/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVariableCanBeMadeConstexpr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionCallInsideCtor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVirtualFunctionInFinalClass/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppVolatileParameterInDeclaration/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWarningDirective/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongIncludesOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppWrongSlashesInIncludeDirective/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroConstantCanBeReplacedWithNullptr/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppZeroValuedExpressionUsedAsNullPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IdentifierTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=IfStdIsConstantEvaluatedCanBeReplaced/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StdIsConstantEvaluatedWillAlwaysEvaluateToConstant/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=StringLiteralTypo/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,6 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/Genarrative.iml" filepath="$PROJECT_DIR$/.idea/Genarrative.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/prettier.xml
generated
6
.idea/prettier.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
183
deploy/container/README.md
Normal file
183
deploy/container/README.md
Normal 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 :8082,Linux release 构建,连接 compose 内 SpacetimeDB
|
||||
├─ otelcol :4317/4318,debug exporter,接收 traces / metrics / logs
|
||||
└─ k6 profile=loadtest 时临时启动,在 compose 网络内压 nginx
|
||||
```
|
||||
|
||||
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
||||
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
||||
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
||||
|
||||
默认 host 端口:
|
||||
|
||||
- `http://127.0.0.1:13101`:容器 SpacetimeDB。
|
||||
- `http://127.0.0.1:18080`:容器 Nginx。
|
||||
- `127.0.0.1:4317` / `127.0.0.1:4318`:容器 Collector OTLP gRPC / HTTP。
|
||||
|
||||
如端口冲突,可设置:
|
||||
|
||||
```powershell
|
||||
$env:GENARRATIVE_CONTAINER_SPACETIME_PORT="13102"
|
||||
$env:GENARRATIVE_CONTAINER_HTTP_PORT="18081"
|
||||
$env:GENARRATIVE_CONTAINER_OTLP_HTTP_PORT="14318"
|
||||
$env:GENARRATIVE_CONTAINER_OTLP_GRPC_PORT="14317"
|
||||
```
|
||||
|
||||
## 初始化
|
||||
|
||||
```bash
|
||||
npm run container:init
|
||||
```
|
||||
|
||||
该命令会从 `deploy/container/api-server.env.example` 生成本地 `deploy/container/api-server.env`。真实 token、库名和外部服务密钥只写本地 env 文件,不提交 Git。
|
||||
|
||||
Docker Desktop 下默认通过 `http://spacetimedb:3101` 连接 compose 内 SpacetimeDB;宿主机只负责用 CLI 发布模块:
|
||||
|
||||
```env
|
||||
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
|
||||
GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest
|
||||
GENARRATIVE_SPACETIME_TOKEN=
|
||||
```
|
||||
|
||||
宿主机发布模块时,先用 CLI 向 `http://127.0.0.1:13101` 发布到 `genarrative-loadtest`,再启动 `npm run container:up`。
|
||||
|
||||
Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `http://127.0.0.1:13101`;容器内部服务之间统一走 `http://spacetimedb:3101`。
|
||||
|
||||
## 构建工具链
|
||||
|
||||
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.2.0` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
|
||||
|
||||
## 启动与验证
|
||||
|
||||
```bash
|
||||
npm run container:config
|
||||
npm run container:build
|
||||
npm run container:up -- spacetimedb
|
||||
spacetime publish genarrative-loadtest --server http://127.0.0.1:13101 --module-path server-rs/crates/spacetime-module --yes --build-options="--debug"
|
||||
npm run container:up
|
||||
npm run container:ps
|
||||
curl -sS http://127.0.0.1:18080/api/runtime/puzzle/gallery
|
||||
```
|
||||
|
||||
查看日志:
|
||||
|
||||
```bash
|
||||
npm run container:logs -- nginx
|
||||
npm run container:logs -- api-server
|
||||
npm run container:logs -- otelcol
|
||||
```
|
||||
|
||||
`npm run container:config` 默认只校验配置,不打印完整 env。排查 compose 展开结果时可临时使用:
|
||||
|
||||
```bash
|
||||
npm run container:config -- --print
|
||||
```
|
||||
|
||||
如果 `deploy/container/api-server.env` 已写入真实 token,不要把完整展开结果贴到公开渠道。
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
npm run container:down
|
||||
```
|
||||
|
||||
如需同时清理容器卷:
|
||||
|
||||
```bash
|
||||
npm run container:down -- -v
|
||||
```
|
||||
|
||||
## 压测
|
||||
|
||||
k6 在 compose 网络内访问 `http://nginx`,避免 Windows 本机直连连接模型干扰 Linux 容器结果:
|
||||
|
||||
```bash
|
||||
npm run container:k6
|
||||
```
|
||||
|
||||
作品列表脚本一次 iteration 默认请求两个公开列表接口,因此目标 500 HTTP req/s 对应 `PEAK_RPS=250`:
|
||||
|
||||
```powershell
|
||||
$env:SCENARIO="spike"
|
||||
$env:START_RPS="25"
|
||||
$env:PEAK_RPS="250"
|
||||
$env:HOLD="60s"
|
||||
$env:END_RPS="25"
|
||||
$env:PREALLOCATED_VUS="100"
|
||||
$env:MAX_VUS="500"
|
||||
$env:DETAIL_RATIO="0"
|
||||
npm run container:k6
|
||||
```
|
||||
|
||||
容器内 `api-server` 资源上限与 Nginx 连接模型已经按 `genarrative-release` 的 2C / 2G / `nofile=4096` / `worker_connections=768` 收口;如果你要改成别的机器,就先重新采样再改这里。
|
||||
|
||||
SpacetimeDB 容器默认只提供运行时,不自动发布模块。首次启动或清理 `spacetime-data` 卷后,先只启动 `spacetimedb` 服务,再发布模块:
|
||||
|
||||
```bash
|
||||
npm run container:up -- spacetimedb
|
||||
spacetime publish genarrative-loadtest --server http://127.0.0.1:13101 --module-path server-rs/crates/spacetime-module --yes --build-options="--debug"
|
||||
```
|
||||
|
||||
发布完成后再执行 `npm run container:up` 和 `npm run container:k6`。如果 `deploy/container/api-server.env` 里的 `GENARRATIVE_SPACETIME_DATABASE` 改成了别的库名,发布命令里的库名也要同步修改。
|
||||
|
||||
如果要压 1000 HTTP req/s,把 `PEAK_RPS` 调到 `500`;如果要压 5000 HTTP req/s,把 `PEAK_RPS` 调到 `2500`,并同时提高 `PREALLOCATED_VUS` / `MAX_VUS`,观察是否先被带宽、Nginx `limit_conn` / `limit_req` 或 api-server 分组背压限制。当前容器 Nginx 对公开 gallery list 使用 `genarrative_gallery_rps`,公开详情和普通 API 使用 `genarrative_api_rps`,后台 API 使用 `genarrative_admin_rps`;api-server 侧对应 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS` 和 `GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。
|
||||
|
||||
2026-05-19 的 2C / 2G 容器压测结论:公开 gallery list 的 `limit_conn=320`、`limit_req rate=5000r/s burst=4096` 与 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320` 是当前发布口径。用宿主机 k6 打 `http://127.0.0.1:18080`,`PEAK_RPS=2500` 等价于约 5000 HTTP req/s 的两接口组合压测;连续 10 轮不重启 SpacetimeDB 的平均实际吞吐约 `4219 HTTP req/s`,总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`。该档会让 SpacetimeDB 内存从约 `366MiB` 累积到约 `885MiB / 896MiB`,下游内存先到危险区。当前不要为了降低“剩余 CPU”继续抬公开列表并发;下一步应减少成功列表请求后的 SpacetimeDB tracking 写入或优化下游连接 / 订阅状态,而不是放大入口并发。
|
||||
|
||||
### 内存采样
|
||||
|
||||
排查 API 容器内存时,优先对比压测前后的 `/proc/$pid/smaps_rollup` 和 cgroup 当前/峰值,不把 Windows 任务管理器总占用当成单进程结论:
|
||||
|
||||
```bash
|
||||
docker exec genarrative-container-loadtest-api-server-1 sh -c 'pid=$(pidof api-server); grep VmRSS /proc/$pid/status; grep RssAnon /proc/$pid/status; cat /proc/$pid/smaps_rollup | grep Anonymous; echo cgroup_current=$(cat /sys/fs/cgroup/memory.current); echo cgroup_peak=$(cat /sys/fs/cgroup/memory.peak)'
|
||||
```
|
||||
|
||||
`/healthz` 也能复现的内存尖峰应先按连接层、service clone 或 allocator 高水位排查,不要直接归因到 SpacetimeDB procedure、作品列表 cache 或业务 DTO。2026-05-18 验证:`AppState` 改为 `Arc<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 MiB,cgroup 峰值约 47 MiB,未再出现 1 GiB 级尖峰。
|
||||
|
||||
## OTLP
|
||||
|
||||
容器内 `otelcol` 默认使用 debug exporter。开启 api-server OTEL:
|
||||
|
||||
```env
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318
|
||||
```
|
||||
|
||||
然后重建或重启容器:
|
||||
|
||||
```bash
|
||||
npm run container:up
|
||||
npm run container:logs -- otelcol
|
||||
```
|
||||
|
||||
Collector 日志会输出 traces / metrics / logs。接 Rider、Jaeger、Tempo、Prometheus、Grafana 或托管平台时,另建独立 Collector 配置,不直接改生产 systemd 或 Nginx 模板。
|
||||
|
||||
容器内需要临时转发到 Grafana Cloud 时,切换 Collector 配置并从当前 shell 传入 Grafana Cloud 凭据;真实 token 不写入仓库文件:
|
||||
|
||||
```powershell
|
||||
$env:GENARRATIVE_CONTAINER_OTELCOL_CONFIG="./otelcol.grafana.yaml"
|
||||
$env:GRAFANA_CLOUD_OTLP_ENDPOINT="https://..."
|
||||
$env:GRAFANA_CLOUD_BASIC_AUTH_HEADER="Basic ..."
|
||||
npm run container:up
|
||||
npm run container:logs -- otelcol
|
||||
```
|
||||
|
||||
`deploy/container/otelcol.grafana.yaml` 会同时保留本地 debug exporter,并通过 `otlphttp/grafana` 把 traces / metrics / logs 发到 Grafana Cloud。
|
||||
|
||||
## 隔离边界
|
||||
|
||||
- 不改生产 systemd 单元。
|
||||
- 不改 Jenkins 发布主流程。
|
||||
- 不要求真实 HTTPS 证书。
|
||||
- 不把真实 `.env`、`.env.local`、`.env.secrets.local` 或 `deploy/container/api-server.env` 放入 Docker build context。
|
||||
- 不在容器镜像里内置 SpacetimeDB 数据或 token。
|
||||
51
deploy/container/api-server.Dockerfile
Normal file
51
deploy/container/api-server.Dockerfile
Normal 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
|
||||
42
deploy/container/api-server.env.example
Normal file
42
deploy/container/api-server.env.example
Normal 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=
|
||||
147
deploy/container/docker-compose.loadtest.yml
Normal file
147
deploy/container/docker-compose.loadtest.yml
Normal 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
220
deploy/container/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
deploy/container/otelcol.grafana.yaml
Normal file
36
deploy/container/otelcol.grafana.yaml
Normal 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]
|
||||
23
deploy/container/otelcol.yaml
Normal file
23
deploy/container/otelcol.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
21
deploy/env/api-server.env.example
vendored
21
deploy/env/api-server.env.example
vendored
@@ -5,6 +5,21 @@ GENARRATIVE_ENV=production
|
||||
GENARRATIVE_API_HOST=127.0.0.1
|
||||
GENARRATIVE_API_PORT=8082
|
||||
GENARRATIVE_API_LOG=info,tower_http=info
|
||||
GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512
|
||||
GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320
|
||||
GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64
|
||||
GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16
|
||||
GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
|
||||
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
||||
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
|
||||
GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
|
||||
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_SERVICE_NAME=genarrative-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,service.namespace=genarrative
|
||||
|
||||
GENARRATIVE_ADMIN_USERNAME=
|
||||
GENARRATIVE_ADMIN_PASSWORD=
|
||||
@@ -79,9 +94,9 @@ SMS_AUTH_ENABLED=false
|
||||
SMS_AUTH_PROVIDER=aliyun
|
||||
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||
ALIYUN_SMS_ENDPOINT=dypnsapi.aliyuncs.com
|
||||
ALIYUN_SMS_SIGN_NAME=
|
||||
ALIYUN_SMS_TEMPLATE_CODE=
|
||||
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
|
||||
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
|
||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||
ALIYUN_SMS_COUNTRY_CODE=86
|
||||
|
||||
|
||||
@@ -11,13 +11,18 @@
|
||||
## Brotli
|
||||
|
||||
- Brotli 只在目标服务器 Nginx 接受 brotli 指令时开启。
|
||||
- Ubuntu / apt 系统的 `Genarrative-Server-Provision` 会安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`;非 apt 系统暂不自动安装,仍按下面的能力探测结果决定是否启用。
|
||||
- Provision 脚本通过临时配置执行 `nginx -t` 做能力探测;探测配置会先 `include /etc/nginx/modules-enabled/*.conf`,避免 Ubuntu 动态模块已安装但测试配置未加载模块导致误判。可用时把模板中的 `# __GENARRATIVE_BROTLI_DIRECTIVES__` 替换为 brotli 指令,不可用时保留注释说明。
|
||||
- 不要直接在静态模板里无条件写 `brotli on;`,否则没有 brotli 模块的服务器会 `nginx -t` 失败并回滚。
|
||||
- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,可能只出现在 `nginx -T` 的 `load_module` 配置里。
|
||||
- 不要用 `nginx -V | grep brotli` 判断 brotli 是否可用;Ubuntu apt 安装的 brotli 是动态模块,不会出现在普通编译参数里。应检查包、`/etc/nginx/modules-enabled/` 的 `load_module` 配置,或用包含 `include /etc/nginx/modules-enabled/*.conf` 的临时配置执行 `nginx -t`。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
dpkg -l 'libnginx-mod-http-brotli-*'
|
||||
ls -l /etc/nginx/modules-enabled/*brotli*
|
||||
nginx -T 2>/dev/null | grep -Ei 'brotli|load_module'
|
||||
|
||||
curl -sSI -H 'Accept-Encoding: gzip' \
|
||||
http://<host>/api/runtime/puzzle/gallery \
|
||||
| grep -iE 'content-encoding|vary|content-type|content-length'
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
# 开发服无域名时使用的 HTTP 入口,只允许用于 DEPLOY_TARGET=development。
|
||||
# 没有域名时,将 SERVER_NAME 填为开发机 IP 或临时主机名。
|
||||
# 生产 release 仍必须使用 genarrative.conf 的 HTTPS 配置。
|
||||
log_format genarrative_upstream
|
||||
'$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
|
||||
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
|
||||
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
|
||||
'upstream_status=$upstream_status request_id=$request_id';
|
||||
|
||||
upstream genarrative_api {
|
||||
server 127.0.0.1:8082;
|
||||
keepalive 64;
|
||||
}
|
||||
|
||||
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name genarrative.example.com;
|
||||
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||
error_log /var/log/nginx/genarrative.error.log warn;
|
||||
limit_conn_status 429;
|
||||
limit_conn_log_level warn;
|
||||
limit_req_status 429;
|
||||
limit_req_log_level warn;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -29,13 +52,16 @@ server {
|
||||
|
||||
location ^~ /admin/api/ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_admin_rps burst=16 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8082/admin/api/;
|
||||
proxy_pass http://genarrative_api/admin/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -65,20 +91,119 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
location = /api/runtime/puzzle/gallery {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 320;
|
||||
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location = /api/runtime/custom-world-gallery {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 320;
|
||||
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location ~ ^/api/runtime/puzzle/gallery/[^/]+$ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 32;
|
||||
limit_req zone=genarrative_api_rps burst=32 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 32;
|
||||
limit_req zone=genarrative_api_rps burst=32 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
# 生产域名需要在部署前替换为真实域名,并由 certbot 或等价流程写入 HTTPS 证书配置。
|
||||
log_format genarrative_upstream
|
||||
'$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
|
||||
'request_time=$request_time upstream_connect_time=$upstream_connect_time '
|
||||
'upstream_header_time=$upstream_header_time upstream_response_time=$upstream_response_time '
|
||||
'upstream_status=$upstream_status request_id=$request_id';
|
||||
|
||||
upstream genarrative_api {
|
||||
server 127.0.0.1:8082;
|
||||
keepalive 64;
|
||||
}
|
||||
|
||||
limit_conn_zone $binary_remote_addr zone=genarrative_api_conn:10m;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_gallery_rps:10m rate=5000r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_api_rps:10m rate=300r/s;
|
||||
limit_req_zone $binary_remote_addr zone=genarrative_admin_rps:10m rate=30r/s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name genarrative.example.com;
|
||||
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||
error_log /var/log/nginx/genarrative.error.log warn;
|
||||
limit_conn_status 429;
|
||||
limit_conn_log_level warn;
|
||||
limit_req_status 429;
|
||||
limit_req_log_level warn;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/html;
|
||||
@@ -15,6 +38,12 @@ server {
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name genarrative.example.com;
|
||||
access_log /var/log/nginx/genarrative.access.log genarrative_upstream;
|
||||
error_log /var/log/nginx/genarrative.error.log warn;
|
||||
limit_conn_status 429;
|
||||
limit_conn_log_level warn;
|
||||
limit_req_status 429;
|
||||
limit_req_log_level warn;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -43,13 +72,16 @@ server {
|
||||
|
||||
location ^~ /admin/api/ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_admin_rps burst=16 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8082/admin/api/;
|
||||
proxy_pass http://genarrative_api/admin/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
@@ -79,20 +111,119 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
location = /api/runtime/puzzle/gallery {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 320;
|
||||
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location = /api/runtime/custom-world-gallery {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 320;
|
||||
limit_req zone=genarrative_gallery_rps burst=4096 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location ~ ^/api/runtime/puzzle/gallery/[^/]+$ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 32;
|
||||
limit_req zone=genarrative_api_rps burst=32 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location ~ ^/api/runtime/custom-world-gallery/[^/]+/[^/]+$ {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 32;
|
||||
limit_req zone=genarrative_api_rps burst=32 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
|
||||
location ~ ^/api(?:/|$) {
|
||||
default_type application/json;
|
||||
limit_conn genarrative_api_conn 64;
|
||||
limit_req zone=genarrative_api_rps burst=64 nodelay;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
|
||||
}
|
||||
|
||||
proxy_pass http://genarrative_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
add_header X-Accel-Buffering no always;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
23
deploy/otelcol/genarrative-debug.yaml
Normal file
23
deploy/otelcol/genarrative-debug.yaml
Normal 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]
|
||||
@@ -15,6 +15,8 @@ Restart=always
|
||||
RestartSec=5
|
||||
KillSignal=SIGINT
|
||||
TimeoutStopSec=30
|
||||
LimitNOFILE=65535
|
||||
TasksMax=2048
|
||||
|
||||
# api-server 只读发布目录,运行态写入必须显式落到环境变量指定的服务端私有目录。
|
||||
NoNewPrivileges=true
|
||||
|
||||
22
deploy/systemd/otelcol-contrib.service
Normal file
22
deploy/systemd/otelcol-contrib.service
Normal 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
|
||||
@@ -40,6 +40,12 @@ server-rs + Axum + SpacetimeDB
|
||||
npm run check:server-rs-ddd
|
||||
```
|
||||
|
||||
## `spacetime-client` mapper 组织
|
||||
|
||||
`server-rs/crates/spacetime-client/src/mapper.rs` 只作为聚合入口,负责声明 `src/mapper/` 下的领域子模块并 re-export 原有 record / mapper 能力;不要在该文件继续堆叠大段映射实现。
|
||||
|
||||
当前子模块按调用领域拆分:`assets.rs`、`auth.rs`、`runtime.rs`、`runtime_profile.rs`、`custom_world.rs`、`puzzle.rs`、`match3d.rs`、`square_hole.rs`、`visual_novel.rs`、`big_fish.rs`、`story.rs`、`ai.rs`、`bark_battle.rs`、`combat.rs`、`inventory.rs`、`npc.rs`,跨领域轻量 helper 和共享 record 统一放在 `common.rs`。该拆分只改变 `spacetime-client` 文件组织,不改变 SpacetimeDB schema、生成绑定、procedure result 契约或外部 DTO;后续新增 mapper 时优先落到对应领域子模块,不得重新引入跨层 JSON 字符串兼容结构。
|
||||
|
||||
## API 路由分组
|
||||
|
||||
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
||||
@@ -73,6 +79,33 @@ npm run check:server-rs-ddd
|
||||
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。
|
||||
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
|
||||
4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
|
||||
5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。
|
||||
|
||||
拼图 `api-server` 内部拆分:
|
||||
|
||||
- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。
|
||||
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
||||
- `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。
|
||||
- `server-rs/crates/api-server/src/puzzle/vector_engine.rs` 承接 VectorEngine 请求体、HTTP 调用、下载 / base64 解码、OSS 写入、asset object / binding 持久化和上游错误归一。
|
||||
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
|
||||
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
|
||||
|
||||
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
|
||||
|
||||
抓大鹅 Match3D `api-server` 内部拆分:
|
||||
|
||||
- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。
|
||||
- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。
|
||||
- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。
|
||||
- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。
|
||||
- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。
|
||||
- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品 sheet 生成、绿幕 / 近白底透明化、切图、append / replace / delete / sort / merge 和素材持久化。
|
||||
- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 承接 VectorEngine Gemini 请求体、响应解析、base64 图片下载和上游错误归一。
|
||||
- `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper;`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。
|
||||
|
||||
该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*`、`/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`。
|
||||
|
||||
生成资产 Adapter 规则:
|
||||
|
||||
@@ -84,14 +117,19 @@ npm run check:server-rs-ddd
|
||||
|
||||
## SpacetimeDB schema 变更规则
|
||||
|
||||
1. 任何 table、reducer、procedure、row shape 或 bindings 变化,都必须同步 `server-rs/crates/spacetime-module/src/migration.rs`、本文件表目录和生成绑定。
|
||||
1. 任何 table、view、reducer、procedure、row shape 或 bindings 变化,都必须同步本文件表 / view 目录和生成绑定;真实 table 变化还必须同步 `server-rs/crates/spacetime-module/src/migration.rs`,view 属于派生投影,不写入迁移导入导出表清单。
|
||||
2. 已有表新增字段必须放在 Rust 表结构体最后,并设置明确 `#[default(...)]`。
|
||||
3. 删除字段、改名、重排字段、改类型或修改字段属性前,必须先询问用户并确认迁移计划。
|
||||
4. Vec 字段不要直接写无法 const 求值的 default;需要默认空集合时优先使用 `Option<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
|
||||
npm run spacetime:generate
|
||||
npm run check:spacetime-runtime-access
|
||||
npm run check:spacetime-schema
|
||||
npm run check:server-rs-ddd
|
||||
```
|
||||
@@ -222,7 +260,7 @@ npm run check:server-rs-ddd
|
||||
### `battle_state`
|
||||
|
||||
- Rust 结构体:`BattleState`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `big_fish_agent_message`
|
||||
|
||||
@@ -238,6 +276,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`BigFishCreationSession`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
|
||||
- 索引:`by_big_fish_session_owner_user_id`、`by_big_fish_session_stage`。公开广场 view 使用 `by_big_fish_session_stage` 读取已发布会话,避免扫整表。
|
||||
|
||||
### `big_fish_event`
|
||||
|
||||
@@ -249,10 +288,17 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`BigFishRuntimeRun`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/big_fish/tables.rs`
|
||||
|
||||
### SpacetimeDB view:`big_fish_gallery_view`
|
||||
|
||||
- Rust view:`big_fish_gallery_view`
|
||||
- 返回类型:`Vec<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`
|
||||
|
||||
- Rust 结构体:`ChapterProgression`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `creation_entry_config`
|
||||
|
||||
@@ -267,37 +313,38 @@ npm run check:server-rs-ddd
|
||||
### `custom_world_agent_message`
|
||||
|
||||
- Rust 结构体:`CustomWorldAgentMessage`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
|
||||
### `custom_world_agent_operation`
|
||||
|
||||
- Rust 结构体:`CustomWorldAgentOperation`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
|
||||
### `custom_world_agent_session`
|
||||
|
||||
- Rust 结构体:`CustomWorldAgentSession`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
|
||||
### `custom_world_draft_card`
|
||||
|
||||
- Rust 结构体:`CustomWorldDraftCard`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
|
||||
### `custom_world_gallery_entry`
|
||||
|
||||
- Rust 结构体:`CustomWorldGalleryEntry`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
- 作用:自定义世界公开作品列表读模型。`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`,`/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。
|
||||
|
||||
### `custom_world_profile`
|
||||
|
||||
- Rust 结构体:`CustomWorldProfile`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
|
||||
### `custom_world_session`
|
||||
|
||||
- Rust 结构体:`CustomWorldSession`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs`
|
||||
|
||||
### `database_migration_import_chunk`
|
||||
|
||||
@@ -312,7 +359,7 @@ npm run check:server-rs-ddd
|
||||
### `inventory_slot`
|
||||
|
||||
- Rust 结构体:`InventorySlot`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `match3d_agent_message`
|
||||
|
||||
@@ -334,15 +381,22 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`Match3DWorkProfileRow`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/match3d/tables.rs`
|
||||
|
||||
### SpacetimeDB view:`match_3_d_gallery_view`
|
||||
|
||||
- Rust view:`match3d_gallery_view`
|
||||
- 返回类型:`Vec<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`
|
||||
|
||||
- Rust 结构体:`NpcState`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `player_progression`
|
||||
|
||||
- Rust 结构体:`PlayerProgression`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `profile_dashboard_state`
|
||||
|
||||
@@ -460,15 +514,64 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`PuzzleWorkProfileRow`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
|
||||
|
||||
### SpacetimeDB view:`puzzle_gallery_view`
|
||||
|
||||
- Rust view:`puzzle_gallery_view`
|
||||
- 返回类型:`Vec<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`
|
||||
|
||||
- Rust 结构体:`QuestLog`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `quest_record`
|
||||
|
||||
- Rust 结构体:`QuestRecord`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `refresh_session`
|
||||
|
||||
@@ -505,30 +608,39 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`SquareHoleWorkProfileRow`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/square_hole/tables.rs`
|
||||
|
||||
### SpacetimeDB view:`square_hole_gallery_view`
|
||||
|
||||
- Rust view:`square_hole_gallery_view`
|
||||
- 返回类型:`Vec<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`
|
||||
|
||||
- Rust 结构体:`StoryEvent`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `story_session`
|
||||
|
||||
- Rust 结构体:`StorySession`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `tracking_daily_stat`
|
||||
|
||||
- Rust 结构体:`TrackingDailyStat`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。
|
||||
|
||||
### `tracking_event`
|
||||
|
||||
- Rust 结构体:`TrackingEvent`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 写入:关键业务埋点同步调用单条 procedure;普通 HTTP route tracking 由 `api-server` 本机 outbox 批量调用 `record_tracking_events_and_return`。outbox 到达批量阈值时先封存 active 文件并切新 active,后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件,`MAX_BYTES` 只做磁盘保护阈值。`event_id` 必须稳定且全局唯一,批量重试时用唯一索引做幂等跳过。
|
||||
|
||||
### `treasure_record`
|
||||
|
||||
- Rust 结构体:`TreasureRecord`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs`
|
||||
|
||||
### `user_account`
|
||||
|
||||
@@ -569,3 +681,10 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`VisualNovelWorkProfileRow`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
|
||||
|
||||
### SpacetimeDB view:`visual_novel_gallery_view`
|
||||
|
||||
- Rust view:`visual_novel_gallery_view`
|
||||
- 返回类型:`Vec<VisualNovelGalleryViewRow>`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs`
|
||||
- 说明:视觉小说公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM visual_novel_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'` 后,从本地 cache 构造 `/api/runtime/visual-novel/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_visual_novel_works` procedure;个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。
|
||||
|
||||
@@ -79,6 +79,8 @@ npm run lint
|
||||
npm run check
|
||||
```
|
||||
|
||||
`npm run build` 由 `scripts/build-gate.mjs` 串行构建主站和后台;该门禁会把 Vite warning 当成失败处理。若看到 `Build gate failed because warnings were emitted`,先看 warning 原文,例如 chunk 体积超过 `vite.config.ts` / `apps/admin-web/vite.config.ts` 的 `chunkSizeWarningLimit`,不要先按 Rust 编译失败排查。
|
||||
|
||||
视觉小说负向扫描与验收门禁:
|
||||
|
||||
```bash
|
||||
@@ -147,8 +149,53 @@ Nginx 负责站点和反向代理
|
||||
Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||
```
|
||||
|
||||
Windows Stdb module 构建流水线运行在 Jenkins `windows` 节点上。该流水线需要执行 PowerShell 逻辑时,统一通过 `bat` 显式调用 `%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe`,不要直接使用 Jenkins `powershell` step;本地 Jenkins durable-task 曾在 `Genarrative-Stdb-Module-Build` workspace 中启动裸 `powershell` 时触发 `CreateProcess error=5, 拒绝访问`。临时 `.ps1` 由 Jenkins `writeFile` 写出后要先转成 UTF-8 with BOM 再交给 Windows PowerShell 5.1 `-File` 解析,避免中文错误消息在无 BOM UTF-8 下被当成本地 ANSI 误解码。Checkout 阶段要优先复用 Jenkins GitSCM 已经完成的结果:`COMMIT_HASH` 为空或与当前 `HEAD` 一致时,不要再额外 `git clean` / `git checkout`,只在确实需要切到别的指定 commit 时才补 fetch、校验和切换。排查时先看对应 build log、`@tmp/durable-*` 下的 `powershellWrapper.ps1`,以及日志中的 `[jenkins-powershell] user/exe`。
|
||||
|
||||
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
||||
|
||||
`Genarrative-Server-Provision` 会安装基础构建依赖、systemd 模板和 Nginx 站点模板。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。
|
||||
|
||||
50 HTTP req/s 首版压测优化口径:
|
||||
|
||||
- `api-server` 生产模板默认 `GENARRATIVE_API_LISTEN_BACKLOG=1024`、`GENARRATIVE_API_WORKER_THREADS=4`;本地未设置 worker threads 时继续使用 Tokio 默认值。
|
||||
- `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=512` 开启应用内 HTTP 并发背压;`GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=320`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=64`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=16` 分别限制公开列表、公开详情和后台 API 热路径。超过许可时直接返回 `429 Too Many Requests` 和 `Retry-After: 1`,`/healthz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。
|
||||
- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax` 和 `cat /proc/$(pidof api-server)/limits` 核对。
|
||||
- Server provision 不在目标机下载 SpacetimeDB 或 `otelcol-contrib`。Jenkins 的 `Prepare Provision Tools` 阶段在 `linux && genarrative-build` 构建机执行 `scripts/prepare-server-provision-tools.sh`:SpacetimeDB 仍通过官方安装入口 `https://install.spacetimedb.com` 准备;`otelcol-contrib` 默认要求在 Jenkins 参数 `OTELCOL_CONTRIB_ARCHIVE` 手动上传 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,再从上传包解出 `provision-tools/otelcol-contrib`。最终工具包通过 `stash/unstash` 上传到 release 部署 agent。目标机上的 `scripts/jenkins-server-provision.sh` 只从该工作区工具包安装 `/stdb/spacetime`、`/stdb/bin/current/*` 和 `/usr/local/bin/otelcol-contrib`。注意 `scripts/jenkins-checkout-source.sh` 会执行 `git reset --hard` / `git clean`,因此被直接执行的新增脚本必须以 Git `100755` 模式提交,或在二次 checkout 之后再补 `chmod +x`。
|
||||
- `otelcol-contrib.service` 作为可选系统服务加入 provision,默认监听 `127.0.0.1:4317/4318` 并使用 `deploy/otelcol/genarrative-debug.yaml`。api-server 是否发送 OTLP 仍由 `GENARRATIVE_OTEL_ENABLED` 控制,服务 unit 见 `deploy/systemd/otelcol-contrib.service`。
|
||||
- Nginx `/api/` 与 `/admin/api/` 通过 `genarrative_api` upstream 代理到 `127.0.0.1:8082`,upstream keepalive 为 64;`limit_conn` 负责连接 / 并发保护,`limit_req` 负责入口 RPS 快拒绝。当前模板把公开 gallery list 单独放到 `genarrative_gallery_rps`,默认 `rate=5000r/s`、`burst=4096`、`limit_conn=320`;公开详情和普通 API 放到 `genarrative_api_rps`,后台 API 放到 `genarrative_admin_rps`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。
|
||||
- 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。
|
||||
- 作品列表短期继续由 `api-server` / BFF 订阅 SpacetimeDB 公开 read model 后读本地 cache,不让浏览器前端直接订阅完整列表;未来如新增 `public_work_gallery_entry` 等专用公开作品列表 read model,前端只可订阅稳定、低基数、公开的专用投影,禁止订阅 `puzzle_work_profile`、`custom_world_profile` 等玩法源表后自行 join、聚合或判断权限。前端直订阅落地前必须先补齐权限、字段契约、排序 / 分页、埋点和 BFF 回退策略。
|
||||
- 50 HTTP req/s 验收目标为 `http_req_failed < 1%`、`p95 < 2s`、`dropped_iterations = 0`,同时压测窗口内 Nginx 无新增 502。2026-05-19 容器 2C / 2G 连续 10 轮不重启 SpacetimeDB 压测:`PEAK_RPS=2500` 等价约 5000 HTTP req/s,平均实际吞吐约 `4219 HTTP req/s`,10 轮总计 `1,897,357` 个 200、`212,542` 个 429、`0` 个 5xx,200 请求平均 `p95=123ms`、`p99=234ms`;该档会把 SpacetimeDB 容器内存从约 `366MiB` 推到约 `885MiB / 896MiB`,因此当前不要继续抬公开 gallery 入口并发,应优先处理 SpacetimeDB 侧连接 / 订阅 / tracking 写入后的内存高水位。
|
||||
|
||||
容器化压测与隔离部署方案单独放在 `deploy/container/`,用于本机或预发模拟 Linux release + Nginx + OTLP Collector 拓扑,不替换当前生产 `systemd + Nginx + Jenkins` 发布路径。当前容器模拟参数按 `genarrative-release` 采样值收口为 2 vCPU / 2 GiB RAM / `nofile=4096` / `worker_connections=768`,并在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=768m`、`api-server cpus=2.0 mem_limit=1g`、`nginx cpus=0.25 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=0.5 mem_limit=512m`。容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,只增加 Tokio worker 调度并发,不突破 `api-server cpus=2.0` 的 CPU 配额:
|
||||
|
||||
```bash
|
||||
npm run container:init
|
||||
npm run container:config
|
||||
npm run container:build
|
||||
npm run container:up
|
||||
npm run container:k6
|
||||
npm run container:down
|
||||
```
|
||||
|
||||
容器方案默认暴露 `http://127.0.0.1:18080`,`api-server` 在容器内监听 `0.0.0.0:8082`,Nginx 通过 `api-server:8082` upstream 反代 `/api/` 和 `/admin/api/`。SpacetimeDB 也纳入 compose,容器内由 `spacetimedb:3101` 提供服务,宿主机通过 `http://127.0.0.1:13101` 进行模块发布;Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。生产 provision 侧则通过 Jenkins 构建机准备的 `provision-tools/otelcol-contrib` 安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。
|
||||
`npm run container:config` 默认只做 quiet 校验,避免把本地 env 中的 token 展开到终端;确需排查完整 compose 时再传 `-- --print`。
|
||||
|
||||
OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日志与 Nginx 文件日志仍保留:
|
||||
|
||||
- 生产与容器 `api-server` env 模板默认 `GENARRATIVE_OTEL_ENABLED=true`;压测、排障或短期要关闭 OTLP 时,必须显式设置 `GENARRATIVE_OTEL_ENABLED=false`。
|
||||
- Collector 使用官方 `otelcol-contrib`,安装与启用仍由 `ENABLE_OTELCOL` / provision 控制,只监听 `127.0.0.1:4317/4318`;本地用 `npm run otel:debug` 启动 debug exporter,用 `npm run otel:rider` 转发到 Rider,再接 Jaeger、Tempo、Prometheus、Grafana 或托管平台。
|
||||
- api-server 发送 OTLP HTTP 时,生产模板使用 `OTEL_SERVICE_NAME=genarrative-api`、`OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318`,容器模板使用 `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318`。
|
||||
- `OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint;不要填 gRPC `4317`,也不要直接填 Rider 端口,Rider 由 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。
|
||||
- 应用日志仍通过 `journalctl -u genarrative-api.service` 查看,Nginx 日志仍写文件;日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。
|
||||
- debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。
|
||||
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
|
||||
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
||||
- 指标 label 只允许低基数字段:HTTP 使用 `method`、`route`、`status_class`,SpacetimeDB 调用使用 `procedure`、`status_class`;`request_id` 只进入 trace/log attribute,不进入 metric label。
|
||||
|
||||
常见外部服务变量:
|
||||
|
||||
- `GENARRATIVE_SPACETIME_SERVER_URL`
|
||||
@@ -164,9 +211,33 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||
- `WECHAT_*`
|
||||
- `ALIYUN_OSS_*`
|
||||
|
||||
### 手机验证码短信
|
||||
|
||||
手机验证码发送走阿里云普通短信 `SendSms`,验证码由 `module-auth` 在当前 `api-server` 进程内生成、哈希存储和校验,不再调用阿里云托管验证码的 `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` 重启后,已发送但未校验的验证码会失效。
|
||||
|
||||
生产默认短信配置:
|
||||
|
||||
```env
|
||||
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||
ALIYUN_SMS_SIGN_NAME=北京亓盒网络科技
|
||||
ALIYUN_SMS_TEMPLATE_CODE=SMS_506245486
|
||||
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||
```
|
||||
|
||||
阿里云模板参数固定发送为 `{"code":"<验证码>"}`。旧托管验证码相关变量如 `ALIYUN_SMS_CODE_LENGTH`、`ALIYUN_SMS_CODE_TYPE`、`ALIYUN_SMS_RETURN_VERIFY_CODE`、`ALIYUN_SMS_CASE_AUTH_POLICY`、`ALIYUN_SMS_SCHEME_NAME` 不再影响真实阿里云校验;验证码长度、有效期、冷却和失败次数由后端本地逻辑控制。真实短信联调仍需 `SMS_AUTH_PROVIDER=aliyun`、`SMS_AUTH_ENABLED=true` 和有效 `ALIYUN_SMS_ACCESS_KEY_*`。修改 `.env.local` 后必须重启 `api-server`,再用 `/api/auth/login-options` 确认返回包含 `phone`;如果通过 shell 临时覆盖,PowerShell 使用 `$env:SMS_AUTH_ENABLED="true"`,cmd 使用 `set SMS_AUTH_ENABLED=true`,不要把引号作为环境变量值的一部分传给进程。
|
||||
|
||||
如需在本地确认平台层确实调用阿里云 `SendSms`,可手动运行默认忽略的真实短信测试。该测试会向 `ALIYUN_SMS_REAL_TEST_PHONE_NUMBER` 发送验证码短信,普通 `cargo test` 不会执行:
|
||||
|
||||
```powershell
|
||||
$env:ALIYUN_SMS_ACCESS_KEY_ID="..."
|
||||
$env:ALIYUN_SMS_ACCESS_KEY_SECRET="..."
|
||||
$env:ALIYUN_SMS_REAL_TEST_PHONE_NUMBER="13800138000"
|
||||
cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms_real_provider_sends_verify_code -- --ignored --nocapture
|
||||
```
|
||||
|
||||
## 埋点与运营查询
|
||||
|
||||
用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。任务配置、进度、领奖、钱包流水分别写入:
|
||||
用户行为埋点原始事实写入 `tracking_event`,聚合投影写入 `tracking_daily_stat`。高频 HTTP route tracking 不直接阻塞请求链路:`api-server` 将普通 route tracking 先写入本机 tracking outbox,再由后台 worker 按数量或时间阈值批量写入 SpacetimeDB;`daily_login`、作品游玩 `work_play_start`、付费、任务领奖和钱包相关关键事件继续同步直写数据库,避免用户任务进度、游玩统计或支付状态出现可感知延迟。任务配置、进度、领奖、钱包流水分别写入:
|
||||
|
||||
- `profile_task_config`
|
||||
- `profile_task_progress`
|
||||
@@ -175,6 +246,18 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||
|
||||
个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
|
||||
|
||||
tracking outbox 默认配置:
|
||||
|
||||
```env
|
||||
GENARRATIVE_TRACKING_OUTBOX_ENABLED=true
|
||||
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
||||
GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500
|
||||
GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS=1000
|
||||
GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES=268435456
|
||||
```
|
||||
|
||||
outbox 采用 NDJSON 文件保存原始事件。达到 `BATCH_SIZE` 时会立刻把当前 active 文件原子封存为 sealed 文件,并马上切到新的 active 继续写入;后台 worker 异步 flush sealed 文件,HTTP 请求线程不等待 SpacetimeDB。`FLUSH_INTERVAL_MS` 只负责兜底封存长时间未满批的 active 文件。SpacetimeDB 批量 procedure 返回成功后删除 sealed 文件,失败则保留文件并重试。`MAX_BYTES` 是磁盘保护阈值,不是 flush 阈值;超过后低价值 route tracking 可以被丢弃并记录日志 / 指标,关键同步事件不进入该丢弃路径。sealed 文件若出现无法解析的坏行,会重命名为 `corrupt-*` 隔离并记录 `genarrative.tracking_outbox.files.corrupt` 指标,避免一个坏文件阻塞后续批量入库。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。
|
||||
|
||||
常用检查思路:
|
||||
|
||||
```sql
|
||||
|
||||
@@ -8,13 +8,17 @@
|
||||
|
||||
当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。
|
||||
|
||||
`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。
|
||||
|
||||
## 草稿与作品架
|
||||
|
||||
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
||||
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
|
||||
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
|
||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||
5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用作品摘要 `updatedAt` 推导。
|
||||
7. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
|
||||
## 拼图
|
||||
|
||||
@@ -27,9 +31,17 @@
|
||||
当前口径:
|
||||
|
||||
- 图像输入复用 `CreativeImageInputPanel`。
|
||||
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
|
||||
- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。
|
||||
- 支持画面描述生图、多参考图生图、上传或历史生成主图后 AI 重绘、上传或历史生成主图后不重绘;关闭 AI 重绘时,前端可提交本地上传 Data URL 或历史 `/generated-*` 图片路径,后端统一解析为首关正式图后再持久化。
|
||||
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;首关关卡图和 UI 背景在命名稳定后并行启动,当前不自动生成背景音乐。
|
||||
- 作品架拼图草稿的“生成中”遮罩只表示初始草稿还没有可查看结果;只要作品摘要、首关封面或任一关卡候选图已经可用,后续 UI 背景重生成和追加关卡生图都必须作为结果页局部生成态处理,不能阻止打开草稿结果页。
|
||||
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试,生成页预计完成时间按 `5` 分钟展示;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
|
||||
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
|
||||
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。
|
||||
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
|
||||
- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。
|
||||
- 结果页 UI 背景重生成只禁用 UI 背景自己的按钮和确认动作,不禁用“新增关卡”、关卡图片生成、关卡详情编辑和结果页导航;关卡图片生成也只标记对应关卡的局部生成进度。
|
||||
- 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。
|
||||
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
|
||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
@@ -51,7 +63,7 @@
|
||||
难度映射:
|
||||
|
||||
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| ---- | ---------: | ---------: | -------: | -------: |
|
||||
| 轻松 | 8 | 2 | 24 | 3 |
|
||||
| 标准 | 12 | 4 | 36 | 9 |
|
||||
| 进阶 | 16 | 6 | 48 | 15 |
|
||||
@@ -60,15 +72,15 @@
|
||||
当前素材生成流水线:
|
||||
|
||||
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
|
||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。
|
||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。
|
||||
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
|
||||
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
|
||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
||||
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
|
||||
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
|
||||
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
|
||||
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
|
||||
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材。
|
||||
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。
|
||||
|
||||
结果页当前结构:
|
||||
|
||||
@@ -81,15 +93,16 @@
|
||||
运行态当前口径:
|
||||
|
||||
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
|
||||
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。
|
||||
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击。
|
||||
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
|
||||
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
|
||||
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
|
||||
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
||||
- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
||||
- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]` 与 `top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||
- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。
|
||||
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
||||
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
||||
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
|
||||
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
|
||||
|
||||
@@ -172,3 +185,4 @@
|
||||
3. 生成失败时,后端应返回可操作 `details.reason` / `details.missingEnv`,前端优先展示具体原因。
|
||||
4. 半配置 OSS 不应阻断 `api-server` 启动;具体生成或换签接口在需要时返回配置缺失。
|
||||
5. 历史 generated path 可以兼容读取,但新链路不要把裸 path 当公开静态资源。
|
||||
6. 发现页 / 推荐流公开作品卡封面必须兼容旧移动浏览器内核:封面容器不能只依赖 CSS `aspect-ratio` 撑高,必须保留 16:9 或对应沉浸卡比例的可见高度兜底;generated 私有封面换签失败时要回落到玩法类型参考图,避免卡片整体黑底。
|
||||
|
||||
@@ -39,6 +39,12 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
||||
|
||||
内部状态值可继续复用历史 `home/category/create/saves/profile`,但用户可见文案按上面的新口径展示。
|
||||
|
||||
## 账户与登录
|
||||
|
||||
1. 主站登录弹窗必须稳定展示 `短信登录` 与 `密码登录` 两个核心入口;`GET /api/auth/login-options` 只能补充微信等环境相关入口,不能决定是否隐藏短信或密码登录。
|
||||
2. `login-options` 为空、失败、只返回 `phone` 或只返回 `password` 时,前端仍要同时展示验证码登录页签和密码登录页签;短信能力真实可用性由发送验证码接口返回结果表达。
|
||||
3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。
|
||||
|
||||
## 账户与充值
|
||||
|
||||
1. “我的”页账户充值弹窗包含 `泥点充值` 与 `会员卡充值` 两个页签,入口必须打开独立弹窗,不在当前面板下方展开。
|
||||
|
||||
@@ -22,7 +22,8 @@ pipeline {
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
|
||||
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名')
|
||||
string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world')
|
||||
string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径')
|
||||
string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '构建机准备并上传到目标机工作区的工具包目录')
|
||||
string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '构建机下载 SpacetimeDB 官方安装产物的根地址;目标机不访问该地址')
|
||||
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
|
||||
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
|
||||
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
|
||||
@@ -31,6 +32,9 @@ pipeline {
|
||||
string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口')
|
||||
choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-http,release 正式入口选 production-https')
|
||||
booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务')
|
||||
booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collector;api-server 模板默认开启 OTLP,如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false')
|
||||
string(name: 'OTELCOL_VERSION', defaultValue: '0.151.0', description: 'otelcol-contrib 版本')
|
||||
stashedFile 'OTELCOL_CONTRIB_ARCHIVE'
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -60,8 +64,30 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!params.SPACETIME_BIN_SOURCE?.trim()) {
|
||||
error('SPACETIME_BIN_SOURCE 不能为空。')
|
||||
if (!params.PROVISION_TOOLS_DIR?.trim()) {
|
||||
error('PROVISION_TOOLS_DIR 不能为空。')
|
||||
}
|
||||
if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..')) {
|
||||
error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}")
|
||||
}
|
||||
if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) {
|
||||
error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}")
|
||||
}
|
||||
def otelcolArchiveFilename = env.OTELCOL_CONTRIB_ARCHIVE_FILENAME?.trim()
|
||||
def expectedOtelcolArchiveFilename = "otelcol-contrib_${params.OTELCOL_VERSION.trim()}_linux_amd64.tar.gz"
|
||||
if (params.ENABLE_OTELCOL) {
|
||||
if (!otelcolArchiveFilename) {
|
||||
error("ENABLE_OTELCOL=true 时必须在 OTELCOL_CONTRIB_ARCHIVE 上传 ${expectedOtelcolArchiveFilename}。")
|
||||
}
|
||||
if (otelcolArchiveFilename != expectedOtelcolArchiveFilename) {
|
||||
error("OTELCOL_CONTRIB_ARCHIVE 文件名必须是 ${expectedOtelcolArchiveFilename},当前上传: ${otelcolArchiveFilename}")
|
||||
}
|
||||
}
|
||||
if (!params.ENABLE_OTELCOL && otelcolArchiveFilename) {
|
||||
echo "ENABLE_OTELCOL=false,已上传的 OTELCOL_CONTRIB_ARCHIVE 将不会被安装。"
|
||||
}
|
||||
if (!params.SPACETIME_DOWNLOAD_ROOT?.trim()) {
|
||||
error('SPACETIME_DOWNLOAD_ROOT 不能为空。')
|
||||
}
|
||||
def nginxMode = params.NGINX_CONFIG_MODE?.trim()
|
||||
if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
|
||||
@@ -77,6 +103,80 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('Prepare Provision Tools') {
|
||||
agent {
|
||||
label 'linux && genarrative-build'
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
def checkoutFromRemote = { String remoteUrl ->
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
|
||||
doGenerateSubmoduleConfigurations: false,
|
||||
extensions: [
|
||||
[$class: 'CleanBeforeCheckout'],
|
||||
[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true],
|
||||
],
|
||||
userRemoteConfigs: [[url: remoteUrl, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
||||
])
|
||||
}
|
||||
try {
|
||||
checkoutFromRemote(env.GIT_REMOTE_URL)
|
||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL
|
||||
} catch (error) {
|
||||
echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}"
|
||||
checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL)
|
||||
env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL
|
||||
}
|
||||
}
|
||||
sh '''
|
||||
bash <<'BASH'
|
||||
set -euo pipefail
|
||||
chmod +x scripts/jenkins-checkout-source.sh
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||
scripts/jenkins-checkout-source.sh
|
||||
# jenkins-checkout-source.sh 会 reset/clean 到目标 commit,前面的临时 chmod 可能被 Git mode 还原;
|
||||
# 直接执行脚本前在二次 checkout 之后再补执行位,避免 Linux agent 报 Permission denied。
|
||||
chmod +x scripts/prepare-server-provision-tools.sh
|
||||
BASH
|
||||
'''
|
||||
script {
|
||||
if (params.ENABLE_OTELCOL) {
|
||||
echo "准备使用手动上传的 otelcol-contrib 包: ${env.OTELCOL_CONTRIB_ARCHIVE_FILENAME}"
|
||||
sh 'bash -lc "rm -rf manual-provision-tool-upload && mkdir -p manual-provision-tool-upload"'
|
||||
dir('manual-provision-tool-upload') {
|
||||
unstash 'OTELCOL_CONTRIB_ARCHIVE'
|
||||
}
|
||||
env.OTELCOL_ARCHIVE_SOURCE = 'manual-provision-tool-upload/OTELCOL_CONTRIB_ARCHIVE'
|
||||
} else {
|
||||
env.OTELCOL_ARCHIVE_SOURCE = ''
|
||||
}
|
||||
}
|
||||
sh '''
|
||||
bash <<'BASH'
|
||||
set -euo pipefail
|
||||
|
||||
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
|
||||
OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \
|
||||
PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \
|
||||
OTELCOL_ARCHIVE_SOURCE="${OTELCOL_ARCHIVE_SOURCE:-}" \
|
||||
SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \
|
||||
scripts/prepare-server-provision-tools.sh
|
||||
BASH
|
||||
'''
|
||||
script {
|
||||
env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim()
|
||||
echo "Provision 工具包已准备,源码 commit=${env.SOURCE_COMMIT}"
|
||||
}
|
||||
stash name: 'server-provision-tools', includes: "${params.PROVISION_TOOLS_DIR}/**", useDefaultExcludes: false
|
||||
}
|
||||
}
|
||||
|
||||
stage('Checkout Provision Files') {
|
||||
agent {
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||
@@ -109,7 +209,7 @@ pipeline {
|
||||
set -euo pipefail
|
||||
chmod +x scripts/jenkins-checkout-source.sh
|
||||
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
|
||||
COMMIT_HASH="${COMMIT_HASH:-}" \
|
||||
COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \
|
||||
GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \
|
||||
GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \
|
||||
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
|
||||
@@ -124,10 +224,20 @@ BASH
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
|
||||
}
|
||||
steps {
|
||||
unstash 'server-provision-tools'
|
||||
sh '''
|
||||
bash <<'BASH'
|
||||
set -euo pipefail
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib"
|
||||
fi
|
||||
chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
|
||||
"${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \
|
||||
"${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone"
|
||||
chmod +x scripts/jenkins-server-provision.sh
|
||||
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \
|
||||
SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \
|
||||
OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \
|
||||
scripts/jenkins-server-provision.sh
|
||||
BASH
|
||||
'''
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
def runWindowsPowerShell(String scriptName, String scriptBody) {
|
||||
def scriptPath = ".jenkins-${scriptName}.ps1"
|
||||
writeFile file: scriptPath, text: scriptBody, encoding: 'UTF-8'
|
||||
bat label: "PowerShell ${scriptName}", script: """
|
||||
@echo off
|
||||
setlocal
|
||||
set "GENARRATIVE_POWERSHELL=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
|
||||
if not exist "%GENARRATIVE_POWERSHELL%" (
|
||||
echo [jenkins-powershell] powershell.exe not found: %GENARRATIVE_POWERSHELL%
|
||||
exit /b 1
|
||||
)
|
||||
echo [jenkins-powershell] user:
|
||||
whoami
|
||||
echo [jenkins-powershell] exe: %GENARRATIVE_POWERSHELL%
|
||||
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "\$path = '%CD%\\${scriptPath}'; \$text = [System.IO.File]::ReadAllText(\$path, [System.Text.Encoding]::UTF8); \$utf8Bom = New-Object System.Text.UTF8Encoding(\$true); [System.IO.File]::WriteAllText(\$path, \$text, \$utf8Bom)"
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
"%GENARRATIVE_POWERSHELL%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%CD%\\${scriptPath}"
|
||||
exit /b %ERRORLEVEL%
|
||||
"""
|
||||
}
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label 'windows'
|
||||
@@ -45,23 +66,95 @@ pipeline {
|
||||
],
|
||||
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}", refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]],
|
||||
])
|
||||
powershell '''
|
||||
script {
|
||||
runWindowsPowerShell('stdb-checkout', '''
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sourceBranch = if ($env:SOURCE_BRANCH) { $env:SOURCE_BRANCH } else { 'master' }
|
||||
$commitHash = if ($env:COMMIT_HASH) { $env:COMMIT_HASH } else { '' }
|
||||
$gitRemoteUrl = if ($env:GIT_REMOTE_URL) { $env:GIT_REMOTE_URL } else { 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' }
|
||||
git fetch --no-tags --prune --depth=1 $gitRemoteUrl "+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}"
|
||||
if ($commitHash) {
|
||||
git checkout --force $commitHash
|
||||
} else {
|
||||
git checkout --force "origin/$sourceBranch"
|
||||
|
||||
function Invoke-GitCommand {
|
||||
param(
|
||||
[string]$Label,
|
||||
[string[]]$Arguments
|
||||
)
|
||||
|
||||
Write-Host "[stdb-checkout] $Label"
|
||||
& git @Arguments
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
throw "[stdb-checkout] $Label failed with exit code $exitCode"
|
||||
}
|
||||
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()
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
[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.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
|
||||
}
|
||||
@@ -72,7 +165,7 @@ pipeline {
|
||||
steps {
|
||||
script {
|
||||
def buildStep = {
|
||||
powershell '''
|
||||
runWindowsPowerShell('stdb-build', '''
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$workspaceTmp = if ($env:WORKSPACE_TMP) { $env:WORKSPACE_TMP } else { "$env:WORKSPACE@tmp" }
|
||||
$env:CARGO_HOME = "$workspaceTmp/cargo-home"
|
||||
@@ -110,6 +203,7 @@ pipeline {
|
||||
}
|
||||
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"
|
||||
'''
|
||||
)
|
||||
}
|
||||
if (params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) {
|
||||
withCredentials([
|
||||
|
||||
78
package-lock.json
generated
78
package-lock.json
generated
@@ -72,6 +72,7 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1515,7 +1516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -1528,7 +1528,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -1542,8 +1541,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "16.3.2",
|
||||
@@ -1606,8 +1604,7 @@
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -1650,7 +1647,8 @@
|
||||
"version": "4.3.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/chai-subset": {
|
||||
"version": "1.3.6",
|
||||
@@ -1696,6 +1694,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1705,6 +1704,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1796,6 +1796,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
@@ -2126,6 +2127,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2216,7 +2218,6 @@
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -2338,6 +2339,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2629,7 +2631,6 @@
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -2685,8 +2686,7 @@
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/domexception": {
|
||||
"version": "4.0.0",
|
||||
@@ -2873,6 +2873,7 @@
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -3697,6 +3698,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"abab": "^2.0.6",
|
||||
"cssstyle": "^3.0.0",
|
||||
@@ -4096,7 +4098,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -4435,6 +4436,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4486,6 +4488,7 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -4619,6 +4622,7 @@
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4627,6 +4631,7 @@
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -5074,6 +5079,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -5126,6 +5132,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -5207,6 +5214,7 @@
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -7027,6 +7035,7 @@
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -7835,15 +7844,13 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -7854,8 +7861,7 @@
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -7891,8 +7897,7 @@
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -7935,7 +7940,8 @@
|
||||
"version": "4.3.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz",
|
||||
"integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"@types/chai-subset": {
|
||||
"version": "1.3.6",
|
||||
@@ -7978,6 +7984,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -7987,6 +7994,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@types/semver": {
|
||||
@@ -8053,6 +8061,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
|
||||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
@@ -8263,7 +8272,8 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"acorn-jsx": {
|
||||
"version": "5.3.2",
|
||||
@@ -8326,7 +8336,6 @@
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -8396,6 +8405,7 @@
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -8605,8 +8615,7 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"detect-libc": {
|
||||
"version": "2.1.2",
|
||||
@@ -8646,8 +8655,7 @@
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"domexception": {
|
||||
"version": "4.0.0",
|
||||
@@ -8782,6 +8790,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
|
||||
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -9360,6 +9369,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz",
|
||||
"integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"abab": "^2.0.6",
|
||||
"cssstyle": "^3.0.0",
|
||||
@@ -9566,8 +9576,7 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"peer": true
|
||||
"dev": true
|
||||
},
|
||||
"magic-string": {
|
||||
"version": "0.30.21",
|
||||
@@ -9813,7 +9822,8 @@
|
||||
"picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"peer": true
|
||||
},
|
||||
"pkg-types": {
|
||||
"version": "1.3.1",
|
||||
@@ -9843,6 +9853,7 @@
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -9926,12 +9937,14 @@
|
||||
"react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"peer": true
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"scheduler": "^0.27.0"
|
||||
}
|
||||
@@ -10256,6 +10269,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"esbuild": "~0.27.0",
|
||||
"fsevents": "~2.3.3",
|
||||
@@ -10287,7 +10301,8 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"ufo": {
|
||||
"version": "1.6.3",
|
||||
@@ -10339,6 +10354,7 @@
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
||||
13
package.json
13
package.json
@@ -10,11 +10,14 @@
|
||||
"dev:web": "node scripts/dev.mjs web",
|
||||
"dev:admin-web": "node scripts/dev.mjs admin-web",
|
||||
"dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
|
||||
"otel:debug": "node scripts/run-otelcol.mjs debug",
|
||||
"otel:rider": "node scripts/run-otelcol.mjs rider",
|
||||
"admin-web:build": "node scripts/admin-web-build.mjs build",
|
||||
"admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck",
|
||||
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
|
||||
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
|
||||
"check:api-server-env": "node scripts/check-api-server-env.mjs",
|
||||
"check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs",
|
||||
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
|
||||
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||
@@ -29,7 +32,7 @@
|
||||
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
|
||||
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
|
||||
"check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs",
|
||||
"check:server-rs-ddd": "npm run check:spacetime-schema && node scripts/check-server-rs-ddd-boundaries.mjs",
|
||||
"check:server-rs-ddd": "npm run check:spacetime-schema && npm run check:spacetime-runtime-access && node scripts/check-server-rs-ddd-boundaries.mjs",
|
||||
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
|
||||
"lint:guardrails": "npm run lint:eslint",
|
||||
"typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit",
|
||||
@@ -42,6 +45,14 @@
|
||||
"test:watch": "vitest",
|
||||
"loadtest:extract-works": "node scripts/loadtest/extract-works-list-data.mjs",
|
||||
"loadtest:k6:works": "k6 run scripts/loadtest/k6-works-list.js",
|
||||
"container:init": "node scripts/container-compose.mjs init",
|
||||
"container:build": "node scripts/container-compose.mjs build",
|
||||
"container:up": "node scripts/container-compose.mjs up",
|
||||
"container:down": "node scripts/container-compose.mjs down",
|
||||
"container:logs": "node scripts/container-compose.mjs logs",
|
||||
"container:ps": "node scripts/container-compose.mjs ps",
|
||||
"container:config": "node scripts/container-compose.mjs config",
|
||||
"container:k6": "node scripts/container-compose.mjs k6",
|
||||
"check": "npm run lint && npm run test && npm run build && npm run check:content",
|
||||
"check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts",
|
||||
"check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { CreationAudioAsset } from './creationAudio';
|
||||
|
||||
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
|
||||
export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string;
|
||||
|
||||
export type Match3DGeneratedItemAssetStatus =
|
||||
| 'pending'
|
||||
@@ -163,6 +164,7 @@ export interface Match3DWorkSummary {
|
||||
updatedAt: string;
|
||||
publishedAt?: string | null;
|
||||
publishReady: boolean;
|
||||
generationStatus?: Match3DWorkGenerationStatus | null;
|
||||
backgroundPrompt?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundImageObjectKey?: string | null;
|
||||
|
||||
@@ -76,6 +76,7 @@ export type PuzzleAgentActionRequest =
|
||||
imageModel?: string | null;
|
||||
aiRedraw?: boolean;
|
||||
candidateCount?: number;
|
||||
shouldAutoNameLevel?: boolean;
|
||||
workTitle?: string;
|
||||
workDescription?: string;
|
||||
summary?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JsonObject } from './common';
|
||||
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
|
||||
|
||||
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
|
||||
export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus'];
|
||||
|
||||
export interface PuzzleWorkSummary {
|
||||
workId: string;
|
||||
@@ -28,6 +29,7 @@ export interface PuzzleWorkSummary {
|
||||
pointIncentiveTotalPoints?: number;
|
||||
pointIncentiveClaimablePoints?: number;
|
||||
publishReady: boolean;
|
||||
generationStatus?: PuzzleWorkGenerationStatus | null;
|
||||
levels?: PuzzleDraftLevel[];
|
||||
}
|
||||
|
||||
@@ -40,6 +42,19 @@ export interface PuzzleWorksResponse {
|
||||
items: PuzzleWorkSummary[];
|
||||
}
|
||||
|
||||
export interface PuzzleGalleryWorkRef {
|
||||
workId: string;
|
||||
profileId: string;
|
||||
}
|
||||
|
||||
export interface PuzzleGalleryResponse {
|
||||
items: PuzzleWorkSummary[];
|
||||
previewRefs?: PuzzleGalleryWorkRef[];
|
||||
hasMore?: boolean;
|
||||
nextCursor?: string | null;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
export interface PuzzleWorkDetailResponse {
|
||||
item: PuzzleWorkProfile;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ function printStatus(key, present) {
|
||||
const env = mergeApiServerEnv(process.cwd(), process.env);
|
||||
const missing = [];
|
||||
|
||||
console.log('[api-server-env] 认证短信配置检查');
|
||||
printStatus('SMS_AUTH_ENABLED', env.SMS_AUTH_ENABLED === 'true');
|
||||
printStatus('SMS_AUTH_PROVIDER', hasValue(env.SMS_AUTH_PROVIDER));
|
||||
|
||||
console.log('[api-server-env] 拼图真实生成配置检查');
|
||||
for (const key of REQUIRED_FOR_PUZZLE_GENERATION) {
|
||||
const present = hasValue(env[key]);
|
||||
|
||||
221
scripts/check-spacetime-runtime-access.mjs
Normal file
221
scripts/check-spacetime-runtime-access.mjs
Normal file
@@ -0,0 +1,221 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function readUtf8(relativePath) {
|
||||
const absolute = path.join(repoRoot, relativePath);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
failures.push(`${relativePath}: 文件不存在,无法执行 SpacetimeDB runtime access 检查`);
|
||||
return null;
|
||||
}
|
||||
return fs.readFileSync(absolute, 'utf8');
|
||||
}
|
||||
|
||||
const forbiddenSnippets = [
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
|
||||
snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.owner_user_id == input.owner_user_id)',
|
||||
reason: 'puzzle_work_profile 已有 by_puzzle_work_owner_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
|
||||
snippet: '.puzzle_work_profile()\n .iter()\n .filter(|row| row.publication_status == PuzzlePublicationStatus::Published)',
|
||||
reason: 'puzzle_work_profile 已有 by_puzzle_work_publication_status 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/puzzle.rs',
|
||||
snippet: '.puzzle_leaderboard_entry()\n .iter()\n .filter(|row| row.profile_id == profile_id && row.grid_size == grid_size)',
|
||||
reason: 'puzzle_leaderboard_entry 已有 by_puzzle_leaderboard_profile_grid 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/match3d.rs',
|
||||
snippet: '.match3d_work_profile()\n .iter()\n .filter(|row| {',
|
||||
reason: 'match3d_work_profile 已有 owner/status 索引,列表不应整表过滤',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/visual_novel.rs',
|
||||
snippet: '.visual_novel_work_profile()\n .iter()\n .filter(|row| {',
|
||||
reason: 'visual_novel_work_profile 已有 owner/status 索引,列表不应整表过滤',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs',
|
||||
snippet: '.asset_object()\n .iter()\n .find(|row| row.bucket == input.bucket && row.object_key == input.object_key)',
|
||||
reason: 'asset_object 已有 by_bucket_object_key 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/asset_metadata/objects.rs',
|
||||
snippet: '.asset_object()\n .iter()\n .filter(|row| row.asset_kind == asset_kind)',
|
||||
reason: 'asset_object 已有 asset_kind 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/ai/stages.rs',
|
||||
snippet: '.ai_task_stage()\n .iter()\n .filter(|row| row.task_id == task_id)',
|
||||
reason: 'ai_task_stage 已有 by_ai_task_stage_task_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/ai/stages.rs',
|
||||
snippet: '.ai_text_chunk()\n .iter()\n .filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)',
|
||||
reason: 'ai_text_chunk 已有 by_ai_text_chunk_task_id / by_ai_text_chunk_task_stage_sequence 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs',
|
||||
snippet: '.ai_task_stage()\n .iter()\n .filter(|stage| stage.task_id == row.task_id)',
|
||||
reason: 'ai_task_stage 快照组装应使用 by_ai_task_stage_task_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/ai/snapshots.rs',
|
||||
snippet: '.ai_result_reference()\n .iter()\n .filter(|reference| reference.task_id == row.task_id)',
|
||||
reason: 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||
snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
|
||||
reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||
snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
|
||||
reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||
snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
|
||||
reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||
snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)',
|
||||
reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||
snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)',
|
||||
reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
|
||||
snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {',
|
||||
reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/custom_world.rs',
|
||||
snippet: '.custom_world_profile()\n .iter()\n .find(|row| {',
|
||||
reason: 'custom_world_profile owner 维度已有 by_custom_world_profile_owner_user_id 索引',
|
||||
},
|
||||
{
|
||||
file: 'server-rs/crates/spacetime-module/src/custom_world.rs',
|
||||
snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {',
|
||||
reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引',
|
||||
},
|
||||
];
|
||||
|
||||
const procedureResultFiles = [
|
||||
'server-rs/crates/module-puzzle/src/application.rs',
|
||||
'server-rs/crates/module-big-fish/src/domain.rs',
|
||||
'server-rs/crates/spacetime-module/src/match3d/types.rs',
|
||||
'server-rs/crates/spacetime-module/src/square_hole/types.rs',
|
||||
'server-rs/crates/spacetime-module/src/visual_novel.rs',
|
||||
'server-rs/crates/spacetime-module/src/bark_battle/types.rs',
|
||||
];
|
||||
|
||||
const mapperCompatibilityFiles = [
|
||||
'server-rs/crates/spacetime-client/src/mapper.rs',
|
||||
'server-rs/crates/spacetime-client/src/lib.rs',
|
||||
];
|
||||
|
||||
const bigFishRuntimeFiles = [
|
||||
'server-rs/crates/module-big-fish/src/commands.rs',
|
||||
'server-rs/crates/spacetime-module/src/big_fish/runtime.rs',
|
||||
'server-rs/crates/spacetime-module/src/big_fish/session.rs',
|
||||
];
|
||||
|
||||
const legacyMapperPatterns = [
|
||||
{
|
||||
pattern: /\b[A-Za-z0-9_]*JsonRecord\b/u,
|
||||
reason: 'spacetime-client mapper 不应保留旧 ProcedureResult JSON 兼容 Record',
|
||||
},
|
||||
{
|
||||
pattern: /\bCompatibleBigFish[A-Za-z0-9_]*\b/u,
|
||||
reason: 'spacetime-client mapper 不应保留 BigFish 旧 JSON 兼容结构',
|
||||
},
|
||||
{
|
||||
pattern: /\bmap_[A-Za-z0-9_]*_json\b/u,
|
||||
reason: 'spacetime-client mapper 不应再通过 map_*_json 反序列化 procedure payload',
|
||||
},
|
||||
{
|
||||
pattern: /serde_json::from_str::<[A-Za-z0-9_:]*JsonRecord/u,
|
||||
reason: 'spacetime-client mapper 不应把 procedure result 再反序列化为 JsonRecord',
|
||||
},
|
||||
{
|
||||
pattern: /\b(?:items|run|work|session|event|feedback)_json:\s*Some\(/u,
|
||||
reason: 'mapper 测试与兼容路径不应再构造旧 procedure JSON 字符串字段',
|
||||
},
|
||||
];
|
||||
|
||||
const typedProcedurePayloadFieldPattern =
|
||||
/\b(?:row|session|work|item|items|run|event|feedback)_json:\s*Option<String>/gu;
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const rule of forbiddenSnippets) {
|
||||
const content = readUtf8(rule.file);
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
if (content.includes(rule.snippet)) {
|
||||
failures.push(`${rule.file}: ${rule.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of procedureResultFiles) {
|
||||
const content = readUtf8(file);
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? [];
|
||||
for (const block of resultBlocks) {
|
||||
const jsonFields = block.match(typedProcedurePayloadFieldPattern);
|
||||
if (jsonFields?.length) {
|
||||
const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult';
|
||||
failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of mapperCompatibilityFiles) {
|
||||
const content = readUtf8(file);
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
for (const rule of legacyMapperPatterns) {
|
||||
if (rule.pattern.test(content)) {
|
||||
failures.push(`${file}: ${rule.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of bigFishRuntimeFiles) {
|
||||
const content = readUtf8(file);
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
const resultBlocks = content.match(/pub struct [A-Za-z0-9_]*ProcedureResult\s*\{[\s\S]*?\n\}/g) ?? [];
|
||||
for (const block of resultBlocks) {
|
||||
const jsonFields = block.match(typedProcedurePayloadFieldPattern);
|
||||
if (jsonFields?.length) {
|
||||
const name = block.match(/pub struct ([A-Za-z0-9_]+)/)?.[1] ?? 'ProcedureResult';
|
||||
failures.push(`${file}: ${name} 仍通过 ${jsonFields.join(', ')} 跨层返回 JSON 字符串`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('SpacetimeDB runtime access 检查失败:');
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('SpacetimeDB runtime access 检查通过。');
|
||||
99
scripts/container-compose.mjs
Normal file
99
scripts/container-compose.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {copyFileSync, existsSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const [, , rawCommand = 'help', ...args] = process.argv;
|
||||
const command = rawCommand.trim();
|
||||
const printComposeConfig = args.includes('--print');
|
||||
const passthroughArgs = args.filter((arg) => arg !== '--print');
|
||||
const projectRoot = process.cwd();
|
||||
const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml');
|
||||
const envExamplePath = path.join('deploy', 'container', 'api-server.env.example');
|
||||
const envPath = path.join('deploy', 'container', 'api-server.env');
|
||||
|
||||
const supportedCommands = new Set(['init', 'build', 'up', 'down', 'logs', 'ps', 'config', 'k6']);
|
||||
|
||||
if (command === 'help' || !supportedCommands.has(command)) {
|
||||
printHelp(command !== 'help');
|
||||
process.exit(command === 'help' ? 0 : 1);
|
||||
}
|
||||
|
||||
if (command === 'init') {
|
||||
ensureEnvFile();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!existsSync(envPath)) {
|
||||
ensureEnvFile();
|
||||
console.error('[container] 请先检查 deploy/container/api-server.env 中的 SpacetimeDB 地址、库名和 token。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const composeArgs = buildComposeArgs(command, passthroughArgs);
|
||||
const child = spawn('docker', composeArgs, {
|
||||
cwd: projectRoot,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[container] docker compose 启动失败: ${error.message}`);
|
||||
console.error('[container] 请确认 Docker Desktop 或 Docker Engine 已安装,并且 docker 在 PATH 中。');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[container] docker compose 被信号终止: ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
function buildComposeArgs(selectedCommand, extraArgs) {
|
||||
const baseArgs = ['compose', '-f', composeFile];
|
||||
switch (selectedCommand) {
|
||||
case 'build':
|
||||
return [...baseArgs, 'build', ...extraArgs];
|
||||
case 'up':
|
||||
return [...baseArgs, 'up', '-d', ...extraArgs];
|
||||
case 'down':
|
||||
return [...baseArgs, 'down', ...extraArgs];
|
||||
case 'logs':
|
||||
return [...baseArgs, 'logs', ...extraArgs];
|
||||
case 'ps':
|
||||
return [...baseArgs, 'ps', ...extraArgs];
|
||||
case 'config':
|
||||
return [...baseArgs, 'config', ...(printComposeConfig ? [] : ['--quiet']), ...extraArgs];
|
||||
case 'k6':
|
||||
return [...baseArgs, '--profile', 'loadtest', 'run', '--rm', 'k6', ...extraArgs];
|
||||
default:
|
||||
throw new Error(`unsupported command: ${selectedCommand}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEnvFile() {
|
||||
if (existsSync(envPath)) {
|
||||
console.log(`[container] 已存在 ${envPath}`);
|
||||
return;
|
||||
}
|
||||
copyFileSync(envExamplePath, envPath);
|
||||
console.log(`[container] 已从 ${envExamplePath} 生成 ${envPath}`);
|
||||
}
|
||||
|
||||
function printHelp(isError) {
|
||||
const output = isError ? console.error : console.log;
|
||||
output(`Usage: npm run container:<command> -- [docker compose args]
|
||||
|
||||
Commands:
|
||||
container:init 生成 deploy/container/api-server.env
|
||||
container:build 构建 api-server 容器镜像
|
||||
container:up 后台启动 spacetimedb + api-server + nginx + otelcol
|
||||
container:down 停止并清理容器
|
||||
container:logs 查看容器日志
|
||||
container:ps 查看容器状态
|
||||
container:config 校验 compose 配置,传 -- --print 可展开完整配置
|
||||
container:k6 在 compose 网络内运行 k6
|
||||
`);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import {existsSync, mkdirSync, readFileSync} from 'node:fs';
|
||||
import {dirname, isAbsolute, resolve} from 'node:path';
|
||||
|
||||
export const LOCAL_ENV_FILES = ['.env', '.env.local', '.env.secrets.local'];
|
||||
const LOCAL_ENV_OVERRIDE_KEYS = new Set([
|
||||
'SMS_AUTH_ENABLED',
|
||||
'SMS_AUTH_PROVIDER',
|
||||
'SMS_AUTH_MOCK_VERIFY_CODE',
|
||||
'WECHAT_AUTH_ENABLED',
|
||||
'WECHAT_AUTH_PROVIDER',
|
||||
]);
|
||||
|
||||
export function buildProtectedEnvKeys(baseEnv) {
|
||||
return new Set(
|
||||
@@ -29,7 +36,7 @@ export function loadEnvFile(path, target, protectedKeys) {
|
||||
}
|
||||
|
||||
const [, key, rawValue] = match;
|
||||
if (protectedKeys.has(key)) {
|
||||
if (protectedKeys.has(key) && !LOCAL_ENV_OVERRIDE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,26 @@ describe('dev utils env merge', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('本地认证开关覆盖外层 shell 旧值', () => {
|
||||
withTempEnvFiles(
|
||||
{
|
||||
'.env.local': [
|
||||
'SMS_AUTH_ENABLED=true',
|
||||
'SMS_AUTH_PROVIDER=aliyun',
|
||||
].join('\n'),
|
||||
},
|
||||
(_env, tempDir) => {
|
||||
const env = mergeApiServerEnv(tempDir, {
|
||||
SMS_AUTH_ENABLED: 'false',
|
||||
SMS_AUTH_PROVIDER: 'mock',
|
||||
});
|
||||
|
||||
expect(env.SMS_AUTH_ENABLED).toBe('true');
|
||||
expect(env.SMS_AUTH_PROVIDER).toBe('aliyun');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
|
||||
withTempEnvFiles(
|
||||
{
|
||||
|
||||
@@ -21,6 +21,14 @@ const TARGETS = [
|
||||
'src',
|
||||
'module_bindings',
|
||||
),
|
||||
entryFile: path.join(
|
||||
REPO_ROOT,
|
||||
'server-rs',
|
||||
'crates',
|
||||
'spacetime-client',
|
||||
'src',
|
||||
'module_bindings.rs',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -64,6 +72,7 @@ for (const target of selectedTargets) {
|
||||
|
||||
console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
|
||||
await replaceGeneratedDir(tempOutDir, target.outDir);
|
||||
await moveGeneratedEntryFile(target);
|
||||
}
|
||||
|
||||
await rm(tempRoot, {recursive: true, force: true});
|
||||
@@ -111,6 +120,23 @@ async function replaceGeneratedDir(fromDir, toDir) {
|
||||
}
|
||||
}
|
||||
|
||||
async function moveGeneratedEntryFile(target) {
|
||||
if (!target.entryFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertInside(target.entryFile, REPO_ROOT, '生成入口文件');
|
||||
const generatedModFile = path.join(target.outDir, 'mod.rs');
|
||||
|
||||
if (!existsSync(generatedModFile)) {
|
||||
throw new Error(`${target.name} bindings 缺少入口文件: ${generatedModFile}`);
|
||||
}
|
||||
|
||||
await rm(target.entryFile, {force: true});
|
||||
await cp(generatedModFile, target.entryFile, {force: true});
|
||||
await rm(generatedModFile, {force: true});
|
||||
}
|
||||
|
||||
function assertInside(candidate, parent, label) {
|
||||
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
||||
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}"
|
||||
SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}"
|
||||
OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}"
|
||||
|
||||
require_non_root_relative_path() {
|
||||
local label="$1"
|
||||
local path="$2"
|
||||
|
||||
if [[ -z "${path}" ]]; then
|
||||
echo "[server-provision] ${label} 不能为空。" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${path}" == /* || "${path}" == *..* ]]; then
|
||||
echo "[server-provision] ${label} 只能是工作区内的相对路径: ${path}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_path() {
|
||||
local path="$1"
|
||||
if [[ ! -e "${path}" ]]; then
|
||||
@@ -63,6 +81,15 @@ install_build_dependencies() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_nginx_brotli_modules() {
|
||||
echo "[server-provision] 安装 Nginx Brotli 动态模块依赖"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
run_cmd apt-get install -y libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
|
||||
else
|
||||
echo "[server-provision] 当前系统未使用 apt,无法自动安装 Nginx Brotli 动态模块;将继续通过 nginx -t 能力探测决定是否启用 Brotli。"
|
||||
fi
|
||||
}
|
||||
|
||||
install_sccache() {
|
||||
for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
|
||||
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
|
||||
@@ -81,16 +108,16 @@ install_sccache() {
|
||||
fi
|
||||
|
||||
echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。"
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "+ cargo install sccache --locked"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo "[server-provision] 未找到 cargo,无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cargo install sccache --locked
|
||||
if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then
|
||||
echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2
|
||||
@@ -98,6 +125,42 @@ install_sccache() {
|
||||
fi
|
||||
}
|
||||
|
||||
sync_otelcol_install() {
|
||||
local target_bin="/usr/local/bin/otelcol-contrib"
|
||||
local source_bin="${OTELCOL_BIN_SOURCE}"
|
||||
local version="${OTELCOL_VERSION:-0.151.0}"
|
||||
local resolved_source="${source_bin}"
|
||||
|
||||
if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then
|
||||
echo "[server-provision] ENABLE_OTELCOL=${ENABLE_OTELCOL:-},跳过 otelcol-contrib 配置。"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v readlink >/dev/null 2>&1; then
|
||||
resolved_source="$(readlink -f "${source_bin}" 2>/dev/null || echo "${source_bin}")"
|
||||
fi
|
||||
|
||||
if [[ ! -x "${resolved_source}" ]]; then
|
||||
echo "[server-provision] otelcol-contrib 不存在或不可执行: ${source_bin}" >&2
|
||||
echo "[server-provision] 请先在构建机准备好 otelcol-contrib ${version},再通过 provision-tools 上传到目标机。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "+ install -m 0755 ${resolved_source} ${target_bin}"
|
||||
return
|
||||
fi
|
||||
|
||||
install -m 0755 "${resolved_source}" "${target_bin}"
|
||||
if ! "${target_bin}" --version >/dev/null 2>&1; then
|
||||
echo "[server-provision] otelcol-contrib 安装后无法执行: ${target_bin}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "${target_bin}" --version 2>/dev/null | grep -q "${version}"; then
|
||||
echo "[server-provision] 警告: otelcol-contrib 版本不是期望的 ${version}: $("${target_bin}" --version 2>/dev/null || true)" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
sync_spacetime_install() {
|
||||
local root_dir="$1"
|
||||
local target_bin_dir="${root_dir}/bin/current"
|
||||
@@ -106,14 +169,6 @@ sync_spacetime_install() {
|
||||
local resolved_command="${SPACETIME_BIN_SOURCE}"
|
||||
local install_dir=""
|
||||
local root_bin="${root_dir}/bin"
|
||||
local share_bin_dir=""
|
||||
local version_dir=""
|
||||
local parent_dir=""
|
||||
|
||||
if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then
|
||||
echo "[server-provision] SpacetimeDB current 目录已存在: ${target_bin_dir}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[server-provision] 同步 SpacetimeDB current 目录到 ${target_bin_dir}"
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
@@ -128,26 +183,10 @@ sync_spacetime_install() {
|
||||
install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)"
|
||||
mkdir -p "${root_bin}"
|
||||
|
||||
for share_bin_dir in \
|
||||
"/usr/.local/share/spacetime/bin" \
|
||||
"/root/.local/share/spacetime/bin" \
|
||||
"${HOME:-}/.local/share/spacetime/bin"; do
|
||||
if [[ -d "${share_bin_dir}" ]]; then
|
||||
version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
|
||||
if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then
|
||||
echo "[server-provision] 同步 SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}"
|
||||
rm -rf "${target_bin_dir}"
|
||||
mkdir -p "${target_bin_dir}"
|
||||
cp -a "${version_dir}/." "${target_bin_dir}/"
|
||||
chmod +x "${target_cli}" "${target_standalone}"
|
||||
chown -R spacetimedb:spacetimedb "${root_bin}"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -d "${install_dir}/bin" ]]; then
|
||||
echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}"
|
||||
rm -rf "${root_bin}"
|
||||
mkdir -p "${root_bin}"
|
||||
cp -a "${install_dir}/bin/." "${root_bin}/"
|
||||
elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then
|
||||
echo "[server-provision] 同步 SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}"
|
||||
@@ -156,14 +195,8 @@ sync_spacetime_install() {
|
||||
cp -f "${install_dir}/spacetimedb-cli" "${target_cli}"
|
||||
cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}"
|
||||
chmod +x "${target_cli}" "${target_standalone}"
|
||||
elif [[ -f "${resolved_command}" ]]; then
|
||||
parent_dir="$(cd -- "${install_dir}/.." && pwd)"
|
||||
if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then
|
||||
echo "[server-provision] 同步 SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}"
|
||||
cp -a "${parent_dir}/bin/." "${root_bin}/"
|
||||
else
|
||||
echo "[server-provision] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2
|
||||
fi
|
||||
echo "[server-provision] 未能从 SpacetimeDB 交付包推断完整安装目录: ${resolved_command}" >&2
|
||||
fi
|
||||
|
||||
if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then
|
||||
@@ -387,6 +420,10 @@ render_api_env_example() {
|
||||
deploy/env/api-server.env.example
|
||||
}
|
||||
|
||||
render_otelcol_service() {
|
||||
cat deploy/systemd/otelcol-contrib.service
|
||||
}
|
||||
|
||||
validate_nginx_tls() {
|
||||
local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}"
|
||||
if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then
|
||||
@@ -523,6 +560,8 @@ render_api_service() {
|
||||
|
||||
require_path deploy/systemd/spacetimedb.service
|
||||
require_path deploy/systemd/genarrative-api.service
|
||||
require_path deploy/systemd/otelcol-contrib.service
|
||||
require_path deploy/otelcol/genarrative-debug.yaml
|
||||
require_path deploy/nginx/genarrative.conf
|
||||
require_path deploy/nginx/genarrative-dev-http.conf
|
||||
require_path deploy/nginx/snippets/genarrative-maintenance.conf
|
||||
@@ -532,13 +571,15 @@ require_path scripts/deploy/maintenance-off.sh
|
||||
require_path scripts/deploy/maintenance-status.sh
|
||||
|
||||
validate_server_names
|
||||
require_non_root_relative_path "PROVISION_TOOLS_DIR" "${PROVISION_TOOLS_DIR}"
|
||||
|
||||
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
|
||||
|
||||
run_cmd id
|
||||
install_build_dependencies
|
||||
install_nginx_brotli_modules
|
||||
install_sccache
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox
|
||||
|
||||
if ! id spacetimedb >/dev/null 2>&1; then
|
||||
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
|
||||
@@ -585,6 +626,16 @@ else
|
||||
echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}"
|
||||
fi
|
||||
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
sync_otelcol_install
|
||||
otelcol_service="$(mktemp)"
|
||||
render_otelcol_service >"${otelcol_service}"
|
||||
install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644
|
||||
rm -f "${otelcol_service}"
|
||||
else
|
||||
echo "[server-provision] ENABLE_OTELCOL=${ENABLE_OTELCOL:-},跳过 otelcol-contrib service 安装。"
|
||||
fi
|
||||
|
||||
if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then
|
||||
install_nginx_config_with_rollback
|
||||
else
|
||||
@@ -593,7 +644,13 @@ fi
|
||||
|
||||
run_cmd systemctl daemon-reload
|
||||
if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl enable otelcol-contrib.service
|
||||
fi
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl restart otelcol-contrib.service
|
||||
fi
|
||||
run_cmd systemctl restart spacetimedb.service
|
||||
wait_for_spacetimedb_service
|
||||
ensure_spacetime_owner_client_token
|
||||
|
||||
@@ -113,6 +113,17 @@ $env:WORKS_DATA="data/works-list.local.json"
|
||||
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||
```
|
||||
|
||||
## 50 HTTP req/s 口径
|
||||
|
||||
`k6-works-list.js` 默认一次 iteration 会依次请求两个公开列表接口:`/api/runtime/puzzle/gallery` 和 `/api/runtime/custom-world-gallery`。因此目标约 50 HTTP req/s 时,`ramping-arrival-rate` 的 `PEAK_RPS` 应设置为 `25`。如果传入 `AUTH_TOKEN` 或把 `DETAIL_RATIO` 设为大于 0,每次 iteration 的请求数会增加,需要重新折算。
|
||||
|
||||
验收目标:
|
||||
|
||||
- `http_req_failed < 1%`
|
||||
- `http_req_duration p95 < 2000ms`
|
||||
- `dropped_iterations = 0`
|
||||
- 压测窗口内 Nginx 无新增 502
|
||||
|
||||
## Smoke
|
||||
|
||||
```bash
|
||||
@@ -151,17 +162,38 @@ BASE_URL=http://127.0.0.1:8787 \
|
||||
WORKS_DATA=data/works-list.local.json \
|
||||
SCENARIO=spike \
|
||||
START_RPS=5 \
|
||||
PEAK_RPS=100 \
|
||||
HOLD=2m \
|
||||
PEAK_RPS=25 \
|
||||
HOLD=60s \
|
||||
DETAIL_RATIO=0 \
|
||||
npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
默认阈值:
|
||||
|
||||
- `http_req_failed < 5%`
|
||||
- `http_req_failed < 1%`
|
||||
- `http_req_duration p95 < 2000ms`
|
||||
- `works_list_shape_error_rate < 5%`
|
||||
- `dropped_iterations = 0`
|
||||
- `works_list_shape_error_rate < 1%`
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:BASE_URL="https://genarrative.world"
|
||||
$env:WORKS_DATA="data/works-list.local.json"
|
||||
$env:SCENARIO="spike"
|
||||
$env:START_RPS="5"
|
||||
$env:PEAK_RPS="25"
|
||||
$env:HOLD="60s"
|
||||
$env:END_RPS="5"
|
||||
$env:DETAIL_RATIO="0"
|
||||
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"
|
||||
```
|
||||
|
||||
线上 release 回归可使用同一组环境变量:
|
||||
|
||||
```bash
|
||||
SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works
|
||||
```
|
||||
|
||||
## 带登录态压测个人作品列表
|
||||
|
||||
@@ -194,9 +226,123 @@ npm run loadtest:k6:works
|
||||
## 排障
|
||||
|
||||
- 如果公开 gallery 返回 `creation_entry_disabled` 或 503,检查本地 creation entry 配置是否禁用了对应入口。
|
||||
- 如果高压下返回 429,优先确认目标环境是否设置了 `GENARRATIVE_API_MAX_CONCURRENT_REQUESTS` 以及 `GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS`、`GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS`。429 表示 Nginx 或 api-server 背压已生效,不等同于业务错误;继续看内存、p95、`http_req_failed` 和 OTLP / Nginx timing 判断阈值是否偏低。
|
||||
- 如果直连 `api-server` 压测出现 `connection refused` 或 status 0,说明压力已经打到 TCP 监听 / accept 层;此时同时检查 `GENARRATIVE_API_LISTEN_BACKLOG`、Nginx upstream keepalive 和是否需要在 Nginx 前置限流,不能只靠应用层背压解释。
|
||||
- 如果个人作品列表返回 401,确认 `AUTH_TOKEN` 是当前 api-server 可识别的 access token。
|
||||
- 如果详情全部 404,确认是否已向目标环境导入与 `WORKS_DATA` 一致的数据。
|
||||
|
||||
## 压测窗口采集
|
||||
|
||||
Nginx upstream timing:
|
||||
|
||||
```bash
|
||||
sudo tail -f /var/log/nginx/genarrative.access.log
|
||||
sudo tail -f /var/log/nginx/genarrative.error.log
|
||||
```
|
||||
|
||||
api-server 与 SpacetimeDB 日志:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u genarrative-api.service -f
|
||||
sudo journalctl -u spacetimedb.service -f
|
||||
```
|
||||
|
||||
api-server 的 OpenTelemetry 在生产与容器模板里默认开启。需要临时关闭时,显式把 `GENARRATIVE_OTEL_ENABLED=false`;需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 `127.0.0.1` 的 `otelcol-contrib` debug exporter:
|
||||
|
||||
```bash
|
||||
npm run otel:debug
|
||||
```
|
||||
|
||||
如果要把本机数据转发给 Rider OpenTelemetry 面板,先在 Rider 的 OpenTelemetry 设置中启用固定 OTLP server port,例如 `17011`,再运行:
|
||||
|
||||
```bash
|
||||
RIDER_OTLP_GRPC_ENDPOINT=127.0.0.1:17011 npm run otel:rider
|
||||
```
|
||||
|
||||
脚本会在 `.codex-temp/otelcol/` 生成临时 collector 配置,默认接收 api-server 发到 `http://127.0.0.1:4318` 的 OTLP HTTP 数据。需要改端口时可设置:
|
||||
|
||||
- `OTELCOL_OTLP_HTTP_ENDPOINT`,默认 `127.0.0.1:4318`
|
||||
- `OTELCOL_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:4317`
|
||||
- `RIDER_OTLP_GRPC_ENDPOINT`,默认 `127.0.0.1:17011`
|
||||
- `OTELCOL_BIN`,默认 `otelcol-contrib`
|
||||
|
||||
等价的 debug collector 配置如下:
|
||||
|
||||
```yaml
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 127.0.0.1:4317
|
||||
http:
|
||||
endpoint: 127.0.0.1:4318
|
||||
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: detailed
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
```
|
||||
|
||||
```bash
|
||||
otelcol-contrib --config /etc/otelcol-contrib/genarrative-debug.yaml
|
||||
```
|
||||
|
||||
然后在 `/etc/genarrative/api-server.env` 中打开:
|
||||
|
||||
```env
|
||||
GENARRATIVE_OTEL_ENABLED=true
|
||||
OTEL_SERVICE_NAME=genarrative-api
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318
|
||||
```
|
||||
|
||||
注意 `api-server` 当前使用 OTLP HTTP exporter,`OTEL_EXPORTER_OTLP_ENDPOINT` 必须指向 Collector 的 HTTP base endpoint `http://127.0.0.1:4318`。不要把它改成 Collector gRPC 端口 `4317`,也不要直接指向 Rider 的 gRPC 端口;Rider 只由 `npm run otel:rider` 启动的 Collector 通过 `RIDER_OTLP_GRPC_ENDPOINT` 转发。
|
||||
|
||||
OTLP logs 是远端观测增量,不替代本地日志;api-server 日志仍看 `journalctl` / `logs/api-server/`,Nginx 日志仍看文件。日志等级继续用 `GENARRATIVE_API_LOG` / `RUST_LOG` 控制,例如 `info,tower_http=info,spacetime_client=info`。
|
||||
|
||||
Rider 的 Logs 面板展示的是 OTLP log event 自身字段,不会自动把父 span 的全部 attributes 摊平到每一条日志。请求完成日志会直接携带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`;更完整的请求链路仍在 Traces 面板中按同一个 trace/span 关联查看。
|
||||
|
||||
压测期间可在 Metrics 面板或 debug exporter 中观察进程内存指标:
|
||||
|
||||
- `process.memory.usage`:进程常驻内存 / RSS。
|
||||
- `process.memory.virtual`:进程虚拟内存;Windows 当前按 `PrivateUsage` 上报,Linux 取 `VmSize`。
|
||||
- `genarrative.process.memory.private`:进程私有内存,Windows 来自 `PrivateUsage`,Linux 近似取 `/proc/self/status` 的 `VmData`。
|
||||
- `process.cpu.time`:进程 user + system 累计 CPU 秒数。
|
||||
- `genarrative.process.cpu.usage_percent`:两次指标采集之间的进程 CPU 使用率;100% 约等于占满 1 个 CPU core。
|
||||
- `process.thread.count`:线程数。
|
||||
- `process.windows.handle.count`:Windows 句柄数。
|
||||
- `process.unix.file_descriptor.count`:Linux 文件描述符数。
|
||||
- `genarrative.http.server.response_bodies.in_flight`:Axum / Hyper 仍持有的响应 body 数;如果内存高但该值很低,说明热点不在业务 handler 生命周期内。
|
||||
- `genarrative.http.server.request_permits.available`:应用层 HTTP 背压剩余 permit 数,带 `pool=default|gallery|detail|admin`;如果目标 pool 未接近 0,说明没有打满对应 `GENARRATIVE_API_*_MAX_CONCURRENT_REQUESTS`。
|
||||
- `genarrative.puzzle_gallery.cache.hits` / `genarrative.puzzle_gallery.cache.stale_hits` / `genarrative.puzzle_gallery.cache.misses` / `genarrative.puzzle_gallery.cache.refreshes_started` / `genarrative.puzzle_gallery.cache.refreshes_failed` / `genarrative.puzzle_gallery.cache.rebuilds`:拼图广场响应缓存 fresh 命中、stale 命中、未命中、后台刷新和重建次数。
|
||||
- `genarrative.puzzle_gallery.cache.rebuild.duration`:拼图广场缓存重建耗时。
|
||||
- `genarrative.puzzle_gallery.cache.data_json_bytes`:拼图广场缓存内预序列化 data JSON 大小。
|
||||
- `genarrative.spacetime.read.calls` / `genarrative.spacetime.read.duration_ms`:SpacetimeDB 订阅本地 cache 读次数和耗时;`read=list_puzzle_gallery` 表示当前路径走 view / local cache,不是 procedure。
|
||||
|
||||
若 `/api/runtime/puzzle/gallery` 单接口压测出现 GB 级瞬时内存峰值,先区分“持续泄漏”和“请求期分配峰值”:关闭 OTEL 后若峰值仍复现且压测结束后回落,主因通常不是 Collector / exporter。当前拼图广场列表命中缓存时应复用 `PuzzleGalleryCache` 中的预序列化 data JSON,只按请求拼接 envelope meta,不应每个请求重新深拷贝 `PuzzleGalleryResponse` 或构造完整 `serde_json::Value`。
|
||||
|
||||
本地 Windows 直连 `api-server` 压测还要单独看 K6 的 VU / 连接模型。已验证在 250 RPS、`PREALLOCATED_VUS=300` 时,哪怕打 `/healthz` 这种小响应,也可能因为本地 300 个 Established 连接触发 `api-server` private memory 瞬时升到约 7GB,压测结束后回落到 100MB 级;同样 250 RPS 改成 `PREALLOCATED_VUS=20 MAX_VUS=40` 后,拼图广场 p95 约 9ms,峰值降到约 600MB。这个现象说明高水位主要来自本机直连连接 / 发送链路,不等价于 SpacetimeDB 或拼图 JSON 缓存泄漏。做本地容量判断时优先让 VU 接近真实并发,避免用过高预分配 VU 把测试变成 Windows 本机连接缓冲压力测试;生产仍以 Nginx upstream keepalive、系统内存和 OTLP 指标一起判断。
|
||||
|
||||
线上回归辅助命令:
|
||||
|
||||
```bash
|
||||
systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax
|
||||
cat /proc/$(pidof api-server)/limits
|
||||
tr '\0' '\n' < /proc/$(pidof api-server)/environ | grep 'GENARRATIVE_API_.*MAX_CONCURRENT_REQUESTS'
|
||||
ss -ltnp | grep 8082
|
||||
curl -sS http://127.0.0.1:8082/healthz
|
||||
```
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
|
||||
218
scripts/loadtest/data/works-list.sample.from-migration-1.json
Normal file
218
scripts/loadtest/data/works-list.sample.from-migration-1.json
Normal file
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"source": "spacetime-migration-1.json",
|
||||
"generatedAt": "2026-05-16T13:35:40.282Z",
|
||||
"counts": {
|
||||
"puzzle_work_profile": 3,
|
||||
"custom_world_profile": 1,
|
||||
"match3d_work_profile": 0,
|
||||
"square_hole_work_profile": 0,
|
||||
"visual_novel_work_profile": 0
|
||||
},
|
||||
"tables": {
|
||||
"puzzle_work_profile": [
|
||||
{
|
||||
"profile_id": "profile-001",
|
||||
"work_id": "work-001",
|
||||
"owner_user_id": "user-001",
|
||||
"author_display_name": "author-001",
|
||||
"cover_asset_id": "asset-001",
|
||||
"cover_image_src": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||
"work_title": "化学家",
|
||||
"level_name": "文学家",
|
||||
"summary": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||
"work_description": "一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1777649242577-7\",\"level_name\":\"文学家\",\"picture_description\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"candidates\":[{\"candidate_id\":\"puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2\",\"image_src\":\"/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png\",\"asset_id\":\"asset-1777649330373133\",\"prompt\":\"几个文学家正站在山上面对着瀑布侃侃而谈\",\"actual_prompt\":\"请生成一张高清插画。画面主体:几个文学家正站在山上面对着瀑布侃侃而谈。画面…",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"化学家\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"一个穿着白大褂的化学家正在做酷炫的化学实验,背景是化学实验室\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"化学家、拼图、插画;禁止标题字\",\"status\":\"I…",
|
||||
"theme_tags_json": "[\"化学家\",\"拼图\",\"插画\",\"禁止标题字\"]",
|
||||
"publication_status": {
|
||||
"Published": []
|
||||
},
|
||||
"play_count": 1,
|
||||
"like_count": 0,
|
||||
"remix_count": 1,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777648804043558
|
||||
},
|
||||
"published_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777649364112270
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "profile-002",
|
||||
"work_id": "work-002",
|
||||
"owner_user_id": "user-002",
|
||||
"author_display_name": "author-002",
|
||||
"work_title": "我不知道",
|
||||
"level_name": "",
|
||||
"summary": "你猜我是谁",
|
||||
"work_description": "你猜我是谁",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"真不知道\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"我不知道\",\"status\":\"Locked\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"真不知道\",\"status\":\"Locked\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"清晰、适合拼图切块\",\"status\":\"Inferred\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"主体轮廓、色块分区、局部细节\",\"status\":\"Inferred\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"我不知道、拼图、插画;禁止标题字\",\"status\":\"Inferred\"}}",
|
||||
"theme_tags_json": "[\"我不知道\"]",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619336673245
|
||||
}
|
||||
},
|
||||
{
|
||||
"profile_id": "profile-003",
|
||||
"work_id": "work-003",
|
||||
"owner_user_id": "user-003",
|
||||
"author_display_name": "author-002",
|
||||
"work_title": "",
|
||||
"level_name": "",
|
||||
"summary": "",
|
||||
"work_description": "",
|
||||
"levels_json": "[{\"level_id\":\"puzzle-level-1\",\"level_name\":\"\",\"picture_description\":\"\",\"candidates\":[],\"selected_candidate_id\":null,\"cover_image_src\":null,\"cover_asset_id\":null,\"generation_status\":\"idle\"}]",
|
||||
"anchor_pack_json": "{\"theme_promise\":{\"key\":\"themePromise\",\"label\":\"题材承诺\",\"value\":\"\",\"status\":\"Missing\"},\"visual_subject\":{\"key\":\"visualSubject\",\"label\":\"画面主体\",\"value\":\"\",\"status\":\"Missing\"},\"visual_mood\":{\"key\":\"visualMood\",\"label\":\"视觉气质\",\"value\":\"\",\"status\":\"Missing\"},\"composition_hooks\":{\"key\":\"compositionHooks\",\"label\":\"拼图记忆点\",\"value\":\"\",\"status\":\"Missing\"},\"tags_and_forbidden\":{\"key\":\"tagsAndForbidden\",\"label\":\"标签与禁忌\",\"value\":\"\",\"status\":\"Missing\"}}",
|
||||
"theme_tags_json": "[\"拼图\",\"插画\",\"清晰构图\"]",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
}
|
||||
}
|
||||
],
|
||||
"custom_world_profile": [
|
||||
{
|
||||
"profile_id": "profile-081",
|
||||
"owner_user_id": "user-002",
|
||||
"author_display_name": "author-012",
|
||||
"author_public_user_code": "author-code-001",
|
||||
"world_name": "青春飞扬校园",
|
||||
"summary_text": "在现代校园中,玩家摆脱内卷,追求真实成长",
|
||||
"subtitle": "反内卷的自由学习之旅",
|
||||
"profile_payload_json": "{\"anchorContent\":null,\"anchorPack\":null,\"attributeSchema\":{\"generatedFrom\":{\"conflictCore\":\"与传统教育模式的冲突\",\"settingSummary\":\"在现代校园中,玩家摆脱内卷,追求真实成长\",\"tone\":\"积极向上,充满活力与创新\",\"worldName\":\"青春飞扬校园\",\"worldType\":\"CUSTOM\"},\"id\":\"schema:rpg-agent:1e15b44d:v1\",\"schemaVersion\":1,\"slots\":[{\"name\":\"知识储备\",\"slotId\":\"axis_a\"},{\"name\":\"创新思维\",\"slotId\":\"axis_b\"},{\"name\":\"社交能力\",\"slotId\":\"axis_c\"},{\"name\":\"抗压能力\",\"slotId\":\"axis_d\"},{\"name\":\"自我认知\",\"slotId\":\"axis_e\"},{\"name\":\"团队协作\",\"slotId\":\"axis_f\"}],\"worldId\":\"custom:青春飞扬校…",
|
||||
"publication_status": {
|
||||
"Draft": []
|
||||
},
|
||||
"play_count": 0,
|
||||
"like_count": 0,
|
||||
"remix_count": 0,
|
||||
"updated_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||
},
|
||||
"created_at": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777531745887256
|
||||
}
|
||||
}
|
||||
],
|
||||
"match3d_work_profile": [],
|
||||
"square_hole_work_profile": [],
|
||||
"visual_novel_work_profile": []
|
||||
},
|
||||
"profileIds": {
|
||||
"puzzle": [
|
||||
"profile-001",
|
||||
"profile-002",
|
||||
"profile-003"
|
||||
],
|
||||
"customWorld": [
|
||||
"profile-081"
|
||||
],
|
||||
"match3d": [],
|
||||
"squareHole": [],
|
||||
"bigFish": [],
|
||||
"visualNovel": []
|
||||
},
|
||||
"workIds": {
|
||||
"puzzle": [
|
||||
"work-001",
|
||||
"work-002",
|
||||
"work-003"
|
||||
],
|
||||
"customWorld": [],
|
||||
"match3d": [],
|
||||
"squareHole": [],
|
||||
"bigFish": [],
|
||||
"visualNovel": []
|
||||
},
|
||||
"normalizedWorks": [
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-001",
|
||||
"profileId": "profile-001",
|
||||
"ownerUserId": "user-001",
|
||||
"title": "化学家",
|
||||
"subtitle": "几个文学家正站在山上面对着瀑布侃侃而谈",
|
||||
"publicationStatus": {
|
||||
"Published": []
|
||||
},
|
||||
"playCount": 1,
|
||||
"likeCount": 0,
|
||||
"remixCount": 1,
|
||||
"coverImageSrc": "/generated-puzzle-assets/puzzle-session-f38101d7277040fcb6fbc41fea8b714a/puzzle-session-f38101d7277040fcb6fbc41fea8b714a-candidate-2/asset-1777649330373133/image.png",
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777703338322544
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-002",
|
||||
"profileId": "profile-002",
|
||||
"ownerUserId": "user-002",
|
||||
"title": "我不知道",
|
||||
"subtitle": "你猜我是谁",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777619351714201
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "puzzle",
|
||||
"workId": "work-003",
|
||||
"profileId": "profile-003",
|
||||
"ownerUserId": "user-003",
|
||||
"title": "",
|
||||
"subtitle": "",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777622285252380
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "customWorld",
|
||||
"profileId": "profile-081",
|
||||
"ownerUserId": "user-002",
|
||||
"title": "青春飞扬校园",
|
||||
"subtitle": "反内卷的自由学习之旅",
|
||||
"publicationStatus": {
|
||||
"Draft": []
|
||||
},
|
||||
"playCount": 0,
|
||||
"likeCount": 0,
|
||||
"remixCount": 0,
|
||||
"updatedAt": {
|
||||
"__timestamp_micros_since_unix_epoch__": 1777532006629209
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"source": "spacetime-migration-7.local.json",
|
||||
"generatedAt": "2026-05-11T13:09:51.569Z",
|
||||
"source": "spacetime-migration-1.json",
|
||||
"generatedAt": "2026-05-18T11:54:04.280Z",
|
||||
"counts": {
|
||||
"puzzle_work_profile": 3,
|
||||
"custom_world_profile": 1,
|
||||
"match3d_work_profile": 0
|
||||
"match3d_work_profile": 0,
|
||||
"square_hole_work_profile": 0,
|
||||
"visual_novel_work_profile": 0
|
||||
},
|
||||
"tables": {
|
||||
"puzzle_work_profile": [
|
||||
@@ -113,7 +115,9 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"match3d_work_profile": []
|
||||
"match3d_work_profile": [],
|
||||
"square_hole_work_profile": [],
|
||||
"visual_novel_work_profile": []
|
||||
},
|
||||
"profileIds": {
|
||||
"puzzle": [
|
||||
|
||||
@@ -56,20 +56,22 @@ const scenarioOptions = {
|
||||
scenarios: {
|
||||
spike: {
|
||||
executor: 'ramping-arrival-rate',
|
||||
startRate: Number(__ENV.START_RPS || 5),
|
||||
preAllocatedVUs: Number(__ENV.PREALLOCATED_VUS || 50),
|
||||
maxVUs: Number(__ENV.MAX_VUS || 200),
|
||||
timeUnit: '1s',
|
||||
stages: [
|
||||
{ target: Number(__ENV.START_RPS || 5), duration: __ENV.RAMP_UP || '30s' },
|
||||
{ target: Number(__ENV.PEAK_RPS || 100), duration: __ENV.HOLD || '2m' },
|
||||
{ target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.RAMP_UP || '30s' },
|
||||
{ target: Number(__ENV.PEAK_RPS || 25), duration: __ENV.HOLD || '2m' },
|
||||
{ target: Number(__ENV.END_RPS || 5), duration: __ENV.RAMP_DOWN || '30s' },
|
||||
],
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.05'],
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<2000'],
|
||||
works_list_shape_error_rate: ['rate<0.05'],
|
||||
dropped_iterations: ['count==0'],
|
||||
works_list_shape_error_rate: ['rate<0.01'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -135,12 +137,12 @@ function unwrapPayload(json) {
|
||||
}
|
||||
|
||||
function hasCollection(payload, keys) {
|
||||
return keys.some((key) => Array.isArray(payload?.[key]));
|
||||
return Boolean(payload) && keys.some((key) => Array.isArray(payload[key]));
|
||||
}
|
||||
|
||||
function firstCollection(payload, keys) {
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(payload?.[key])) return payload[key];
|
||||
if (payload && Array.isArray(payload[key])) return payload[key];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -150,10 +152,11 @@ function hasListItemShape(payload, keys) {
|
||||
if (collection.length === 0) return true;
|
||||
const item = collection[0];
|
||||
const hasId = Boolean(
|
||||
item?.profileId || item?.profile_id || item?.workId || item?.work_id || item?.publicWorkCode,
|
||||
item &&
|
||||
(item.profileId || item.profile_id || item.workId || item.work_id || item.publicWorkCode),
|
||||
);
|
||||
const hasTitle = Boolean(
|
||||
item?.title || item?.workTitle || item?.work_title || item?.levelName || item?.worldName,
|
||||
item && (item.title || item.workTitle || item.work_title || item.levelName || item.worldName),
|
||||
);
|
||||
return hasId && hasTitle;
|
||||
}
|
||||
@@ -211,7 +214,8 @@ function performDetailRequest() {
|
||||
const payload = unwrapPayload(json);
|
||||
const ok = check(response, {
|
||||
[`${endpoint.name} status is 200`]: (res) => res.status === 200,
|
||||
[`${endpoint.name} has detail payload`]: () => endpoint.expectKeys.some((key) => payload?.[key]),
|
||||
[`${endpoint.name} has detail payload`]: () =>
|
||||
Boolean(payload) && endpoint.expectKeys.some((key) => payload[key]),
|
||||
});
|
||||
worksDetailShapeErrorRate.add(!ok, { endpoint: endpoint.name });
|
||||
}
|
||||
|
||||
151
scripts/prepare-server-provision-tools.sh
Executable file
151
scripts/prepare-server-provision-tools.sh
Executable 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
119
scripts/run-otelcol.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
import {mkdirSync, writeFileSync} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const [, , rawMode = 'debug', ...args] = process.argv;
|
||||
const mode = rawMode.trim();
|
||||
const printConfigOnly = args.includes('--print-config');
|
||||
|
||||
const supportedModes = new Set(['debug', 'rider']);
|
||||
if (!supportedModes.has(mode)) {
|
||||
console.error('[otelcol] mode must be one of: debug, rider');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const otlpHttpEndpoint = readEnv('OTELCOL_OTLP_HTTP_ENDPOINT', '127.0.0.1:4318');
|
||||
const otlpGrpcEndpoint = readEnv('OTELCOL_OTLP_GRPC_ENDPOINT', '127.0.0.1:4317');
|
||||
const riderEndpoint = readEnv('RIDER_OTLP_GRPC_ENDPOINT', '127.0.0.1:17011');
|
||||
const debugVerbosity = readEnv('OTELCOL_DEBUG_VERBOSITY', 'detailed');
|
||||
const otelcolBin = readEnv('OTELCOL_BIN', 'otelcol-contrib');
|
||||
|
||||
const configText = buildConfig(mode);
|
||||
const configDir = path.resolve('.codex-temp', 'otelcol');
|
||||
const configPath = path.join(configDir, `genarrative-${mode}.yaml`);
|
||||
mkdirSync(configDir, {recursive: true});
|
||||
writeFileSync(configPath, configText, 'utf8');
|
||||
|
||||
console.log(`[otelcol] wrote ${configPath}`);
|
||||
console.log(`[otelcol] receiving OTLP HTTP at http://${otlpHttpEndpoint}`);
|
||||
console.log(`[otelcol] receiving OTLP gRPC at ${otlpGrpcEndpoint}`);
|
||||
if (mode === 'rider') {
|
||||
console.log(`[otelcol] forwarding traces/metrics/logs to Rider OTLP gRPC at ${riderEndpoint}`);
|
||||
}
|
||||
console.log(
|
||||
'[otelcol] api-server env: GENARRATIVE_OTEL_ENABLED=true OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318'
|
||||
);
|
||||
|
||||
if (printConfigOnly) {
|
||||
console.log(configText);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const child = spawn(otelcolBin, ['--config', configPath], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const stopChild = () => {
|
||||
if (!child.killed) {
|
||||
child.kill();
|
||||
}
|
||||
};
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||||
process.on(signal, () => {
|
||||
stopChild();
|
||||
process.exit(130);
|
||||
});
|
||||
}
|
||||
|
||||
process.on('exit', stopChild);
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[otelcol] failed to start ${otelcolBin}: ${error.message}`);
|
||||
console.error('[otelcol] install otelcol-contrib and make sure it is on PATH, or set OTELCOL_BIN.');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
console.error(`[otelcol] exited by signal: ${signal}`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
function readEnv(key, fallback) {
|
||||
const value = process.env[key]?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function buildConfig(selectedMode) {
|
||||
const exporters =
|
||||
selectedMode === 'rider'
|
||||
? ` otlp/rider:
|
||||
endpoint: ${riderEndpoint}
|
||||
tls:
|
||||
insecure: true
|
||||
debug:
|
||||
verbosity: ${debugVerbosity}`
|
||||
: ` debug:
|
||||
verbosity: ${debugVerbosity}`;
|
||||
|
||||
const pipelineExporters = selectedMode === 'rider' ? '[otlp/rider, debug]' : '[debug]';
|
||||
|
||||
return `receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: ${otlpGrpcEndpoint}
|
||||
http:
|
||||
endpoint: ${otlpHttpEndpoint}
|
||||
|
||||
exporters:
|
||||
${exporters}
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
exporters: ${pipelineExporters}
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
exporters: ${pipelineExporters}
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
exporters: ${pipelineExporters}
|
||||
`;
|
||||
}
|
||||
188
server-rs/Cargo.lock
generated
188
server-rs/Cargo.lock
generated
@@ -105,6 +105,7 @@ dependencies = [
|
||||
"module-square-hole",
|
||||
"module-story",
|
||||
"module-visual-novel",
|
||||
"opentelemetry",
|
||||
"platform-agent",
|
||||
"platform-auth",
|
||||
"platform-llm",
|
||||
@@ -118,6 +119,7 @@ dependencies = [
|
||||
"shared-contracts",
|
||||
"shared-kernel",
|
||||
"shared-logging",
|
||||
"socket2 0.6.3",
|
||||
"spacetime-client",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -129,6 +131,7 @@ dependencies = [
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webp",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1761,6 +1764,7 @@ dependencies = [
|
||||
"platform-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -2070,6 +2074,90 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"js-sys",
|
||||
"pin-project-lite",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-appender-tracing"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"http 1.4.0",
|
||||
"opentelemetry",
|
||||
"reqwest 0.12.28",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-otlp"
|
||||
version = "0.31.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
"opentelemetry-proto",
|
||||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"reqwest 0.12.28",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"prost",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
"futures-util",
|
||||
"opentelemetry",
|
||||
"percent-encoding",
|
||||
"rand 0.9.4",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2151,6 +2239,26 @@ dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2320,6 +2428,29 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "3.7.2"
|
||||
@@ -2622,6 +2753,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
@@ -3036,6 +3168,12 @@ dependencies = [
|
||||
name = "shared-logging"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"opentelemetry-appender-tracing",
|
||||
"opentelemetry-otlp",
|
||||
"opentelemetry_sdk",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
@@ -3130,6 +3268,7 @@ dependencies = [
|
||||
"module-square-hole",
|
||||
"module-story",
|
||||
"module-visual-novel",
|
||||
"opentelemetry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-contracts",
|
||||
@@ -3137,6 +3276,7 @@ dependencies = [
|
||||
"spacetimedb-sdk",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3807,6 +3947,38 @@ version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio-stream",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-prost"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -3898,6 +4070,22 @@ dependencies = [
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-opentelemetry"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"opentelemetry",
|
||||
"smallvec",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
"tracing-subscriber",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
|
||||
@@ -100,6 +100,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
sha2 = "0.10"
|
||||
socket2 = "0.6"
|
||||
spacetimedb = "2.2.0"
|
||||
spacetimedb-sdk = "2.2.0"
|
||||
spacetimedb-lib = { version = "2.2.0", default-features = false }
|
||||
@@ -110,7 +111,13 @@ tokio-tungstenite = "0.27"
|
||||
tower = "0.5"
|
||||
tower-http = "0.6"
|
||||
tracing = "0.1"
|
||||
opentelemetry = "0.31"
|
||||
opentelemetry-appender-tracing = { version = "0.31", default-features = false, features = ["experimental_use_tracing_span_context"] }
|
||||
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["http-proto", "reqwest-blocking-client", "trace", "metrics", "logs"] }
|
||||
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics", "logs"] }
|
||||
tracing-opentelemetry = { version = "0.32", default-features = false }
|
||||
tracing-subscriber = "0.3"
|
||||
windows-sys = "0.61"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
uuid = "1"
|
||||
|
||||
@@ -11,6 +11,7 @@ base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png", "webp"] }
|
||||
http-body-util = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] }
|
||||
webp = { workspace = true }
|
||||
module-ai = { workspace = true }
|
||||
@@ -43,18 +44,23 @@ sha2 = { workspace = true }
|
||||
shared-contracts = { workspace = true, features = ["oss-contracts"] }
|
||||
shared-kernel = { workspace = true }
|
||||
shared-logging = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
spacetime-client = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] }
|
||||
tokio-stream = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
tower-http = { workspace = true, features = ["trace"] }
|
||||
tracing = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
zip = { workspace = true, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
use axum::Json;
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
http::{HeaderValue, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
#[cfg(test)]
|
||||
@@ -32,6 +41,30 @@ where
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn json_success_data_bytes_response(
|
||||
request_context: Option<&RequestContext>,
|
||||
data_json: Bytes,
|
||||
) -> Response {
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
let meta = serde_json::to_vec(&build_api_response_meta(Some(context)))
|
||||
.map(Bytes::from)
|
||||
.unwrap_or_else(|_| Bytes::from_static(b"null"));
|
||||
let chunks = [
|
||||
Bytes::from_static(b"{\"ok\":true,\"data\":"),
|
||||
data_json,
|
||||
Bytes::from_static(b",\"error\":null,\"meta\":"),
|
||||
meta,
|
||||
Bytes::from_static(b"}"),
|
||||
];
|
||||
let stream = stream::iter(chunks.into_iter().map(Ok::<Bytes, Infallible>));
|
||||
return json_body_response(Body::from_stream(stream));
|
||||
}
|
||||
|
||||
json_bytes_response(data_json)
|
||||
}
|
||||
|
||||
pub fn json_error_body(
|
||||
request_context: Option<&RequestContext>,
|
||||
error: &ApiErrorPayload,
|
||||
@@ -65,6 +98,19 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo
|
||||
)
|
||||
}
|
||||
|
||||
fn json_bytes_response(bytes: Bytes) -> Response {
|
||||
json_body_response(Body::from(bytes))
|
||||
}
|
||||
|
||||
fn json_body_response(body: Body) -> Response {
|
||||
let mut response = body.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -106,6 +152,31 @@ mod tests {
|
||||
assert!(body.get("meta").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_response_streams_cached_data_inside_standard_envelope() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
let request_context = build_request_context(true);
|
||||
let response = json_success_data_bytes_response(
|
||||
Some(&request_context),
|
||||
Bytes::from_static(br#"{"items":[]}"#),
|
||||
);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value = serde_json::from_slice(&body).expect("body should be json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["items"], Value::Array(Vec::new()));
|
||||
assert_eq!(
|
||||
payload["meta"]["requestId"],
|
||||
Value::String("req-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||
let request_context = build_request_context(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::Extension,
|
||||
extract::{Extension, FromRef},
|
||||
http::Request,
|
||||
middleware,
|
||||
response::Response,
|
||||
@@ -11,17 +11,19 @@ use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
trace::{DefaultOnRequest, TraceLayer},
|
||||
};
|
||||
use tracing::{Level, Span, error, info, info_span, warn};
|
||||
use tracing::{Level, Span, error, info_span};
|
||||
|
||||
use crate::{
|
||||
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
||||
backpressure::limit_concurrent_requests,
|
||||
creation_entry_config::require_creation_entry_route_enabled,
|
||||
error_middleware::normalize_error_response,
|
||||
modules,
|
||||
request_context::{RequestContext, attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
runtime_inventory::get_runtime_inventory_state,
|
||||
state::AppState,
|
||||
state::{AppState, BackpressureState},
|
||||
telemetry::record_http_observability,
|
||||
tracking::record_route_tracking_event_after_success,
|
||||
vector_engine_audio_generation::{
|
||||
create_background_music_task, create_sound_effect_task,
|
||||
@@ -42,8 +44,6 @@ use crate::{
|
||||
|
||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
||||
|
||||
Router::new()
|
||||
.merge(modules::admin::router(state.clone()))
|
||||
.merge(modules::health::router(state.clone()))
|
||||
@@ -77,6 +77,11 @@ pub fn build_router(state: AppState) -> Router {
|
||||
state.clone(),
|
||||
require_creation_entry_route_enabled,
|
||||
))
|
||||
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
BackpressureState::from_ref(&state),
|
||||
limit_concurrent_requests,
|
||||
))
|
||||
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
|
||||
@@ -86,47 +91,55 @@ pub fn build_router(state: AppState) -> Router {
|
||||
state.clone(),
|
||||
record_api_tracking_after_success,
|
||||
))
|
||||
// HTTP 指标与请求完成日志放在 tracing span 内侧,日志事件可以继承当前 trace/span context。
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
record_http_observability,
|
||||
))
|
||||
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<Body>| {
|
||||
let request_id =
|
||||
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
||||
let route = crate::telemetry::observability_route(request.uri().path());
|
||||
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
|
||||
let span_name = format!("{} {}", request.method(), route);
|
||||
|
||||
info_span!(
|
||||
"http.request",
|
||||
otel.kind = "server",
|
||||
otel.name = %span_name,
|
||||
otel.status_code = tracing::field::Empty,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
http.request.method = %request.method(),
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %request.uri().path(),
|
||||
request_id = %request_id,
|
||||
status = tracing::field::Empty,
|
||||
latency_ms = tracing::field::Empty,
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(
|
||||
move |response: &axum::response::Response,
|
||||
|response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
let slow_request = latency_ms >= slow_request_threshold_ms;
|
||||
span.record("status", status);
|
||||
span.record("latency_ms", latency_ms);
|
||||
if slow_request {
|
||||
warn!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = true,
|
||||
"http request completed slowly"
|
||||
);
|
||||
span.record("http.response.status_code", status);
|
||||
span.record(
|
||||
"otel.status_code",
|
||||
if response.status().is_server_error() {
|
||||
"ERROR"
|
||||
} else {
|
||||
info!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = false,
|
||||
"http request completed"
|
||||
"OK"
|
||||
},
|
||||
);
|
||||
}
|
||||
span.record("latency_ms", latency_ms);
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|
||||
@@ -752,10 +752,14 @@ mod tests {
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use http_body_util::BodyExt;
|
||||
use platform_auth::{
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use reqwest::{Method, multipart};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::new_uuid_simple_string;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
@@ -873,13 +877,17 @@ mod tests {
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
let app = build_router(AppState::new(config).expect("state should build"));
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let token =
|
||||
seed_authenticated_token(&state, "13800138120", "sess_assets_direct_upload").await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/assets/direct-upload-tickets")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-request-id", "req-oss-ticket")
|
||||
.header("x-genarrative-response-envelope", "1")
|
||||
@@ -1693,6 +1701,33 @@ mod tests {
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
async fn seed_authenticated_token(
|
||||
state: &AppState,
|
||||
phone_number: &str,
|
||||
session_seed: &str,
|
||||
) -> String {
|
||||
let user = state
|
||||
.seed_test_phone_user_with_password(phone_number, "secret123")
|
||||
.await;
|
||||
let claims = AccessTokenClaims::from_input(
|
||||
AccessTokenClaimsInput {
|
||||
user_id: user.id.clone(),
|
||||
session_id: state.seed_test_refresh_session_for_user(&user, session_seed),
|
||||
provider: AuthProvider::Password,
|
||||
roles: vec!["user".to_string()],
|
||||
token_version: user.token_version,
|
||||
phone_verified: true,
|
||||
binding_status: BindingStatus::Active,
|
||||
display_name: Some(user.display_name.clone()),
|
||||
},
|
||||
state.auth_jwt_config(),
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("claims should build");
|
||||
|
||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
||||
}
|
||||
|
||||
fn build_object_url(
|
||||
config: &AppConfig,
|
||||
object_key: &str,
|
||||
|
||||
481
server-rs/crates/api-server/src/backpressure.rs
Normal file
481
server-rs/crates/api-server/src/backpressure.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,19 @@ pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000
|
||||
pub struct AppConfig {
|
||||
pub bind_host: String,
|
||||
pub bind_port: u16,
|
||||
pub listen_backlog: i32,
|
||||
pub worker_threads: Option<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 otel_enabled: bool,
|
||||
pub admin_username: Option<String>,
|
||||
pub admin_password: Option<String>,
|
||||
pub admin_token_ttl_seconds: u64,
|
||||
@@ -147,7 +159,19 @@ impl Default for AppConfig {
|
||||
Self {
|
||||
bind_host: "127.0.0.1".to_string(),
|
||||
bind_port: 3000,
|
||||
listen_backlog: 1024,
|
||||
worker_threads: None,
|
||||
max_concurrent_requests: None,
|
||||
gallery_max_concurrent_requests: None,
|
||||
detail_max_concurrent_requests: None,
|
||||
admin_max_concurrent_requests: None,
|
||||
tracking_outbox_enabled: true,
|
||||
tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"),
|
||||
tracking_outbox_batch_size: 500,
|
||||
tracking_outbox_flush_interval: Duration::from_millis(1_000),
|
||||
tracking_outbox_max_bytes: 256 * 1024 * 1024,
|
||||
log_filter: "info,tower_http=info".to_string(),
|
||||
otel_enabled: false,
|
||||
admin_username: None,
|
||||
admin_password: None,
|
||||
admin_token_ttl_seconds: 4 * 60 * 60,
|
||||
@@ -164,11 +188,11 @@ impl Default for AppConfig {
|
||||
dev_password_entry_auto_register_enabled: false,
|
||||
sms_auth_enabled: false,
|
||||
sms_auth_provider: "mock".to_string(),
|
||||
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
||||
sms_endpoint: "dysmsapi.aliyuncs.com".to_string(),
|
||||
sms_access_key_id: None,
|
||||
sms_access_key_secret: None,
|
||||
sms_sign_name: "速通互联验证码".to_string(),
|
||||
sms_template_code: "100001".to_string(),
|
||||
sms_sign_name: "北京亓盒网络科技".to_string(),
|
||||
sms_template_code: "SMS_506245486".to_string(),
|
||||
sms_template_param_key: "code".to_string(),
|
||||
sms_country_code: "86".to_string(),
|
||||
sms_scheme_name: None,
|
||||
@@ -301,6 +325,57 @@ impl AppConfig {
|
||||
{
|
||||
config.log_filter = log_filter;
|
||||
}
|
||||
if let Some(listen_backlog) =
|
||||
read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"])
|
||||
{
|
||||
config.listen_backlog = listen_backlog;
|
||||
}
|
||||
if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) {
|
||||
config.worker_threads = Some(worker_threads);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.gallery_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.detail_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(max_concurrent_requests) =
|
||||
read_first_usize_env(&["GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS"])
|
||||
{
|
||||
config.admin_max_concurrent_requests = Some(max_concurrent_requests);
|
||||
}
|
||||
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) {
|
||||
config.tracking_outbox_enabled = enabled;
|
||||
}
|
||||
if let Some(dir) = read_first_non_empty_env(&["GENARRATIVE_TRACKING_OUTBOX_DIR"]) {
|
||||
config.tracking_outbox_dir = PathBuf::from(dir);
|
||||
}
|
||||
if let Some(batch_size) = read_first_usize_env(&["GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE"])
|
||||
{
|
||||
config.tracking_outbox_batch_size = batch_size;
|
||||
}
|
||||
if let Some(flush_interval_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS"])
|
||||
{
|
||||
config.tracking_outbox_flush_interval = Duration::from_millis(flush_interval_ms);
|
||||
}
|
||||
if let Some(max_bytes) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES"])
|
||||
{
|
||||
config.tracking_outbox_max_bytes = max_bytes;
|
||||
}
|
||||
if let Some(otel_enabled) = read_first_bool_env(&["GENARRATIVE_OTEL_ENABLED"]) {
|
||||
config.otel_enabled = otel_enabled;
|
||||
}
|
||||
|
||||
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
|
||||
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
|
||||
@@ -881,6 +956,14 @@ fn read_first_positive_u32_env(keys: &[&str]) -> Option<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> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -946,6 +1029,16 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
|
||||
}
|
||||
|
||||
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() {
|
||||
"1" | "true" | "yes" | "on" => Some(true),
|
||||
"0" | "false" | "no" | "off" => Some(false),
|
||||
@@ -971,6 +1064,15 @@ fn parse_positive_u32(raw: &str) -> Option<u32> {
|
||||
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> {
|
||||
raw.trim().parse::<u32>().ok()
|
||||
}
|
||||
@@ -1012,7 +1114,9 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider};
|
||||
use super::{
|
||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool,
|
||||
};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
@@ -1035,13 +1139,44 @@ mod tests {
|
||||
config.dashscope_base_url,
|
||||
"https://dashscope.aliyuncs.com/api/v1"
|
||||
);
|
||||
assert_eq!(config.sms_endpoint, "dypnsapi.aliyuncs.com");
|
||||
assert_eq!(config.sms_endpoint, "dysmsapi.aliyuncs.com");
|
||||
assert_eq!(config.sms_sign_name, "北京亓盒网络科技");
|
||||
assert_eq!(config.sms_template_code, "SMS_506245486");
|
||||
assert_eq!(config.sms_template_param_key, "code");
|
||||
assert_eq!(
|
||||
config.wechat_authorize_endpoint,
|
||||
"https://open.weixin.qq.com/connect/qrconnect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bool_accepts_wrapped_quotes_from_shell_env() {
|
||||
assert_eq!(parse_bool("\"true\""), Some(true));
|
||||
assert_eq!(parse_bool("'true'"), Some(true));
|
||||
assert_eq!(parse_bool("\"false\""), Some(false));
|
||||
assert_eq!(parse_bool("'off'"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("SMS_AUTH_ENABLED");
|
||||
std::env::set_var("SMS_AUTH_ENABLED", "\"true\"");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert!(config.sms_auth_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("SMS_AUTH_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_non_public_models_and_urls() {
|
||||
let _guard = ENV_LOCK
|
||||
@@ -1151,6 +1286,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_api_runtime_performance_settings() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock should not poison");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG");
|
||||
std::env::remove_var("GENARRATIVE_API_WORKER_THREADS");
|
||||
std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||
std::env::set_var("GENARRATIVE_API_LISTEN_BACKLOG", "2048");
|
||||
std::env::set_var("GENARRATIVE_API_WORKER_THREADS", "6");
|
||||
std::env::set_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS", "128");
|
||||
std::env::set_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS", "64");
|
||||
std::env::set_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS", "32");
|
||||
std::env::set_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS", "16");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_TRACKING_OUTBOX_DIR",
|
||||
"/tmp/genarrative-tracking-outbox",
|
||||
);
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576");
|
||||
std::env::set_var("GENARRATIVE_OTEL_ENABLED", "true");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
assert_eq!(config.listen_backlog, 2048);
|
||||
assert_eq!(config.worker_threads, Some(6));
|
||||
assert_eq!(config.max_concurrent_requests, Some(128));
|
||||
assert_eq!(config.gallery_max_concurrent_requests, Some(64));
|
||||
assert_eq!(config.detail_max_concurrent_requests, Some(32));
|
||||
assert_eq!(config.admin_max_concurrent_requests, Some(16));
|
||||
assert!(!config.tracking_outbox_enabled);
|
||||
assert_eq!(
|
||||
config.tracking_outbox_dir,
|
||||
std::path::PathBuf::from("/tmp/genarrative-tracking-outbox")
|
||||
);
|
||||
assert_eq!(config.tracking_outbox_batch_size, 250);
|
||||
assert_eq!(
|
||||
config.tracking_outbox_flush_interval,
|
||||
std::time::Duration::from_millis(2_000)
|
||||
);
|
||||
assert_eq!(config.tracking_outbox_max_bytes, 1_048_576);
|
||||
assert!(config.otel_enabled);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_API_LISTEN_BACKLOG");
|
||||
std::env::remove_var("GENARRATIVE_API_WORKER_THREADS");
|
||||
std::env::remove_var("GENARRATIVE_API_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||
std::env::remove_var("GENARRATIVE_OTEL_ENABLED");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_wechat_pay_settings() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
@@ -13,6 +13,7 @@ mod auth_payload;
|
||||
mod auth_public_user;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod backpressure;
|
||||
mod bark_battle;
|
||||
mod big_fish;
|
||||
mod big_fish_agent_turn;
|
||||
@@ -54,10 +55,12 @@ mod password_entry;
|
||||
mod password_management;
|
||||
mod phone_auth;
|
||||
mod platform_errors;
|
||||
mod process_metrics;
|
||||
mod profile_identity;
|
||||
mod prompt;
|
||||
mod puzzle;
|
||||
mod puzzle_agent_turn;
|
||||
mod puzzle_gallery_cache;
|
||||
mod refresh_session;
|
||||
mod registration_reward;
|
||||
mod request_context;
|
||||
@@ -75,7 +78,9 @@ mod square_hole_agent_turn;
|
||||
mod state;
|
||||
mod story_battles;
|
||||
mod story_sessions;
|
||||
mod telemetry;
|
||||
mod tracking;
|
||||
mod tracking_outbox;
|
||||
mod vector_engine_audio_generation;
|
||||
mod visual_novel;
|
||||
mod volcengine_speech;
|
||||
@@ -85,8 +90,15 @@ mod wechat_provider;
|
||||
mod work_author;
|
||||
mod work_play_tracking;
|
||||
|
||||
use shared_logging::init_tracing;
|
||||
use std::{collections::HashSet, env, fs, io, panic, thread, time::Duration};
|
||||
use shared_logging::{OtelConfig, init_tracing};
|
||||
use socket2::{Domain, Protocol, Socket, Type};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
env, fs, io,
|
||||
net::{SocketAddr, TcpListener as StdTcpListener},
|
||||
panic, thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tokio::time::timeout;
|
||||
@@ -103,12 +115,18 @@ fn main() -> Result<(), io::Error> {
|
||||
.name("api-server-bootstrap".to_string())
|
||||
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.spawn(|| {
|
||||
TokioRuntimeBuilder::new_multi_thread()
|
||||
load_local_env_files();
|
||||
let config = AppConfig::from_env();
|
||||
let mut runtime_builder = TokioRuntimeBuilder::new_multi_thread();
|
||||
runtime_builder
|
||||
.enable_all()
|
||||
.thread_name("api-server-worker")
|
||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||
.build()?
|
||||
.block_on(run_server())
|
||||
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES);
|
||||
if let Some(worker_threads) = config.worker_threads {
|
||||
runtime_builder.worker_threads(worker_threads);
|
||||
}
|
||||
|
||||
runtime_builder.build()?.block_on(run_server(config))
|
||||
})?;
|
||||
|
||||
match server_thread.join() {
|
||||
@@ -117,28 +135,55 @@ fn main() -> Result<(), io::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_server() -> Result<(), io::Error> {
|
||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量。
|
||||
// 只尊重外层 shell 先注入的变量;后续本地文件需要能覆盖前序本地文件。
|
||||
load_local_env_files();
|
||||
|
||||
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
|
||||
let config = AppConfig::from_env();
|
||||
init_tracing(&config.log_filter)?;
|
||||
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
init_tracing(
|
||||
&config.log_filter,
|
||||
OtelConfig {
|
||||
enabled: config.otel_enabled,
|
||||
},
|
||||
)?;
|
||||
process_metrics::register_process_metrics();
|
||||
telemetry::register_http_runtime_metrics();
|
||||
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listener = TcpListener::bind(bind_address).await?;
|
||||
let listen_backlog = config.listen_backlog;
|
||||
let worker_threads = config.worker_threads;
|
||||
let otel_enabled = config.otel_enabled;
|
||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
let router = build_router(state);
|
||||
|
||||
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");
|
||||
info!(
|
||||
%bind_address,
|
||||
listen_backlog,
|
||||
worker_threads = worker_threads.unwrap_or(0),
|
||||
otel_enabled,
|
||||
"api-server 已完成 tracing 初始化并开始监听"
|
||||
);
|
||||
|
||||
axum::serve(listener, router).await
|
||||
}
|
||||
|
||||
fn build_tcp_listener(
|
||||
bind_address: SocketAddr,
|
||||
listen_backlog: i32,
|
||||
) -> Result<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(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
881
server-rs/crates/api-server/src/match3d/draft.rs
Normal file
@@ -0,0 +1,881 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn submit_and_finalize_match3d_message(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
session_id: String,
|
||||
payload: SendMatch3DAgentMessageRequest,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&session_id,
|
||||
"sessionId",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&payload.client_message_id,
|
||||
"clientMessageId",
|
||||
)?;
|
||||
ensure_non_empty(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
&payload.text,
|
||||
"text",
|
||||
)?;
|
||||
|
||||
let submitted = state
|
||||
.spacetime_client()
|
||||
.submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
user_message_id: payload.client_message_id.clone(),
|
||||
user_message_text: payload.text.clone(),
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let next_turn = submitted.current_turn.saturating_add(1);
|
||||
let next_config = build_config_from_message(&submitted, &payload);
|
||||
let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn);
|
||||
let progress_percent = resolve_progress_percent_for_turn(next_turn);
|
||||
let stage = if progress_percent >= 100 {
|
||||
"ReadyToCompile"
|
||||
} else {
|
||||
"Collecting"
|
||||
}
|
||||
.to_string();
|
||||
|
||||
state
|
||||
.spacetime_client()
|
||||
.finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput {
|
||||
session_id,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)),
|
||||
assistant_reply_text: Some(assistant_reply),
|
||||
config_json: serialize_match3d_config(&next_config),
|
||||
progress_percent,
|
||||
stage,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
error_message: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn load_match3d_agent_session_response_with_persisted_assets(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session: Match3DAgentSessionRecord,
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else {
|
||||
return map_match3d_agent_session_response(session);
|
||||
};
|
||||
let assets =
|
||||
get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await;
|
||||
map_match3d_agent_session_response_with_assets(session, &assets)
|
||||
}
|
||||
|
||||
fn resolve_match3d_session_existing_profile_id(
|
||||
session: &Match3DAgentSessionRecord,
|
||||
) -> Option<String> {
|
||||
session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.or_else(|| {
|
||||
session
|
||||
.published_profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) async fn compile_match3d_draft_for_session(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
game_name: Option<String>,
|
||||
summary: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
cover_image_src: Option<String>,
|
||||
generate_click_sound: Option<bool>,
|
||||
) -> Result<(Match3DAgentSessionRecord, Vec<Match3DGeneratedItemAsset>), Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let initial_session = state
|
||||
.spacetime_client()
|
||||
.get_match3d_agent_session(session_id.clone(), owner_user_id.clone())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})?;
|
||||
let mut config = resolve_config_or_default(initial_session.config.as_ref());
|
||||
if let Some(generate_click_sound) = generate_click_sound {
|
||||
config.generate_click_sound = generate_click_sound;
|
||||
}
|
||||
// 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session
|
||||
// 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。
|
||||
let has_complete_form_config = !config.theme_text.trim().is_empty()
|
||||
&& config.clear_count > 0
|
||||
&& (1..=10).contains(&config.difficulty);
|
||||
if !has_complete_form_config
|
||||
&& (initial_session.current_turn < 3 || initial_session.progress_percent < 100)
|
||||
{
|
||||
return Err(match3d_bad_request(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
"match3d 创作配置尚未确认完成",
|
||||
));
|
||||
}
|
||||
|
||||
let requested_game_name = normalize_optional_match3d_text(game_name);
|
||||
let requested_summary = normalize_optional_match3d_text(summary);
|
||||
let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty());
|
||||
let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src);
|
||||
let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
let profile_id = resolve_match3d_draft_profile_id(&initial_session);
|
||||
let initial_game_name = requested_game_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.game_name.clone());
|
||||
let initial_tags = requested_tags
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_work_metadata.tags.clone());
|
||||
let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros());
|
||||
execute_billable_match3d_draft_generation(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id.as_str(),
|
||||
billing_asset_id.as_str(),
|
||||
async {
|
||||
let mut session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(initial_game_name),
|
||||
requested_summary.clone().or_else(|| Some(String::new())),
|
||||
Some(serde_json::to_string(&initial_tags).unwrap_or_default()),
|
||||
requested_cover_image_src.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if session.draft.is_none() {
|
||||
return Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await;
|
||||
let resolved_game_name = requested_game_name
|
||||
.unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone());
|
||||
let resolved_summary = requested_summary
|
||||
.clone()
|
||||
.unwrap_or_else(|| generated_work_metadata.metadata.summary.clone());
|
||||
let resolved_tags = match requested_tags {
|
||||
Some(tags) => tags,
|
||||
None => {
|
||||
generate_match3d_work_tags_for_plan(
|
||||
state,
|
||||
resolved_game_name.as_str(),
|
||||
config.theme_text.as_str(),
|
||||
resolved_summary.as_str(),
|
||||
&generated_work_metadata.metadata.tags,
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
generated_work_metadata.metadata.tags = resolved_tags.clone();
|
||||
session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session_id,
|
||||
owner_user_id.clone(),
|
||||
profile_id.clone(),
|
||||
Some(resolved_game_name),
|
||||
Some(resolved_summary),
|
||||
Some(serde_json::to_string(&resolved_tags).unwrap_or_default()),
|
||||
requested_cover_image_src.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let existing_assets = get_match3d_existing_generated_item_assets(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let generated_item_assets = generate_match3d_item_assets(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.items,
|
||||
existing_assets,
|
||||
)
|
||||
.await?;
|
||||
let generated_item_assets = ensure_match3d_background_asset(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
owner_user_id.as_str(),
|
||||
session.session_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
&config,
|
||||
generated_work_metadata.background_prompt.as_str(),
|
||||
generated_item_assets,
|
||||
)
|
||||
.await?;
|
||||
let existing_cover_image_src = get_match3d_existing_cover_image_src(
|
||||
state,
|
||||
owner_user_id.as_str(),
|
||||
profile_id.as_str(),
|
||||
)
|
||||
.await;
|
||||
let default_cover_image_src = requested_cover_image_src
|
||||
.clone()
|
||||
.or(existing_cover_image_src)
|
||||
.or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets));
|
||||
let next_session = upsert_match3d_draft_snapshot(
|
||||
state,
|
||||
request_context,
|
||||
authenticated,
|
||||
session.session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
profile_id,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
default_cover_image_src,
|
||||
None,
|
||||
serialize_match3d_generated_item_assets(&generated_item_assets),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((next_session, generated_item_assets))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。
|
||||
async fn execute_billable_match3d_draft_generation<T, Fut>(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, Response>
|
||||
where
|
||||
Fut: Future<Output = Result<T, Response>>,
|
||||
{
|
||||
let points_consumed = consume_match3d_draft_generation_points(
|
||||
state,
|
||||
request_context,
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match operation.await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(response) => {
|
||||
if points_consumed {
|
||||
refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id)
|
||||
.await;
|
||||
}
|
||||
Err(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn consume_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
) -> Result<bool, Response> {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_consume:{}:match3d_draft_generation:{}",
|
||||
owner_user_id, billing_asset_id
|
||||
);
|
||||
match state
|
||||
.spacetime_client()
|
||||
.consume_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
|
||||
tracing::warn!(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_asset_operation_wallet_error(error),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refund_match3d_draft_generation_points(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
billing_asset_id: &str,
|
||||
) {
|
||||
let ledger_id = format!(
|
||||
"asset_operation_refund:{}:match3d_draft_generation:{}",
|
||||
owner_user_id, billing_asset_id
|
||||
);
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.refund_profile_wallet_points(
|
||||
owner_user_id.to_string(),
|
||||
MATCH3D_DRAFT_GENERATION_POINTS_COST,
|
||||
ledger_id,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
owner_user_id,
|
||||
billing_asset_id,
|
||||
error = %error,
|
||||
"抓大鹅草稿生成失败后的泥点退款失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String {
|
||||
session
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|draft| draft.profile_id.trim())
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
.or_else(|| {
|
||||
session
|
||||
.published_profile_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|profile_id| !profile_id.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn upsert_match3d_draft_snapshot(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
profile_id: String,
|
||||
game_name: Option<String>,
|
||||
summary_text: Option<String>,
|
||||
tags_json: Option<String>,
|
||||
cover_image_src: Option<String>,
|
||||
cover_asset_id: Option<String>,
|
||||
generated_item_assets_json: Option<String>,
|
||||
) -> Result<Match3DAgentSessionRecord, Response> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.compile_match3d_draft(Match3DCompileDraftRecordInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
author_display_name: resolve_author_display_name(state, authenticated),
|
||||
game_name,
|
||||
summary_text,
|
||||
tags_json,
|
||||
cover_image_src,
|
||||
cover_asset_id,
|
||||
compiled_at_micros: current_utc_micros(),
|
||||
generated_item_assets_json,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
match3d_error_response(
|
||||
request_context,
|
||||
MATCH3D_AGENT_PROVIDER,
|
||||
map_match3d_client_error(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
pub(super) fn build_config_from_create_request(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
) -> Match3DConfigJson {
|
||||
Match3DConfigJson {
|
||||
theme_text: payload
|
||||
.theme_text
|
||||
.as_deref()
|
||||
.or(payload.seed_text.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(MATCH3D_DEFAULT_THEME)
|
||||
.to_string(),
|
||||
reference_image_src: payload.reference_image_src.clone(),
|
||||
clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT),
|
||||
difficulty: payload
|
||||
.difficulty
|
||||
.unwrap_or(MATCH3D_DEFAULT_DIFFICULTY)
|
||||
.clamp(1, 10),
|
||||
asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()),
|
||||
asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()),
|
||||
asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()),
|
||||
generate_click_sound: payload.generate_click_sound.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_config_from_message(
|
||||
session: &Match3DAgentSessionRecord,
|
||||
payload: &SendMatch3DAgentMessageRequest,
|
||||
) -> Match3DConfigJson {
|
||||
let current = resolve_config_or_default(session.config.as_ref());
|
||||
let text = payload.text.trim();
|
||||
let reference_image_src = payload
|
||||
.reference_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or(current.reference_image_src);
|
||||
let quick_fill_requested =
|
||||
payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置");
|
||||
|
||||
let mut theme_text = current.theme_text;
|
||||
let mut clear_count = current.clear_count.max(1);
|
||||
let mut difficulty = current.difficulty.clamp(1, 10);
|
||||
let asset_style_id = current.asset_style_id;
|
||||
let asset_style_label = current.asset_style_label;
|
||||
let asset_style_prompt = current.asset_style_prompt;
|
||||
let generate_click_sound = current.generate_click_sound;
|
||||
|
||||
match session.current_turn {
|
||||
0 => {
|
||||
theme_text = if quick_fill_requested {
|
||||
MATCH3D_DEFAULT_THEME.to_string()
|
||||
} else {
|
||||
parse_theme_answer(text).unwrap_or(theme_text)
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
clear_count = if quick_fill_requested {
|
||||
clear_count
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["消除", "次数", "clearCount"])
|
||||
.unwrap_or(clear_count)
|
||||
}
|
||||
.max(1);
|
||||
}
|
||||
_ => {
|
||||
difficulty = if quick_fill_requested {
|
||||
difficulty
|
||||
} else {
|
||||
parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty)
|
||||
}
|
||||
.clamp(1, 10);
|
||||
}
|
||||
}
|
||||
|
||||
Match3DConfigJson {
|
||||
theme_text,
|
||||
reference_image_src,
|
||||
clear_count,
|
||||
difficulty,
|
||||
asset_style_id,
|
||||
asset_style_label,
|
||||
asset_style_prompt,
|
||||
generate_click_sound,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson {
|
||||
config
|
||||
.map(|config| Match3DConfigJson {
|
||||
theme_text: config.theme_text.clone(),
|
||||
reference_image_src: config.reference_image_src.clone(),
|
||||
clear_count: config.clear_count.max(1),
|
||||
difficulty: config.difficulty.clamp(1, 10),
|
||||
asset_style_id: config.asset_style_id.clone(),
|
||||
asset_style_label: config.asset_style_label.clone(),
|
||||
asset_style_prompt: config.asset_style_prompt.clone(),
|
||||
generate_click_sound: config.generate_click_sound,
|
||||
})
|
||||
.unwrap_or_else(|| Match3DConfigJson {
|
||||
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_text(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option<String> {
|
||||
serde_json::to_string(config).ok()
|
||||
}
|
||||
|
||||
pub(super) fn build_seed_text(
|
||||
payload: &CreateMatch3DAgentSessionRequest,
|
||||
config: &Match3DConfigJson,
|
||||
) -> String {
|
||||
payload
|
||||
.seed_text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}题材,消除{}次,难度{}",
|
||||
config.theme_text, config.clear_count, config.difficulty
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String {
|
||||
format!(
|
||||
"已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。",
|
||||
config.theme_text,
|
||||
config.clear_count,
|
||||
config.clear_count.saturating_mul(3),
|
||||
config.difficulty
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String {
|
||||
match current_turn {
|
||||
0 => MATCH3D_QUESTION_THEME.to_string(),
|
||||
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),
|
||||
2 => MATCH3D_QUESTION_DIFFICULTY.to_string(),
|
||||
_ => build_match3d_assistant_reply(config),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 {
|
||||
match current_turn {
|
||||
0 => 0,
|
||||
1 => 33,
|
||||
2 => 66,
|
||||
_ => 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_theme_answer(text: &str) -> Option<String> {
|
||||
for marker in ["题材", "主题"] {
|
||||
if let Some((_, value)) = text.split_once(marker) {
|
||||
let normalized = value
|
||||
.trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace())
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_matches(['。', ',', ',', ';', ';'])
|
||||
.to_string();
|
||||
if !normalized.is_empty() {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
let trimmed = text.trim();
|
||||
if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option<u32> {
|
||||
for keyword in keywords {
|
||||
if let Some(index) = text.find(keyword) {
|
||||
let suffix = &text[index + keyword.len()..];
|
||||
if let Some(value) = first_positive_integer(suffix) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
first_positive_integer(text)
|
||||
}
|
||||
|
||||
fn first_positive_integer(text: &str) -> Option<u32> {
|
||||
let mut digits = String::new();
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_digit() {
|
||||
digits.push(ch);
|
||||
} else if !digits.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
digits.parse::<u32>().ok().filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_tags(tags: Vec<String>) -> Vec<String> {
|
||||
let mut result: Vec<String> = Vec::new();
|
||||
for tag in tags {
|
||||
let trimmed = normalize_match3d_tag(tag.as_str());
|
||||
if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) {
|
||||
result.push(trimmed);
|
||||
}
|
||||
if result.len() >= 6 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn normalize_optional_match3d_text(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
async fn generate_match3d_draft_plan(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Match3DGeneratedDraftPlan {
|
||||
let Some(llm_client) = state
|
||||
.creative_agent_gpt5_client()
|
||||
.or_else(|| state.llm_client())
|
||||
else {
|
||||
return fallback_match3d_draft_plan(config);
|
||||
};
|
||||
let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。";
|
||||
let gameplay_item_count = resolve_match3d_gameplay_item_count(config);
|
||||
let generated_item_count = resolve_match3d_generated_item_count(config);
|
||||
let user_prompt = format!(
|
||||
"题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。",
|
||||
config.theme_text, gameplay_item_count, generated_item_count
|
||||
);
|
||||
let response = llm_client
|
||||
.request_text(
|
||||
LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
])
|
||||
.with_model(MATCH3D_WORK_METADATA_LLM_MODEL)
|
||||
.with_responses_api(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config)
|
||||
.unwrap_or_else(|| fallback_match3d_draft_plan(config)),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = MATCH3D_AGENT_PROVIDER,
|
||||
theme_text = config.theme_text.as_str(),
|
||||
error = %error,
|
||||
"抓大鹅草稿生成计划失败,降级使用本地生成计划"
|
||||
);
|
||||
fallback_match3d_draft_plan(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_match3d_draft_plan(
|
||||
raw: &str,
|
||||
config: &Match3DConfigJson,
|
||||
) -> Option<Match3DGeneratedDraftPlan> {
|
||||
let raw = raw.trim();
|
||||
let json_text = if let Some(start) = raw.find('{')
|
||||
&& let Some(end) = raw.rfind('}')
|
||||
&& end > start
|
||||
{
|
||||
&raw[start..=end]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let value = serde_json::from_str::<Value>(json_text).ok()?;
|
||||
let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?);
|
||||
if game_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let tags = value
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str)))
|
||||
.unwrap_or_default();
|
||||
let fallback = fallback_match3d_draft_plan(config);
|
||||
let summary = value
|
||||
.get("summary")
|
||||
.or_else(|| value.get("description"))
|
||||
.or_else(|| value.get("workSummary"))
|
||||
.or_else(|| value.get("work_summary"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_work_summary)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback.metadata.summary);
|
||||
let items = value
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let name =
|
||||
normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let item_size = item
|
||||
.get("itemSize")
|
||||
.or_else(|| item.get("item_size"))
|
||||
.or_else(|| item.get("size"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_item_size)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| infer_match3d_item_size(&name));
|
||||
let sound_prompt = item
|
||||
.get("soundPrompt")
|
||||
.or_else(|| item.get("sound_prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_audio_prompt)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name));
|
||||
Some(Match3DGeneratedItemPlan {
|
||||
name,
|
||||
item_size,
|
||||
sound_prompt,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let background_prompt = value
|
||||
.get("backgroundPrompt")
|
||||
.or_else(|| value.get("background_prompt"))
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_match3d_background_prompt)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback.background_prompt);
|
||||
|
||||
Some(Match3DGeneratedDraftPlan {
|
||||
metadata: Match3DGeneratedWorkMetadata {
|
||||
game_name,
|
||||
summary,
|
||||
tags: normalize_match3d_tag_candidates(tags),
|
||||
},
|
||||
items: normalize_match3d_item_plan(config, items),
|
||||
background_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option<Match3DGeneratedWorkMetadata> {
|
||||
let config = Match3DConfigJson {
|
||||
theme_text: MATCH3D_DEFAULT_THEME.to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: MATCH3D_DEFAULT_CLEAR_COUNT,
|
||||
difficulty: MATCH3D_DEFAULT_DIFFICULTY,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
};
|
||||
parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata)
|
||||
}
|
||||
|
||||
fn normalize_match3d_game_name(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、'])
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(16)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_match3d_work_summary(raw: &str) -> String {
|
||||
raw.trim()
|
||||
.trim_matches(['"', '\'', '“', '”'])
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(80)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata {
|
||||
let theme = theme_text.trim();
|
||||
let normalized_theme = if theme.is_empty() { "主题" } else { theme };
|
||||
Match3DGeneratedWorkMetadata {
|
||||
game_name: format!("{normalized_theme}抓大鹅"),
|
||||
summary: normalize_match3d_work_summary(
|
||||
format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(),
|
||||
),
|
||||
tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan {
|
||||
let metadata = fallback_match3d_work_metadata(config.theme_text.as_str());
|
||||
let items = fallback_match3d_item_names(config.theme_text.as_str())
|
||||
.into_iter()
|
||||
.take(resolve_match3d_generated_item_count(config))
|
||||
.map(|name| Match3DGeneratedItemPlan {
|
||||
item_size: infer_match3d_item_size(&name),
|
||||
sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name),
|
||||
name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Match3DGeneratedDraftPlan {
|
||||
background_prompt: build_fallback_match3d_background_prompt(config),
|
||||
metadata,
|
||||
items,
|
||||
}
|
||||
}
|
||||
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
1406
server-rs/crates/api-server/src/match3d/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
2631
server-rs/crates/api-server/src/match3d/item_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let mut response = map_match3d_agent_session_response(session);
|
||||
if let Some(draft) = response.draft.as_mut() {
|
||||
if generated_item_assets.is_empty() {
|
||||
return response;
|
||||
}
|
||||
|
||||
draft.generated_item_assets = generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
|
||||
pub(super) fn map_match3d_draft_response(
|
||||
draft: Match3DResultDraftRecord,
|
||||
) -> 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,
|
||||
game_name: draft.game_name,
|
||||
theme_text: draft.theme_text,
|
||||
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
|
||||
background_image_src: None,
|
||||
background_image_object_key: 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(
|
||||
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
|
||||
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(
|
||||
message: Match3DAgentMessageRecord,
|
||||
) -> Match3DAgentMessageResponse {
|
||||
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
let generated_item_asset_json =
|
||||
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 generation_status = resolve_match3d_work_generation_status(
|
||||
&item,
|
||||
&generated_item_asset_json,
|
||||
background_asset.as_ref(),
|
||||
);
|
||||
let generated_background_asset = background_asset
|
||||
.clone()
|
||||
.map(map_match3d_background_asset_for_work);
|
||||
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||
background_image_src: background_asset
|
||||
.as_ref()
|
||||
|
||||
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
37
server-rs/crates/api-server/src/match3d/runtime.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
pub(super) fn normalize_match3d_run_status(value: &str) -> &str {
|
||||
match value {
|
||||
"Running" => "running",
|
||||
"Won" => "won",
|
||||
"Failed" => "failed",
|
||||
"Stopped" => "stopped",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_item_state(value: &str) -> &str {
|
||||
match value {
|
||||
"InBoard" => "in_board",
|
||||
"InTray" => "in_tray",
|
||||
"Cleared" => "cleared",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str {
|
||||
match value {
|
||||
"TimeUp" => "time_up",
|
||||
"TrayFull" => "tray_full",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str {
|
||||
match value {
|
||||
"RejectedNotClickable" => "item_not_clickable",
|
||||
"RejectedAlreadyMoved" => "item_not_in_board",
|
||||
"RejectedTrayFull" => "tray_full",
|
||||
"VersionConflict" => "snapshot_version_mismatch",
|
||||
"RunFinished" => "run_not_active",
|
||||
_ => value,
|
||||
}
|
||||
}
|
||||
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
1875
server-rs/crates/api-server/src/match3d/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
483
server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_match3d_material_sheet(
|
||||
state: &AppState,
|
||||
config: &Match3DConfigJson,
|
||||
item_names: &[String],
|
||||
) -> Result<Match3DMaterialSheet, AppError> {
|
||||
let settings = require_match3d_vector_engine_gemini_image_settings(state)?;
|
||||
let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?;
|
||||
let prompt = build_match3d_material_sheet_prompt(config, item_names);
|
||||
let negative_prompt = build_match3d_material_sheet_negative_prompt(config);
|
||||
let generated = create_match3d_vector_engine_gemini_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
prompt.as_str(),
|
||||
negative_prompt.as_str(),
|
||||
"抓大鹅素材图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅素材图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DMaterialSheet {
|
||||
task_id: generated.task_id,
|
||||
image,
|
||||
})
|
||||
}
|
||||
|
||||
fn require_match3d_vector_engine_gemini_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<Match3DVectorEngineGeminiImageSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"reason": "VECTOR_ENGINE_API_KEY 未配置",
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(Match3DVectorEngineGeminiImageSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_image_http_client(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_match3d_vector_engine_gemini_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let request_body = build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt,
|
||||
negative_prompt,
|
||||
MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO,
|
||||
);
|
||||
let response = http_client
|
||||
.post(build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings,
|
||||
))
|
||||
.query(&[("key", settings.api_key.as_str())])
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_match3d_vector_engine_gemini_image_request_error(format!(
|
||||
"{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
status,
|
||||
response_text.as_str(),
|
||||
failure_context,
|
||||
));
|
||||
}
|
||||
|
||||
let payload = parse_match3d_json_payload(
|
||||
response_text.as_str(),
|
||||
"解析抓大鹅 VectorEngine Gemini 图片生成响应失败",
|
||||
"vector-engine-gemini",
|
||||
)?;
|
||||
let image_urls = extract_match3d_image_urls(&payload);
|
||||
if !image_urls.is_empty() {
|
||||
return download_match3d_images_from_urls(
|
||||
http_client,
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
image_urls,
|
||||
1,
|
||||
"vector-engine-gemini",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let b64_images = extract_match3d_b64_images(&payload);
|
||||
if !b64_images.is_empty() {
|
||||
return Ok(match3d_images_from_base64(
|
||||
format!("vector-engine-gemini-{}", current_utc_micros()),
|
||||
b64_images,
|
||||
1,
|
||||
));
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片",
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_image_request_body(
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
aspect_ratio: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"contents": [{
|
||||
"role": "user",
|
||||
"parts": [{
|
||||
"text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt),
|
||||
}],
|
||||
}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["TEXT", "IMAGE"],
|
||||
"imageConfig": {
|
||||
"aspectRatio": aspect_ratio,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_match3d_vector_engine_gemini_generate_content_url(
|
||||
settings: &Match3DVectorEngineGeminiImageSettings,
|
||||
) -> String {
|
||||
let base_url = settings.base_url.trim_end_matches("/v1");
|
||||
format!(
|
||||
"{}/v1beta/models/{}:generateContent",
|
||||
base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL
|
||||
)
|
||||
}
|
||||
|
||||
fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String {
|
||||
let prompt = prompt.trim();
|
||||
let negative_prompt = negative_prompt.trim();
|
||||
if negative_prompt.is_empty() {
|
||||
return prompt.to_string();
|
||||
}
|
||||
|
||||
format!("{prompt}\n避免:{negative_prompt}")
|
||||
}
|
||||
|
||||
async fn download_match3d_images_from_urls(
|
||||
http_client: &reqwest::Client,
|
||||
task_id: String,
|
||||
image_urls: Vec<String>,
|
||||
candidate_count: u32,
|
||||
provider: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
{
|
||||
images
|
||||
.push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?);
|
||||
}
|
||||
Ok(OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_match3d_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
provider: &str,
|
||||
) -> Result<DownloadedOpenAiImage, AppError> {
|
||||
let response = http_client.get(image_url).send().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("下载抓大鹅生成图片失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("读取抓大鹅生成图片内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": "下载抓大鹅生成图片失败",
|
||||
"status": status.as_u16(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str());
|
||||
Ok(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes: body.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_images_from_base64(
|
||||
task_id: String,
|
||||
b64_images: Vec<String>,
|
||||
candidate_count: u32,
|
||||
) -> OpenAiGeneratedImages {
|
||||
let images = b64_images
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 4) as usize)
|
||||
.filter_map(|raw| decode_match3d_base64_image(raw.as_str()))
|
||||
.collect();
|
||||
OpenAiGeneratedImages {
|
||||
task_id,
|
||||
actual_prompt: None,
|
||||
images,
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_match3d_base64_image(raw: &str) -> Option<DownloadedOpenAiImage> {
|
||||
let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?;
|
||||
let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string();
|
||||
Some(DownloadedOpenAiImage {
|
||||
extension: match3d_mime_to_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_match3d_json_payload(
|
||||
raw_text: &str,
|
||||
failure_context: &str,
|
||||
provider: &str,
|
||||
) -> Result<Value, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": format!("{failure_context}:{error}"),
|
||||
"rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_match3d_image_urls(payload: &Value) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "url", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image", &mut urls);
|
||||
collect_match3d_strings_by_key(payload, "image_url", &mut urls);
|
||||
let mut deduped = Vec::new();
|
||||
for url in urls {
|
||||
if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) {
|
||||
deduped.push(url);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec<String> {
|
||||
let mut values = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, "b64_json", &mut values);
|
||||
collect_match3d_inline_image_data(payload, &mut values);
|
||||
values
|
||||
}
|
||||
|
||||
fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_match3d_inline_image_data(entry, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for key in ["inlineData", "inline_data"] {
|
||||
if let Some(Value::Object(inline_data)) = object.get(key) {
|
||||
let mime_type = inline_data
|
||||
.get("mimeType")
|
||||
.or_else(|| inline_data.get("mime_type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png")
|
||||
.to_ascii_lowercase();
|
||||
if !mime_type.is_empty() && !mime_type.starts_with("image/") {
|
||||
continue;
|
||||
}
|
||||
if let Some(data) = inline_data
|
||||
.get("data")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(data.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for nested_value in object.values() {
|
||||
collect_match3d_inline_image_data(nested_value, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
|
||||
let mut results = Vec::new();
|
||||
collect_match3d_strings_by_key(payload, target_key, &mut results);
|
||||
results.into_iter().next()
|
||||
}
|
||||
|
||||
fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
|
||||
match payload {
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
collect_match3d_strings_by_key(entry, target_key, results);
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
for (key, nested_value) in object {
|
||||
if key == target_key {
|
||||
match nested_value {
|
||||
Value::String(text) => {
|
||||
let text = text.trim();
|
||||
if !text.is_empty() {
|
||||
results.push(text.to_string());
|
||||
}
|
||||
}
|
||||
Value::Array(entries) => {
|
||||
for entry in entries {
|
||||
if let Some(text) = entry
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
results.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
collect_match3d_strings_by_key(nested_value, target_key, results);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_match3d_vector_engine_gemini_image_upstream_error(
|
||||
upstream_status: reqwest::StatusCode,
|
||||
raw_text: &str,
|
||||
fallback_message: &str,
|
||||
) -> AppError {
|
||||
let message = parse_match3d_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800);
|
||||
tracing::warn!(
|
||||
provider = "vector-engine-gemini",
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"抓大鹅 VectorEngine Gemini 图片生成上游请求失败"
|
||||
);
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "vector-engine-gemini",
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String {
|
||||
let trimmed = raw_text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return fallback_message.to_string();
|
||||
}
|
||||
if let Ok(payload) = serde_json::from_str::<Value>(trimmed) {
|
||||
for key in ["message", "code"] {
|
||||
if let Some(value) = find_first_match3d_string_by_key(&payload, key) {
|
||||
return if key == "message" {
|
||||
value
|
||||
} else {
|
||||
format!("{fallback_message}({value})")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String {
|
||||
raw_text.chars().take(max_chars).collect()
|
||||
}
|
||||
|
||||
fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String {
|
||||
let mime_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.map(str::trim)
|
||||
.unwrap_or("image/png");
|
||||
match mime_type {
|
||||
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
|
||||
mime_type.to_string()
|
||||
}
|
||||
_ => "image/png".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str {
|
||||
match mime_type {
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"image/jpeg" | "image/jpg" => "jpg",
|
||||
_ => "png",
|
||||
}
|
||||
}
|
||||
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
1254
server-rs/crates/api-server/src/match3d/works.rs
Normal file
File diff suppressed because it is too large
Load Diff
493
server-rs/crates/api-server/src/process_metrics.rs
Normal file
493
server-rs/crates/api-server/src/process_metrics.rs
Normal 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
1943
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
1943
server-rs/crates/api-server/src/puzzle/draft.rs
Normal file
File diff suppressed because it is too large
Load Diff
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
264
server-rs/crates/api-server/src/puzzle/generation.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||||
if error.code() == "UPSTREAM_ERROR" {
|
||||
let body_text = error.body_text();
|
||||
return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": format!("拼图图片生成失败:{body_text}"),
|
||||
}));
|
||||
}
|
||||
|
||||
error
|
||||
}
|
||||
|
||||
pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||||
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||||
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_image_candidates(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
reference_image_src: Option<&str>,
|
||||
use_reference_image_edit: bool,
|
||||
image_model: Option<&str>,
|
||||
candidate_count: u32,
|
||||
candidate_start_index: usize,
|
||||
) -> Result<Vec<GeneratedPuzzleImageCandidate>, AppError> {
|
||||
let total_started_at = Instant::now();
|
||||
let count = candidate_count.clamp(1, 1);
|
||||
let resolved_model = resolve_puzzle_image_model(image_model);
|
||||
let http_client = build_puzzle_image_http_client(state, resolved_model)?;
|
||||
let has_reference_image = has_puzzle_reference_image(reference_image_src);
|
||||
let should_use_reference_image_edit =
|
||||
should_use_puzzle_reference_image_edit(reference_image_src, use_reference_image_edit);
|
||||
let actual_prompt = build_puzzle_vector_engine_generation_prompt(
|
||||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||
should_use_reference_image_edit,
|
||||
);
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
prompt_chars = prompt.chars().count(),
|
||||
actual_prompt_chars = actual_prompt.chars().count(),
|
||||
has_reference_image,
|
||||
use_reference_image_edit = should_use_reference_image_edit,
|
||||
"拼图图片生成请求已准备"
|
||||
);
|
||||
let reference_image_started_at = Instant::now();
|
||||
let reference_image = match reference_image_src
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|_| should_use_reference_image_edit)
|
||||
{
|
||||
Some(source) => {
|
||||
let resolved =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
reference_mime = %resolved.mime_type,
|
||||
reference_bytes = resolved.bytes_len,
|
||||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||
"拼图参考图解析完成"
|
||||
);
|
||||
Some(resolved)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if !should_use_reference_image_edit {
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
has_reference_image,
|
||||
use_reference_image_edit = should_use_reference_image_edit,
|
||||
elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64,
|
||||
"拼图参考图解析跳过"
|
||||
);
|
||||
}
|
||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。
|
||||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||
let settings = require_puzzle_vector_engine_settings(state)?;
|
||||
let vector_engine_started_at = Instant::now();
|
||||
let generated = if should_use_reference_image_edit {
|
||||
let reference_image = reference_image.as_ref().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "AI 重绘需要提供参考图。",
|
||||
}))
|
||||
})?;
|
||||
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
reference_image,
|
||||
)
|
||||
.await;
|
||||
match edit_result {
|
||||
Ok(generated) => Ok(generated),
|
||||
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||||
tracing::warn!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
reference_mime = %reference_image.mime_type,
|
||||
reference_bytes = reference_image.bytes_len,
|
||||
error = %error,
|
||||
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||||
);
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
resolved_model,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
Some(reference_image),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
} else {
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
resolved_model,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
generated_image_count = generated.images.len(),
|
||||
elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64,
|
||||
"拼图 VectorEngine 生图与下载完成"
|
||||
);
|
||||
let mut items = Vec::with_capacity(generated.images.len());
|
||||
|
||||
for (index, image) in generated.images.into_iter().enumerate() {
|
||||
let candidate_id = format!(
|
||||
"{session_id}-candidate-{}",
|
||||
candidate_start_index + index + 1
|
||||
);
|
||||
let downloaded_image = image.clone();
|
||||
let persist_started_at = Instant::now();
|
||||
let asset = persist_puzzle_generated_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
level_name,
|
||||
candidate_id.as_str(),
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_puzzle_generation_endpoint_error)?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
candidate_id = %candidate_id,
|
||||
image_bytes = downloaded_image.bytes.len(),
|
||||
image_mime = %downloaded_image.mime_type,
|
||||
elapsed_ms = persist_started_at.elapsed().as_millis() as u64,
|
||||
"拼图生成图片已写入 OSS 与资产索引"
|
||||
);
|
||||
items.push(GeneratedPuzzleImageCandidate {
|
||||
record: PuzzleGeneratedImageCandidateRecord {
|
||||
candidate_id,
|
||||
image_src: asset.image_src,
|
||||
asset_id: asset.asset_id,
|
||||
prompt: prompt.to_string(),
|
||||
actual_prompt: Some(actual_prompt.clone()),
|
||||
source_type: resolved_model.candidate_source_type().to_string(),
|
||||
// 单图生成结果总是直接成为当前正式图。
|
||||
selected: index == 0,
|
||||
},
|
||||
downloaded_image,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
candidate_count = items.len(),
|
||||
has_reference_image,
|
||||
elapsed_ms = total_started_at.elapsed().as_millis() as u64,
|
||||
"拼图图片候选生成完成"
|
||||
);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt).as_str(),
|
||||
Some("文字、水印、按钮文字、数字、教程浮层、拼图碎片、完整拼图图像、拼图槽、棋盘、拼图区边框、物品槽、HUD、角色手指"),
|
||||
"9:16",
|
||||
1,
|
||||
&[],
|
||||
"拼图 UI 背景图生成失败",
|
||||
)
|
||||
.await?;
|
||||
let image = generated.images.into_iter().next().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"message": "拼图 UI 背景图生成失败:未返回图片",
|
||||
}))
|
||||
})?;
|
||||
persist_puzzle_ui_background_image(
|
||||
state,
|
||||
owner_user_id,
|
||||
session_id,
|
||||
level_name,
|
||||
generated.task_id.as_str(),
|
||||
image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_puzzle_ui_background_request_prompt_for_test(
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> String {
|
||||
build_puzzle_ui_background_generation_prompt(level_name, prompt)
|
||||
}
|
||||
2044
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
2044
server-rs/crates/api-server/src/puzzle/handlers.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,7 @@ pub(super) fn map_puzzle_form_draft_response(
|
||||
pub(super) fn map_puzzle_draft_level_response(
|
||||
level: PuzzleDraftLevelRecord,
|
||||
) -> PuzzleDraftLevelResponse {
|
||||
let generation_status = resolve_puzzle_level_generation_status(&level);
|
||||
PuzzleDraftLevelResponse {
|
||||
level_id: level.level_id,
|
||||
level_name: level.level_name,
|
||||
@@ -115,7 +116,7 @@ pub(super) fn map_puzzle_draft_level_response(
|
||||
selected_candidate_id: level.selected_candidate_id,
|
||||
cover_image_src: level.cover_image_src,
|
||||
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(
|
||||
state: &AppState,
|
||||
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 {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
@@ -316,6 +428,7 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status: item.generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
898
server-rs/crates/api-server/src/puzzle/tests.rs
Normal file
898
server-rs/crates/api-server/src/puzzle/tests.rs
Normal 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));
|
||||
}
|
||||
1283
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
1283
server-rs/crates/api-server/src/puzzle/vector_engine.rs
Normal file
File diff suppressed because it is too large
Load Diff
252
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal file
252
server-rs/crates/api-server/src/puzzle_gallery_cache.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||
use module_auth::{
|
||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||
@@ -27,20 +28,126 @@ use shared_contracts::creation_entry_config::CreationEntryConfigResponse;
|
||||
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
|
||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
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_provider::build_wechat_provider;
|
||||
|
||||
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)]
|
||||
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 state;AppState 外层必须保持浅拷贝。
|
||||
#[derive(Debug)]
|
||||
pub struct AppStateInner {
|
||||
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
http_request_permit_pools: HttpRequestPermitPools,
|
||||
auth_jwt_config: JwtConfig,
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
refresh_cookie_config: RefreshCookieConfig,
|
||||
@@ -60,6 +167,8 @@ pub struct AppState {
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
ai_task_service: AiTaskService,
|
||||
spacetime_client: SpacetimeClient,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||
llm_client: Option<LlmClient>,
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||
@@ -190,11 +299,14 @@ impl AppState {
|
||||
pool_size: config.spacetime_pool_size,
|
||||
procedure_timeout: config.spacetime_procedure_timeout,
|
||||
});
|
||||
let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone());
|
||||
let llm_client = build_llm_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,
|
||||
http_request_permit_pools,
|
||||
auth_jwt_config,
|
||||
admin_runtime,
|
||||
refresh_cookie_config,
|
||||
@@ -214,13 +326,15 @@ impl AppState {
|
||||
wechat_pay_client,
|
||||
ai_task_service,
|
||||
spacetime_client,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache::new(),
|
||||
tracking_outbox,
|
||||
llm_client,
|
||||
creative_agent_gpt5_client,
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
#[cfg(test)]
|
||||
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||
})
|
||||
})))
|
||||
}
|
||||
|
||||
pub fn auth_jwt_config(&self) -> &JwtConfig {
|
||||
@@ -235,6 +349,10 @@ impl AppState {
|
||||
&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(
|
||||
&self,
|
||||
input: module_runtime::CreationEntryTypeAdminUpsertInput,
|
||||
@@ -464,6 +582,14 @@ impl AppState {
|
||||
&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> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
512
server-rs/crates/api-server/src/telemetry.rs
Normal file
512
server-rs/crates/api-server/src/telemetry.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ pub async fn record_route_tracking_event_after_success(
|
||||
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> {
|
||||
@@ -524,26 +524,101 @@ pub async fn record_tracking_event_after_success(
|
||||
request_context: &RequestContext,
|
||||
draft: TrackingEventDraft,
|
||||
) {
|
||||
let occurred_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
|
||||
let event_id = build_tracking_event_id(&draft, occurred_at_micros);
|
||||
let event_key = draft.event_key.to_string();
|
||||
let scope_kind = draft.scope_kind;
|
||||
let scope_id = draft.scope_id;
|
||||
let metadata_json = draft.metadata.to_string();
|
||||
record_tracking_event_input_after_success(
|
||||
state,
|
||||
request_context,
|
||||
build_tracking_event_input(draft),
|
||||
)
|
||||
.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
|
||||
.spacetime_client()
|
||||
.record_tracking_event(
|
||||
event_id,
|
||||
event_key.clone(),
|
||||
scope_kind,
|
||||
scope_id.clone(),
|
||||
draft.user_id,
|
||||
draft.owner_user_id,
|
||||
draft.profile_id,
|
||||
draft.module_key.map(str::to_string),
|
||||
procedure_event_key,
|
||||
procedure_scope_kind,
|
||||
procedure_scope_id,
|
||||
user_id,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
module_key,
|
||||
metadata_json,
|
||||
occurred_at_micros as i64,
|
||||
occurred_at_micros,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -551,7 +626,7 @@ pub async fn record_tracking_event_after_success(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_kind = %log_scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
"后端埋点已记录"
|
||||
),
|
||||
@@ -559,7 +634,7 @@ pub async fn record_tracking_event_after_success(
|
||||
request_id = request_context.request_id(),
|
||||
operation = request_context.operation(),
|
||||
event_key = %event_key,
|
||||
scope_kind = %scope_kind.as_str(),
|
||||
scope_kind = %log_scope_kind.as_str(),
|
||||
scope_id = %scope_id,
|
||||
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 {
|
||||
if draft.event_key == "daily_login"
|
||||
&& draft.scope_kind == RuntimeTrackingScopeKind::User
|
||||
|
||||
621
server-rs/crates/api-server/src/tracking_outbox.rs
Normal file
621
server-rs/crates/api-server/src/tracking_outbox.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ platform-auth = { workspace = true }
|
||||
shared-kernel = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting", "parsing"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
|
||||
@@ -18,10 +18,11 @@ use std::{
|
||||
};
|
||||
|
||||
use platform_auth::{
|
||||
SmsAuthProvider, SmsProviderError, SmsSendCodeRequest, SmsVerifyCodeRequest, hash_password,
|
||||
SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password,
|
||||
verify_password,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_kernel::{
|
||||
build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string,
|
||||
normalize_optional_string, normalize_required_string, parse_rfc3339,
|
||||
@@ -77,6 +78,7 @@ struct StoredRefreshSession {
|
||||
struct StoredPhoneCode {
|
||||
phone_number: String,
|
||||
scene: PhoneAuthScene,
|
||||
verify_code_hash: String,
|
||||
expires_at: String,
|
||||
last_sent_at: String,
|
||||
failed_attempts: u32,
|
||||
@@ -117,6 +119,7 @@ pub struct AuthUserService {
|
||||
pub struct PhoneAuthService {
|
||||
store: InMemoryAuthStore,
|
||||
sms_provider: SmsAuthProvider,
|
||||
verify_code_salt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -431,6 +434,7 @@ impl PhoneAuthService {
|
||||
Self {
|
||||
store,
|
||||
sms_provider,
|
||||
verify_code_salt: new_uuid_simple_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +446,7 @@ impl PhoneAuthService {
|
||||
let scene = input.scene.clone();
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
|
||||
let verify_code = self.generate_phone_verify_code();
|
||||
info!(
|
||||
scene = scene.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| {
|
||||
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
|
||||
.sms_provider
|
||||
.send_code(SmsSendCodeRequest {
|
||||
national_phone_number,
|
||||
scene: input.scene.as_str().to_string(),
|
||||
verify_code,
|
||||
})
|
||||
.await
|
||||
.map_err(map_sms_provider_error_to_phone_error)?;
|
||||
@@ -488,6 +500,7 @@ impl PhoneAuthService {
|
||||
StoredPhoneCode {
|
||||
phone_number: normalized_phone.e164.clone(),
|
||||
scene,
|
||||
verify_code_hash,
|
||||
expires_at,
|
||||
last_sent_at: format_rfc3339(now).map_err(|message| {
|
||||
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
|
||||
@@ -516,28 +529,12 @@ impl PhoneAuthService {
|
||||
) -> Result<PhoneLoginResult, PhoneAuthError> {
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
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,
|
||||
&PhoneAuthScene::Login,
|
||||
&input.verify_code,
|
||||
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
|
||||
.store
|
||||
@@ -582,30 +579,12 @@ impl PhoneAuthService {
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
verify_sms_code_format(&input.verify_code)?;
|
||||
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,
|
||||
&PhoneAuthScene::ResetPassword,
|
||||
&input.verify_code,
|
||||
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
|
||||
.find_by_phone_number(&normalized_phone.e164)?
|
||||
@@ -632,28 +611,12 @@ impl PhoneAuthService {
|
||||
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
||||
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
|
||||
verify_sms_code_format(&input.verify_code)?;
|
||||
let provider_out_id = self.store.assert_phone_code_active(
|
||||
self.verify_phone_code(
|
||||
&normalized_phone.e164,
|
||||
&PhoneAuthScene::BindPhone,
|
||||
&input.verify_code,
|
||||
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
|
||||
.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(
|
||||
&self,
|
||||
input: BindWechatVerifiedPhoneInput,
|
||||
@@ -1518,12 +1510,12 @@ impl InMemoryAuthStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_phone_code_active(
|
||||
fn get_active_phone_code(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
scene: &PhoneAuthScene,
|
||||
now: OffsetDateTime,
|
||||
) -> Result<Option<String>, PhoneAuthError> {
|
||||
) -> Result<StoredPhoneCode, PhoneAuthError> {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -1543,7 +1535,7 @@ impl InMemoryAuthStore {
|
||||
state.phone_codes_by_key.remove(&key);
|
||||
return Err(PhoneAuthError::VerifyCodeExpired);
|
||||
}
|
||||
Ok(stored.provider_out_id)
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
fn consume_phone_code_success(
|
||||
@@ -2069,6 +2061,7 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr
|
||||
SmsProviderError::InvalidConfig(message) => {
|
||||
PhoneAuthError::SmsProviderInvalidConfig(message)
|
||||
}
|
||||
SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode,
|
||||
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> {
|
||||
format_shared_rfc3339(value)
|
||||
}
|
||||
@@ -2655,6 +2678,14 @@ mod tests {
|
||||
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]
|
||||
async fn phone_login_expires_code_after_too_many_wrong_attempts() {
|
||||
let service = build_phone_service(build_store());
|
||||
|
||||
1
server-rs/crates/module-bark-battle/src/application.rs
Normal file
1
server-rs/crates/module-bark-battle/src/application.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! 中文注释:汪汪声浪领域应用服务预留落位,当前规则仍集中在 domain/scoring。
|
||||
1
server-rs/crates/module-bark-battle/src/commands.rs
Normal file
1
server-rs/crates/module-bark-battle/src/commands.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! 中文注释:汪汪声浪命令归一化预留落位,当前无独立命令构造。
|
||||
1
server-rs/crates/module-bark-battle/src/errors.rs
Normal file
1
server-rs/crates/module-bark-battle/src/errors.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! 中文注释:汪汪声浪领域错误预留落位,当前复用调用方错误文本。
|
||||
1
server-rs/crates/module-bark-battle/src/events.rs
Normal file
1
server-rs/crates/module-bark-battle/src/events.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! 中文注释:汪汪声浪领域事件预留落位,当前不导出独立事件类型。
|
||||
@@ -1,4 +1,8 @@
|
||||
mod application;
|
||||
mod commands;
|
||||
pub mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
pub mod scoring;
|
||||
|
||||
pub use domain::*;
|
||||
|
||||
@@ -68,7 +68,7 @@ pub struct BigFishWorkRemixInput {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BigFishWorksProcedureResult {
|
||||
pub ok: bool,
|
||||
pub items_json: Option<String>,
|
||||
pub items: Vec<BigFishWorkSummarySnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -188,9 +188,9 @@ pub struct BigFishInputSubmitInput {
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BigFishRunProcedureResult {
|
||||
pub ok: bool,
|
||||
pub run_json: Option<String>,
|
||||
pub run: Option<BigFishRuntimeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
1
server-rs/crates/module-creative-agent/src/events.rs
Normal file
1
server-rs/crates/module-creative-agent/src/events.rs
Normal file
@@ -0,0 +1 @@
|
||||
//! 中文注释:创意 Agent 领域事件预留落位,当前流程不导出独立事件类型。
|
||||
@@ -2,6 +2,7 @@ mod application;
|
||||
mod commands;
|
||||
mod domain;
|
||||
mod errors;
|
||||
mod events;
|
||||
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user