diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 01124161..a1816a5a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,38 @@ --- +## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链 + +- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。 +- 决策:Server-Provision 只做服务器初始化,全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 与 `Provision Server` 在同一个目标 agent workspace 顺序执行,不再切到 `linux && genarrative-build`,不再 `stash/unstash` 工具包。`scripts/jenkins-server-provision.sh` 不再安装 clang / lld / pkg-config / libssl-dev / sccache;非 dry-run 仍要求目标 dev / release agent 具备 root 权限,因为 provision 会写 systemd、Nginx、`/etc` 和系统用户。Job 的 `Pipeline script from SCM` 与 Jenkinsfile 参数 `SOURCE_GIT_REMOTE_URL` 都必须使用本机路径或目标 agent 可访问的内网 Git 源,不允许公网 Git fallback。 +- 影响范围:`jenkins/Jenkinsfile.production-server-provision`、`scripts/jenkins-server-provision.sh`、生产运维文档、Server-Provision 排障口径。 +- 验证方式:Jenkins 日志中 Server-Provision 的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都在目标 dev / release agent 上执行;日志不出现 `Running on Jenkins`、`linux && genarrative-build`、`stash 'server-provision-tools'`、`Git 主地址拉取失败...改用备用地址`、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 或构建依赖 / sccache 安装步骤;`bash -n scripts/jenkins-server-provision.sh` 和编码检查通过。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-06-05 api-server 重启先摘流再排空并持久化 outbox + +- 背景:生产部署重启 api-server 时,如果只用 `/healthz` 判断存活并直接停止进程,运行中的 HTTP 请求和本地 tracking outbox active 文件都可能被中断,容易造成用户请求失败或内存/本地缓冲数据延迟丢失。 +- 决策:`/healthz` 只表示进程存活,发布和生产接流检查统一使用 `/readyz`。api-server 收到 `SIGINT` / `SIGTERM` 后先把 readiness 标记为不可用,再交给 Axum graceful shutdown 排空已有 HTTP 请求;退出前在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存 active tracking outbox 并尽力 flush sealed 文件,失败或超时则保留本地文件给下次启动重试。systemd 停机窗口统一放到 `TimeoutStopSec=90`。 +- 影响范围:`server-rs/crates/api-server`、`deploy/systemd/genarrative-api.service`、生产 API deploy 脚本、Jenkins API deploy 参数、Nginx 公网健康检查暴露策略、开发运维文档。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml readyz_reports_readiness_and_draining_state`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml shutdown_flush_seals_active_file_for_later_retry`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、部署脚本 `bash -n` 与 `/readyz` 本机 smoke。 +- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-06-05 OSS 平台适配器输出结构化日志 + +- 背景:AI 生成资产、浏览器直传签名、私有读签名和对象确认都依赖 OSS;如果 OSS 侧只有错误字符串,排查资产写入 / 确认失败时很难按操作、对象、状态码和耗时下钻。 +- 决策:`server-rs/crates/platform-oss` 统一为 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object` 输出结构化日志。日志固定携带 `provider=aliyun-oss`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 +- 影响范围:`server-rs/crates/platform-oss`、`api-server` 资产签名 / 上传 / 确认链路、OTLP logs、本地 `logs/api-server/` 与运维排障文档。 +- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`;真实联调时按 `provider=aliyun-oss` 与 `operation` 过滤日志,确认只出现对象定位和状态字段,不出现签名材料。 +- 关联文档:`server-rs/crates/platform-oss/README.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-06-03 创作入口关闭不下架已发布作品 + +- 背景:`creation_entry_disabled` 曾由 api-server 按 runtime 路由前缀统一熔断,导致用户进入平台首页或启动已发布作品时也可能看到“创作入口已关闭”错误。 +- 决策:入口配置的 `open=false` 只表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。后端熔断只拦新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不因创作入口关闭而失败。前端平台首页遇到旧服务端返回的 `creation_entry_disabled` 只降级,不弹平台级错误弹窗;关闭态模板卡必须明显禁用并展示 `暂未开放`,不得继续显示泥点消耗。 +- 影响范围:`server-rs/crates/api-server/src/creation_entry_config.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、创作入口相关测试与玩法链路文档。 +- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误;进入平台首页不弹“平台首页:creation_entry_disabled”;关闭态入口卡显示锁定状态且不显示 `10-20泥点数`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口 - 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index 6cfb9325..9fad4640 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -205,7 +205,7 @@ npm run check:server-rs-ddd - 使用 `npm run dev:api-server` 重新拉起后端。 - 禁止使用 `npm run api-server:maincloud`、`npm.cmd run api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径;这些只属于历史残留。 -- 检查 `/healthz`。 +- 本地 smoke 检查 `/healthz`;发布后或确认实例可接生产流量时检查 `/readyz`。 - 执行对应自动测试。 - 涉及 SpacetimeDB 表、reducer、procedure、row shape 或绑定变化时,同步更新 `migration.rs`、表目录和生成绑定。 - SpacetimeDB 已有表新增字段必须放在 Rust 表结构体最后,并设置明确默认值;需要修改字段名时,先询问用户并确认迁移计划,再同步更新 `server-rs/crates/spacetime-module/src/migration.rs`、表目录和生成绑定。 @@ -224,7 +224,7 @@ npm run check:server-rs-ddd ## 生产压测与观测默认口径 - 作品列表 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` 为准。 +- 生产 `api-server` 默认 backlog、worker threads、HTTP 并发背压、`/readyz` 接流检查、systemd 优雅停机窗口、Nginx upstream timing log 和 OTLP 开关以 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` 为准。 - OpenTelemetry 现阶段可选发送 traces / metrics / logs,但不会取代本地 `journalctl -u genarrative-api.service`、`logs/api-server/` 与 `/var/log/nginx/genarrative.*.log`。 - 指标 label 不写 raw URI、userId、profileId 或 request_id;request_id 只用于 trace/log 串联。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 032cd00d..ff5b9a56 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -35,10 +35,10 @@ - 现象:`external_api_call_failure` 里看到 `failureStage=request_send`、`timeout=true`、`statusCode=null`,`errorSource` 可能是 `client error (SendRequest)` 或更完整的 reqwest 底层错误链,前端只知道图片生成失败。 - 原因:`timeout=true` 来自 `reqwest::Error::is_timeout()`,不是业务代码固定写死;`SendRequest` 是 Hyper 发送请求阶段的错误来源标签,只说明请求未拿到可归类的 HTTP 响应,不会包含上游 JSON 错误体。 -- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。 +- 处理:先按 `provider/failureStage/statusClass` 聚合,再用 `user_id` / `profile_id` 和 `metadata_json.userId/profileId/requestId` 定位触发者、草稿 / 作品和同一次 HTTP 请求;`request_send + timeout=true` 优先查 provider 日志的 `source_chain`、请求体大小、参考图数量、出口网络、代理/Nginx、VectorEngine 当时可用性和同一 request_id 日志。当前 `platform-image` 对 `request_send` 的 `timeout` / `connect` 错误最多重试 3 次,multipart `/v1/images/edits` 每次重试都必须重建 form;看到 `VectorEngine 图片请求发送失败,准备重试` 只是单次 attempt 失败,最终 `external_api_call_failure` 才代表该用户请求整体失败。若记录有 `502` 或 `429 moderation_blocked`,按上游网关或审核失败另行处理,不要归到传输超时。 - 拼图关卡资产生成按 `level_scene -> ui_spritesheet -> level_background` 顺序执行,每个资产会输出 `slot`、`asset_kind`、`elapsed_ms`;排查拼图草稿失败时优先看同一 request_id 下最后一个失败 slot。 -- 验证:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 -- 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。 +- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## “我的”页每日任务卡不要硬编码进度 @@ -1187,6 +1187,7 @@ ## Jenkins 生产流水线拉 Git 先本机再域名备用 +- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为服务器初始化专用口径,不允许公网 Git fallback,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源。 - 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15`、`curl 56 GnuTLS recv error (-9)`、`early EOF`、`invalid index-pack output` 失败;发布类流水线还可能先遇到 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达。 - 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。即使只使用域名 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`,Jenkins Git 插件也会拉取所有分支。 - 处理:Jenkins Job 的 `Pipeline script from SCM` 由 Windows controller 执行,SCM URL 使用公网域名 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。运行于 `linux && genarrative-build` 的 `Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` checkout 阶段,以及部署/发布类 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后直接尝试 `GIT_REMOTE_FALLBACK_URL=https://git.genarrative.world/GenarrativeAI/Genarrative.git`,不再配置内网 IP fallback;这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、域名备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,只有指定 commit 时才允许加深历史做分支归属校验。 @@ -1209,12 +1210,12 @@ - 验证:运行 `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`。 -## Server-Provision 工具准备只在 Linux build 节点做一次 +## Server-Provision 工具准备只在目标部署 agent 内做一次 -- 现象:`Genarrative-Server-Provision` 在后续目标发布节点重复执行 `scripts/prepare-server-provision-tools.sh`,或日志里出现目标节点继续访问 GitHub release / `install.spacetimedb.com`。 -- 原因:当前流水线已经改成 Linux build 节点一次性准备 `provision-tools/` 并 stash 给目标发布阶段;如果目标发布阶段又重新准备工具包,就会重复下载并把目标节点暴露到外网依赖。 -- 处理:只允许 `Prepare Provision Tools` 阶段在 `linux && genarrative-build` 节点生成 `provision-tools/`;后续 `Provision Server` 阶段只 `unstash 'server-provision-tools'` 并安装其中的 `spacetime` 与 `otelcol-contrib`,不要再运行 `prepare-server-provision-tools.sh`。 -- 验证:Jenkins 日志应先在 Linux build 节点出现 `[prepare-provision-tools] 工具包已准备`,后续目标发布节点只出现安装 / systemd / Nginx provision 日志;目标节点不应出现 `下载 otelcol-contrib:` 或 `下载 SpacetimeDB 官方安装器脚本:`。 +- 现象:`Genarrative-Server-Provision` 选择 `DEPLOY_TARGET=development` 时如果阶段跑在 `Running on Jenkins` 或 `linux && genarrative-build`,真实 provision 会落到构建机 / controller,而不是 dev 服务器。 +- 原因:Server-Provision 是服务器初始化流水线,dev / release 都是目标服务器,不应把 development 当成 build 节点预览目标,也不应通过 build 节点 stash 工具包再切回目标机;同时公网 Git fallback 会让目标 agent 内网源不可达时悄悄改从公网拉源码,掩盖服务器路由问题。 +- 处理:Server-Provision 全程运行在目标部署 agent:development 使用 `linux && genarrative-dev-deploy`,release 使用 `linux && genarrative-release-deploy`。`Prepare Provision Tools` 和 `Provision Server` 在同一个目标 agent workspace 内顺序执行,不再使用 `linux && genarrative-build`,也不再 `stash/unstash` 工具包。Job 的 `Pipeline script from SCM` 与参数 `SOURCE_GIT_REMOTE_URL` 都必须指向本机路径或内网 Git 源,不允许 `https://git.genarrative.world/...` 公网地址。 +- 验证:Jenkins 日志中 `Provision Target` 下的 `Prepare`、`Checkout Provision Files`、`Prepare Provision Tools` 和 `Provision Server` 都应运行在目标 dev / release agent;日志不应出现 `stash 'server-provision-tools'`、目标阶段 `unstash`、`Git 主地址拉取失败...改用备用地址` 或 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。 - 关联:`jenkins/Jenkinsfile.production-server-provision`、`scripts/prepare-server-provision-tools.sh`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## 个人任务 scope 不得扩成 work/site/module diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 239b5c4c..be9dd0eb 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -190,7 +190,7 @@ http { proxy_set_header X-Request-Id $request_id; } - location ~ ^/(generated-|healthz) { + location ~ ^/(generated-|healthz|readyz) { return 404; } diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 1434d8be..90b2378b 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -11,6 +11,7 @@ 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_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 GENARRATIVE_TRACKING_OUTBOX_ENABLED=true GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE=500 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index b7c0cdaa..62e87f14 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -215,7 +215,7 @@ server { } # 开发服仍不恢复旧生成资源代理和健康检查公网入口。 - location ~ ^/(generated-|healthz) { + location ~ ^/(generated-|healthz|readyz) { return 404; } diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index c26e9bbb..fa1a111b 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -235,7 +235,7 @@ server { } # 生产公网不再暴露旧生成资源代理和健康检查入口。 - location ~ ^/(generated-|healthz) { + location ~ ^/(generated-|healthz|readyz) { return 404; } diff --git a/deploy/systemd/genarrative-api.service b/deploy/systemd/genarrative-api.service index bba53a79..82ddd339 100644 --- a/deploy/systemd/genarrative-api.service +++ b/deploy/systemd/genarrative-api.service @@ -14,7 +14,7 @@ ExecStart=/opt/genarrative/current/api-server Restart=always RestartSec=5 KillSignal=SIGINT -TimeoutStopSec=30 +TimeoutStopSec=90 LimitNOFILE=65535 TasksMax=2048 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index c5d48c72..99d3d112 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -130,9 +130,10 @@ npm run check:server-rs-ddd 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 -6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 -7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 -8. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 +6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 +7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 +8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 +9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 @@ -176,7 +177,7 @@ npm run check:server-rs-ddd - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 -- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 +- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index d62ee632..68666bb1 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-05-15` +更新时间:`2026-06-05` ## 标准开发流程 @@ -47,7 +47,7 @@ npm run dev:api-server Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模块命令会先在系统级端口段注册表里给当前用户分配一个端口段,再把该段映射为 `web = start`、`api = start + 1`、`spacetime = start + 2`、`admin-web = start + 3`。默认注册表目录是 `/var/tmp/genarrative-dev-port-ranges/`,其中 `registry.json` 记录各用户的活跃段,`registry.lock` 负责串行化分配;可以用 `GENARRATIVE_DEV_PORT_RANGE_REGISTRY_DIR` 覆盖目录。系统自动分配时从 `10000-10099` 开始,每次占用 100 个端口块,后续块按 `10100-10199`、`10200-10299` 递增;`GENARRATIVE_DEV_PORT_RANGE` 或 `--port-range` 只在 Linux 上生效,Windows 仍按原来的 3000 / 8082 / 3101 / 3102 端口探测与漂移逻辑运行,不读这个系统级注册表。 -后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;需要确认实例可接生产流量时检查 `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 @@ -69,6 +69,8 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv 本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若 VectorEngine 在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 `request_id` 的 provider 日志字段 `source`、`source_chain`、`source_chain_depth`,再查 `external_api_call_failure.metadata_json.errorSource`;当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。拼图关卡资产按 `level_scene -> ui_spritesheet -> level_background` 顺序生成,日志会带 `slot`、`asset_kind` 和 `elapsed_ms`。 +VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对同一请求最多发送 3 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。 + 查看本地 Rust / SpacetimeDB 日志: ```bash @@ -242,26 +244,27 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分 `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 -生产 Jenkins 的 `Pipeline script from SCM` 仍由 Jenkins controller 读取 Jenkinsfile,SCM URL 继续使用 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`。现在所有生产流水线 job 的首次 checkout 都先走 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;两层 checkout 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`,后续二次源码确认继续走 `scripts/jenkins-checkout-source.sh`。 +生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。其它构建 / 发布流水线仍按各自 Jenkinsfile 的 checkout 口径执行;所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。 `Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` 阶段如果一开始就报 `No such DSL method 'pipeline'`,优先检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 是否带 UTF-8 BOM。Jenkins Declarative Pipeline 的首个 token 必须是纯 `pipeline`;仓库中的 Jenkinsfile 应保存为 UTF-8 without BOM,只有临时写给 Windows PowerShell 5.1 `-File` 执行的 `.ps1` 才需要按对应 helper 转成带 BOM。验证时可检查文件前三字节不再是 `EF BB BF`,并运行 `validateDeclarativePipeline` 或重放该流水线。 `Genarrative-Stdb-Module-Build` 或 SpacetimeDB module 构建失败若出现 Rust `E0425 cannot find function migrate_*`,优先排查 `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` 等同文件内默认种子迁移 helper 是否在分支合并时只保留了调用、漏掉了函数定义。`Genarrative-Stdb-Module-Build` 现在运行在 `linux && genarrative-build` 节点上,Checkout 与 Build 都走 bash + cargo + sccache,不再依赖 Windows PowerShell 或 Git Bash。修复时不要直接删除迁移调用;应恢复只纠偏历史默认种子且不覆盖后台手动配置的 helper,并用 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 复现 Jenkins module 编译路径。 -`Genarrative-Server-Provision` 现在也运行在 `linux && genarrative-build` / `linux && genarrative-release-deploy` 节点上,`Prepare Provision Tools` 会在 Linux build 节点直接准备 SpacetimeDB 与 `otelcol-contrib` 交付件,再 stash 给后续发布阶段;旧 Windows 下载 helper 已退役。`Genarrative-Stdb-Module-Build`、`Genarrative-Server-Provision` 和 `Genarrative-Notify-Email` 都不再需要单独的 Windows 节点口径。 +`Genarrative-Server-Provision` 只做服务器初始化,不再承担构建职责。流水线全程运行在目标服务器 agent:`DEPLOY_TARGET=development` 使用 `linux && genarrative-dev-deploy`,`DEPLOY_TARGET=release` 使用 `linux && genarrative-release-deploy`;`Prepare Provision Tools` 也在同一个目标 agent 工作区内准备 SpacetimeDB 与 `otelcol-contrib` 交付件,不再切到 `linux && genarrative-build`,也不再 stash 给后续阶段。`SOURCE_GIT_REMOTE_URL` 必须显式填写为目标 agent 可访问的本机路径、`file:///` 地址、localhost / 127.0.0.1、RFC1918 内网 HTTP Git 地址、单标签内网主机名或 `.local` / `.lan` / `.internal` 地址;这条流水线不配置公网 Git 备用地址,目标 agent 拉不到内网源就应直接失败。真实初始化会写入 `/etc` / systemd / Nginx、创建系统用户并修改服务,目标 dev / release agent 非 dry-run 时都必须具备 root 权限。 生产环境变量模板:`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,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 +`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 50 HTTP req/s 首版压测优化口径: - `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 不再通过 Windows helper 下载。`Genarrative-Server-Provision` 的 `Prepare Provision Tools` 在 Linux build 节点直接准备 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz`,再 stash `provision-tools/` 给后续发布阶段;如果 build 节点需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置 Linux 侧可访问的 HTTP 代理。后续 Linux 目标节点只消费 `provision-tools/`,不再回退到外网下载。 -- `Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,统一走 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 优先、`https://git.genarrative.world/GenarrativeAI/Genarrative.git` 备用的 checkout 口径。 +- `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` 与 `/readyz` 不受该限制。这些值不是 RPS 限速;如果压测中 429 上升但内存和 p95 收敛,说明背压正在保护进程。直连 `api-server` 的极高 RPS 压测若出现 `connection refused`,通常已经打到 TCP 监听 / accept 层,应同时检查 backlog、Nginx upstream keepalive 和前置限流。 +- `api-server` 正常运行时 `/healthz` 返回进程存活状态,`/readyz` 返回是否仍接收新流量;收到 `SIGINT` / `SIGTERM` 后会先把 readiness 标记为不可用,再让 Axum 停止接新连接并等待已有 HTTP 请求排空。systemd 仍以 `KillSignal=SIGINT` 停服务,`TimeoutStopSec=90` 作为长请求排空上限。 +- `genarrative-api.service` 设置 `LimitNOFILE=65535`、`TasksMax=2048`;上线后用 `systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax -p TimeoutStopUSec` 和 `cat /proc/$(pidof api-server)/limits` 核对。 +- Server provision 不再通过 Windows helper 下载,也不再通过 Linux build 节点中转工具包。`Prepare Provision Tools` 在目标 dev / release agent 工作区内准备 `spacetime-x86_64-unknown-linux-gnu.tar.gz` 和 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 并生成 `provision-tools/`;如果目标服务器下载需要代理,在 `PROVISION_DOWNLOAD_PROXY` 配置目标机可访问的 HTTP 代理。 +- 除 `Genarrative-Server-Provision` 外,`Genarrative-Stdb-Module-Build`、`Genarrative-Web-Build`、`Genarrative-Api-Build`、`Genarrative-*Deploy`、`Genarrative-Database-Import/Export`、`Genarrative-Full-Build-And-Deploy` 和 `Genarrative-Notify-Email` 的生产流水线现都以 Linux agent 为主,仍按各自 Jenkinsfile 的 checkout 口径执行。Server provision 不使用公网备用 Git 源。 - `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`。通用 `/api` location 设置 `client_max_body_size 64m` 是反代兜底,防止拼图入口页 / 新增关卡本地参考图 Data URL 或旧兼容请求在到达 `api-server` 前被默认 1 MiB 上限拦截;拼图本地参考图前后端统一限制 6MB,历史图片仍提交 `referenceImageAssetObjectId(s)`。若线上出现 `413 Request Entity Too Large` 且 access log 中 `request_time=0.000`、`upstream_status=-`,说明请求在 Nginx 层被拦截,先用 `nginx -T | grep client_max_body_size` 检查 release 模板是否已渲染并 reload,同时检查前端是否超出 6MB 或错误提交了未压缩大图。`limit_conn_status 429` 和 `limit_req_status 429` 必须在 HTTP 与 HTTPS server 中同时生效;若线上压测看到 `limiting connections by zone "genarrative_api_conn"` 却返回 503,优先检查 `nginx -T` 里 HTTPS server 是否缺少这些状态码,以及 `/api/runtime/puzzle/gallery` 是否误落到通用 `location ~ ^/api` 的 `limit_conn=64`。压测时看 `/var/log/nginx/genarrative.access.log` 中的 `request_time`、`upstream_connect_time`、`upstream_header_time`、`upstream_response_time`、`upstream_status`、`request_id`。 - 作品列表 K6 脚本一次 iteration 默认请求两个公开接口,因此约 50 HTTP req/s 的目标命令使用 `SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works`。 @@ -279,7 +282,7 @@ 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 侧现在由 Linux build 节点直接准备 `provision-tools/otelcol-contrib`,再交给后续 Linux 发布阶段安装本机 `otelcol-contrib.service`,真实库名、token 和外部服务密钥只写本地 `deploy/container/api-server.env`,不提交 Git。完整拓扑、端口、k6 参数和 OTLP debug exporter 使用方法见 `deploy/container/README.md`。 +容器方案默认暴露 `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 侧现在由目标 dev / release agent 自己准备 `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 文件日志仍保留: @@ -292,7 +295,8 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 -- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 +- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 +- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。 - 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 查看。 @@ -378,7 +382,7 @@ ORDER BY failures DESC, last_seen DESC LIMIT 100; ``` -VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,仍不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` 的 `api-server` request 日志、provider 日志 `source_chain`、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。 +VectorEngine `request_send` 且 `timeout = true` 的记录表示 `reqwest::Error::is_timeout()` 判定为超时,常见于连接、发送请求体、等待上游首包或上游长时间无响应;`errorSource` 会保存 reqwest 底层错误链,若只看到 `client error (SendRequest)`,表示 Hyper 只暴露到发送请求阶段,仍不等于最终根因。若 `statusCode` 为空,应优先查同一 `requestId` 的 `api-server` request 日志、provider 日志 `source_chain`、request_params、reference_image_bytes_total、Nginx / 出口网络、VectorEngine 可用性和请求体大小;若已有 `502`、`429 moderation_blocked` 等状态码,则按上游网关或内容审核失败单独处理,不要和传输超时混为一类。 tracking outbox 默认配置: @@ -388,9 +392,10 @@ 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_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS=5000 ``` -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` 幂等跳过重复事件。 +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` 指标,避免一个坏文件阻塞后续批量入库。api-server 收到退出信号后会在 `GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS` 窗口内封存 active 文件并尽力 flush sealed 文件,超时或 SpacetimeDB 暂不可用时保留本地文件给下次启动继续投递。该机制提供至少一次投递语义,依赖 `tracking_event.event_id` 幂等跳过重复事件。 release 机器如果日志每秒刷 `tracking outbox ... Permission denied (os error 13)`,先检查 `/etc/genarrative/api-server.env` 是否缺少 `GENARRATIVE_TRACKING_OUTBOX_DIR`。缺少时 `api-server` 会回退到本地开发默认相对路径 `server-rs/.data/tracking-outbox`,而 systemd 的工作目录是只读发布目录 `/opt/genarrative/releases/`,`genarrative` 用户无法在其中创建 `server-rs`。修复顺序: diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 6e3c3458..48aad42a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,6 +8,8 @@ 当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。 +创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。 + 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick;视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链。 @@ -36,6 +38,8 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 入口配置中的 `open=false` 表示关闭新建创作入口,不表示下架已有草稿、私有作品或公开作品。api-server 的入口熔断只允许拦截新建创作、新建草稿、首次生成入口和 Remix 成草稿等会产生新创作的请求;公开广场列表、公开详情、点赞、已发布作品启动、运行态过程请求、存档 / 浏览记录和已有作品回读不能因为创作入口关闭而返回 `creation_entry_disabled`。平台首页如果遇到旧服务端返回的 `creation_entry_disabled`,只能降级为空列表或隐藏入口,不弹平台级错误弹窗。 +创作入口页的关闭态卡片必须有明显差异:卡片禁用点击,展示后台配置的关闭态 badge 或 `暂未开放`,不再显示 `10-20泥点数` 这类可创建成本提示;开放态卡片仍不显示普通 `可创建 / 可创作` badge。 + `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 `platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recommended` / `热门推荐`,并把历史 `recent` / `最近创作` 归一到推荐分类。`最近创作` 不属于模板分类页签,只能由 7 天内的真实草稿 / 作品架后端数据决定是否展示;展示内容仍然从后端入口配置的模板卡中筛选,不读取或渲染作品标题、作品摘要、草稿阶段文案。 @@ -73,7 +77,7 @@ RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。 12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys;平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。 -发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。 +发现页 / 推荐页公开作品卡的作者行只显示公开昵称或账号生成的脱敏手机号;不得把纯 `SY-*` 陶泥号或作品号当作卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露额外账号标识。 平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID;`PZ`、`BF`、`JH`、`WF`、`BO`、`M3`、`SH`、`VN`、`BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射。 @@ -173,7 +177,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。 ## 敲木鱼 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 68c05a44..254e5128 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -46,6 +46,8 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 3. 登录弹窗继续复用现有独立 modal 和页签结构,不在页面中新增功能说明类文案,也不把邀请码输入放回登录面板。 4. 微信小程序 `web-view` 外壳默认不预登录,首次进入直接打开 H5,并保持与 Web 端一致的未登录状态;只有 H5 触发 `openLoginModal` / `requireAuth` 等受保护入口时,才跳转小程序原生授权态。 5. 小程序内需要登录时不展示 H5 登录弹窗,也不走手输手机号 / 短信验证码流程;统一通过原生 `button open-type="getPhoneNumber"` 获取微信手机号授权,再调用 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 换取系统登录态。 +6. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。 +7. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 ## 账户与充值 diff --git a/jenkins/Jenkinsfile.production-api-build b/jenkins/Jenkinsfile.production-api-build index 9d925de6..609866ca 100644 --- a/jenkins/Jenkinsfile.production-api-build +++ b/jenkins/Jenkinsfile.production-api-build @@ -88,7 +88,7 @@ pipeline { chmod +x scripts/jenkins-prepare-cargo-env.sh source scripts/jenkins-prepare-cargo-env.sh if ! command -v clang >/dev/null 2>&1 || ! command -v lld >/dev/null 2>&1; then - echo "[api-build] 缺少 clang/lld。请先运行 Genarrative-Server-Provision 安装 Linux 构建依赖。" >&2 + echo "[api-build] 缺少 clang/lld。请在 genarrative-build 节点预先安装 Linux 构建依赖。" >&2 exit 1 fi if ! command -v sccache >/dev/null 2>&1; then diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index 95432266..8d17e08e 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -24,7 +24,7 @@ pipeline { string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: '生产 release 根目录') string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接') string(name: 'SERVICE_NAME', defaultValue: 'genarrative-api.service', description: 'systemd 服务名') - string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/healthz', description: '本机健康检查地址') + string(name: 'HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/readyz', description: '本机 readiness 检查地址') string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'api-server 连接的 SpacetimeDB database') string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: 'api-server 连接的 SpacetimeDB server URL') diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index 25d7b229..2561aa3f 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -7,25 +7,21 @@ pipeline { buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } - environment { - GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' - GIT_REMOTE_FALLBACK_URL = 'https://git.genarrative.world/GenarrativeAI/Genarrative.git' - } - parameters { - choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用当前 Linux 开发/构建/开发部署 agent') + choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用 dev 服务器部署 agent,release 使用正式服务器部署 agent') booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run') booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置') + string(name: 'SOURCE_GIT_REMOTE_URL', defaultValue: '', description: '部署脚本 Git 来源;必须是目标 agent 可访问的内网/本机 Gitea 地址,不配置公网备用') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支') 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: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: 'Linux 预下载阶段暂存 SpacetimeDB/otelcol 安装包的工作区相对目录') + string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录') string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录') - string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,Linux 预下载阶段下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理') - string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: 'Linux 预下载阶段使用的 SpacetimeDB Linux release tarball 根地址') + string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理') + string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址') string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值') string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir') string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录') @@ -40,205 +36,160 @@ pipeline { } stages { - stage('Prepare') { + stage('Provision Target') { agent { - label 'linux && genarrative-build' + label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" } - steps { - script { - if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { - error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') - } - if (!params.DRY_RUN && !params.CONFIRM_PROVISION) { - error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。') - } - if (!params.SERVER_NAME?.trim()) { - error('SERVER_NAME 不能为空。') - } - if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { - error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}") - } - def serverAliases = params.SERVER_ALIASES?.trim() - if (serverAliases) { - serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName -> - if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { - error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}") + stages { + stage('Prepare') { + steps { + script { + if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { + error('release provision 需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') + } + if (!params.DRY_RUN && !params.CONFIRM_PROVISION) { + error('执行服务器初始化前必须勾选 CONFIRM_PROVISION;否则请保持 DRY_RUN=true。') + } + if (!params.SERVER_NAME?.trim()) { + error('SERVER_NAME 不能为空。') + } + def sourceGitRemoteUrl = params.SOURCE_GIT_REMOTE_URL?.trim() + if (!sourceGitRemoteUrl) { + error('SOURCE_GIT_REMOTE_URL 不能为空。') + } + def isLocalGitPath = sourceGitRemoteUrl ==~ /^\/[0-9A-Za-z._\/-]+$/ + def isLocalGitFileUrl = sourceGitRemoteUrl ==~ /^file:\/\/\/\S+$/ + def isPrivateHttpGitUrl = sourceGitRemoteUrl ==~ /^https?:\/\/(localhost|127(?:\.[0-9]{1,3}){3}|10(?:\.[0-9]{1,3}){3}|192\.168(?:\.[0-9]{1,3}){2}|172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.[0-9]{1,3}){2}|[A-Za-z0-9-]+|[A-Za-z0-9.-]+\.(?:local|lan|internal))(?::[0-9]+)?\/\S+$/ + if (!isLocalGitPath && !isLocalGitFileUrl && !isPrivateHttpGitUrl) { + error('Genarrative-Server-Provision 不允许使用公网 Git 仓库;SOURCE_GIT_REMOTE_URL 只能是目标 agent 可访问的本机路径、file:/// 地址、localhost/127.0.0.1、RFC1918 内网 HTTP 地址、单标签内网主机名或 .local/.lan/.internal 地址。') + } + env.EFFECTIVE_GIT_REMOTE_URL = sourceGitRemoteUrl + if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { + error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}") + } + def serverAliases = params.SERVER_ALIASES?.trim() + if (serverAliases) { + serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName -> + if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { + error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}") + } + } + } + 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('..') || params.PROVISION_TOOLS_DIR.trim() == '.') { + error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}") + } + if (!params.PROVISION_DOWNLOADS_DIR?.trim()) { + error('PROVISION_DOWNLOADS_DIR 不能为空。') + } + if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') { + error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}") + } + def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim() + def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim() + if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) { + error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}") + } + def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim() + if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) { + error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}") + } + if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) { + error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}") + } + if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) { + error('SPACETIME_DOWNLOAD_ROOT 不能为空。') + } + if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) { + error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}") + } + def nginxMode = params.NGINX_CONFIG_MODE?.trim() + if (!(nginxMode in ['none', 'production-https', 'development-http'])) { + error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}") + } + if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') { + error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。') + } + if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') { + error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。') } } } - 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('..') || params.PROVISION_TOOLS_DIR.trim() == '.') { - error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}") - } - if (!params.PROVISION_DOWNLOADS_DIR?.trim()) { - error('PROVISION_DOWNLOADS_DIR 不能为空。') - } - if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') { - error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}") - } - def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim() - def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim() - if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) { - error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}") - } - def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim() - if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) { - error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}") - } - if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) { - error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}") - } - if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) { - error('SPACETIME_DOWNLOAD_ROOT 不能为空。') - } - if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) { - error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}") - } - def nginxMode = params.NGINX_CONFIG_MODE?.trim() - if (!(nginxMode in ['none', 'production-https', 'development-http'])) { - error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http,当前值: ${params.NGINX_CONFIG_MODE}") - } - if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') { - error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。') - } - if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') { - error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。') - } } - } - } - 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 + stage('Checkout Provision Files') { + steps { + script { + 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: env.EFFECTIVE_GIT_REMOTE_URL, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], + ]) + } + sh ''' + bash <<'BASH' + set -euo pipefail + chmod +x scripts/jenkins-checkout-source.sh + SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ + COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL}" \ + SOURCE_COMMIT_FILE=".jenkins-source-commit" \ + scripts/jenkins-checkout-source.sh BASH - ''' - sh ''' - bash -lc ' - set -euo pipefail - chmod +x scripts/prepare-server-provision-tools.sh - PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \ - PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \ - OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \ - PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \ - PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \ - SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \ - SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \ - scripts/prepare-server-provision-tools.sh - ' - ''' - script { - env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() - echo "Provision 工具包源码 commit=${env.SOURCE_COMMIT}" + ''' + 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'}" - } - 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 + stage('Prepare Provision Tools') { + steps { + sh ''' + bash -lc ' + set -euo pipefail + chmod +x scripts/prepare-server-provision-tools.sh + PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \ + PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \ + OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \ + PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \ + PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \ + SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \ + SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \ + scripts/prepare-server-provision-tools.sh + ' + ''' } } - sh ''' - bash <<'BASH' - set -euo pipefail - chmod +x scripts/jenkins-checkout-source.sh - SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ - 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" \ - scripts/jenkins-checkout-source.sh -BASH - ''' - script { - env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() - echo "Provision 源码 commit=${env.SOURCE_COMMIT}" - } - } - } - stage('Provision Server') { - agent { - 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 + stage('Provision Server') { + steps { + 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 - ''' + ''' + } + } } } } @@ -253,9 +204,7 @@ BASH string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'), string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''), string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')), - string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')), string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), - string(name: 'DATABASE', value: params.DATABASE ?: ''), string(name: 'SUMMARY', value: '服务器初始化流水线结束'), ] def notificationRecipients = params.NOTIFICATION_EMAILS?.trim() diff --git a/miniprogram/config.js b/miniprogram/config.js index aa218a03..c521817f 100644 --- a/miniprogram/config.js +++ b/miniprogram/config.js @@ -1,17 +1,19 @@ // 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。 // 示例:https://game.example.com/ // 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。 -const WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world'; +const WEB_VIEW_ENTRY_URL = 'https://www.genarrative.world'; +const DEV_WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world'; // 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。 // 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。 -const API_BASE_URL = 'https://dev.genarrative.world'; +const API_BASE_URL = 'https://www.genarrative.world'; +const DEV_API_BASE_URL = 'https://dev.genarrative.world'; // 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。 const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65'; -// 中文注释:按当前上传版本填写 develop / trial / release,后端会写入会话来源快照。 -const MINI_PROGRAM_ENV = 'develop'; +// 中文注释:仅作为运行时环境识别失败时的兜底;正常情况下由 wx.getAccountInfoSync 自动判断。 +const MINI_PROGRAM_ENV = 'release'; // 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。 const WEB_VIEW_SOURCE_QUERY = { @@ -21,6 +23,8 @@ const WEB_VIEW_SOURCE_QUERY = { module.exports = { API_BASE_URL, + DEV_API_BASE_URL, + DEV_WEB_VIEW_ENTRY_URL, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 1f33e2cb..4b9e22c7 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -3,6 +3,8 @@ const { API_BASE_URL, + DEV_API_BASE_URL, + DEV_WEB_VIEW_ENTRY_URL, MINI_PROGRAM_APP_ID, MINI_PROGRAM_ENV, WEB_VIEW_ENTRY_URL, @@ -16,6 +18,33 @@ const PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result'; const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; const AUTH_ACTION_LOGIN = 'login'; const PAY_RESULT_RECHECK_DELAY_MS = 120; +const WEB_VIEW_SHARE_TITLE = '陶泥儿'; +const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; + +function showWebViewShareMenu() { + if (typeof wx.showShareMenu !== 'function') { + return; + } + + wx.showShareMenu({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); +} + +function buildWebViewShareAppMessage() { + return { + title: WEB_VIEW_SHARE_TITLE, + path: WEB_VIEW_SHARE_PATH, + }; +} + +function buildWebViewShareTimeline() { + return { + title: WEB_VIEW_SHARE_TITLE, + query: '', + }; +} function isConfiguredEntryUrl(value) { const trimmed = String(value || '').trim(); @@ -78,6 +107,68 @@ function parseBooleanQueryFlag(value) { return value === true || value === '1' || value === 'true' || value === 'yes'; } +function normalizeMiniProgramEnv(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'release') { + return 'release'; + } + if (normalized === 'trial') { + return 'trial'; + } + if ( + normalized === 'develop' || + normalized === 'development' || + normalized === 'dev' + ) { + return 'dev'; + } + return ''; +} + +function readMiniProgramEnvVersion() { + if (typeof wx.getAccountInfoSync !== 'function') { + return ''; + } + try { + const accountInfo = wx.getAccountInfoSync(); + return ( + accountInfo && + accountInfo.miniProgram && + accountInfo.miniProgram.envVersion + ); + } catch (error) { + console.warn('[web-view] read mini program env failed', error); + return ''; + } +} + +function resolveMiniProgramRuntimeConfig() { + const miniProgramEnv = + normalizeMiniProgramEnv(readMiniProgramEnvVersion()) || + normalizeMiniProgramEnv(MINI_PROGRAM_ENV) || + 'release'; + const useReleaseChannel = miniProgramEnv === 'release'; + const webViewEntryUrl = useReleaseChannel + ? WEB_VIEW_ENTRY_URL + : DEV_WEB_VIEW_ENTRY_URL || WEB_VIEW_ENTRY_URL; + const apiBaseUrl = useReleaseChannel + ? API_BASE_URL + : DEV_API_BASE_URL || API_BASE_URL; + const sourceQuery = { + ...WEB_VIEW_SOURCE_QUERY, + }; + if (!useReleaseChannel) { + sourceQuery.miniProgramEnv = miniProgramEnv; + } + + return { + apiBaseUrl, + miniProgramEnv, + sourceQuery, + webViewEntryUrl, + }; +} + function shouldStartAuthFromQuery(query) { return String((query && query.authAction) || '').trim() === AUTH_ACTION_LOGIN; } @@ -87,12 +178,13 @@ function shouldReturnToPreviousPage(query) { } function resolveWebViewUrl(authResult) { - const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim(); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); if (!isConfiguredEntryUrl(entryUrl)) { return ''; } - const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY); + const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery); if (!authResult || !authResult.token) { return sourcedUrl; } @@ -178,7 +270,8 @@ function wxLogin() { function requestMiniProgramLogin(code) { return new Promise((resolve, reject) => { - const apiBaseUrl = trimTrailingSlash(API_BASE_URL); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); if (!isConfiguredApiBaseUrl(apiBaseUrl)) { reject(new Error('请先配置 API_BASE_URL')); return; @@ -195,7 +288,7 @@ function requestMiniProgramLogin(code) { 'x-client-platform': resolveClientPlatform(), 'x-client-instance-id': getClientInstanceId(), 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': MINI_PROGRAM_ENV, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, }, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { @@ -219,7 +312,8 @@ function requestMiniProgramLogin(code) { function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { return new Promise((resolve, reject) => { - const apiBaseUrl = trimTrailingSlash(API_BASE_URL); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); + const apiBaseUrl = trimTrailingSlash(runtimeConfig.apiBaseUrl); if (!isConfiguredApiBaseUrl(apiBaseUrl)) { reject(new Error('请先配置 API_BASE_URL')); return; @@ -237,7 +331,7 @@ function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { 'x-client-platform': resolveClientPlatform(), 'x-client-instance-id': getClientInstanceId(), 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, - 'x-mini-program-env': MINI_PROGRAM_ENV, + 'x-mini-program-env': runtimeConfig.miniProgramEnv, }, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { @@ -271,18 +365,6 @@ async function resolveAuthResult() { }; } -async function refreshMiniProgramSessionSilently() { - if (!isConfiguredApiBaseUrl(API_BASE_URL)) { - return null; - } - try { - return await resolveAuthResult(); - } catch (error) { - console.warn('[web-view] silent mini program login refresh failed', error); - return null; - } -} - Page({ data: { authResult: null, @@ -296,8 +378,10 @@ Page({ async onLoad(query = {}) { this._lastLaunchQuery = query; + showWebViewShareMenu(); + const runtimeConfig = resolveMiniProgramRuntimeConfig(); // 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。 - if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) { + if (!isConfiguredEntryUrl(runtimeConfig.webViewEntryUrl)) { this.setData({ errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。', loading: false, @@ -309,19 +393,18 @@ Page({ const forcedPhoneBinding = parseBooleanQueryFlag(query.phoneBindingRequired); const returnToPreviousPage = shouldReturnToPreviousPage(query); if (!shouldStartAuthFromQuery(query) && !forcedPhoneBinding) { - const authResult = await refreshMiniProgramSessionSilently(); this.setData({ - authResult, + authResult: null, errorMessage: '', loading: false, phoneBindingRequired: false, returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(null), }); return; } - if (!isConfiguredApiBaseUrl(API_BASE_URL)) { + if (!isConfiguredApiBaseUrl(runtimeConfig.apiBaseUrl)) { this.setData({ errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。', loading: false, @@ -490,4 +573,12 @@ Page({ // 中文注释:支付由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, + + onShareAppMessage() { + return buildWebViewShareAppMessage(); + }, + + onShareTimeline() { + return buildWebViewShareTimeline(); + }, }); diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 9373b375..0f861923 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -5,10 +5,10 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/healthz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] + ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] 说明: - 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 healthz 检查。 + 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。 若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。 失败时保留维护模式。 EOF @@ -209,6 +209,7 @@ ensure_runtime_env_and_dirs() { # 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。 # 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。 + ensure_env_value "${api_env_file}" "GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS" "5000" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED" "true" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR" "/var/lib/genarrative/tracking-outbox" ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500" @@ -228,7 +229,7 @@ VERSION="" RELEASE_ROOT="/opt/genarrative/releases" CURRENT_LINK="/opt/genarrative/current" SERVICE_NAME="genarrative-api.service" -HEALTH_URL="http://127.0.0.1:8082/healthz" +HEALTH_URL="http://127.0.0.1:8082/readyz" API_ENV_FILE="/etc/genarrative/api-server.env" DATABASE="" SPACETIME_SERVER_URL="" @@ -362,7 +363,7 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}" -echo "[production-api-deploy] 等待 healthz: ${HEALTH_URL}" +echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}" for _ in {1..30}; do if curl -fsS "${HEALTH_URL}" >/dev/null; then "${SCRIPT_DIR}/maintenance-off.sh" @@ -373,5 +374,5 @@ for _ in {1..30}; do sleep 2 done -echo "[production-api-deploy] healthz 检查超时: ${HEALTH_URL}" >&2 +echo "[production-api-deploy] readiness 检查超时: ${HEALTH_URL}" >&2 exit 1 diff --git a/scripts/jenkins-prepare-cargo-env.sh b/scripts/jenkins-prepare-cargo-env.sh index 3a21cbf9..1654c28f 100755 --- a/scripts/jenkins-prepare-cargo-env.sh +++ b/scripts/jenkins-prepare-cargo-env.sh @@ -28,7 +28,7 @@ if [[ -z "${RUSTUP_HOME:-}" && -n "${ORIGINAL_HOME}" && -d "${ORIGINAL_HOME}/.ru fi # HOME 会在下面切到组件级缓存目录,因此这里先把真实用户的 Rust 工具链目录补进 PATH。 -# Server-Provision 通过 cargo install 安装的 sccache 通常会落在 /root/.cargo/bin。 +# Jenkins 构建节点预装的 Rust 工具和 sccache 通常会落在 /root/.cargo/bin。 for tool_dir in "${ORIGINAL_HOME}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then export PATH="${tool_dir}:${PATH}" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index e5d4e943..51a6b216 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -56,6 +56,18 @@ run_cmd() { fi } +require_root_for_real_provision() { + if [[ "${DRY_RUN}" == "true" ]]; then + return + fi + + if [[ "$(id -u)" != "0" ]]; then + echo "[server-provision] 非 dry-run 会安装系统包、写入 systemd/Nginx 和创建系统用户,必须在 root agent 上执行。" >&2 + echo "[server-provision] 当前用户: $(id -un) uid=$(id -u)。请确认 DEPLOY_TARGET=${DEPLOY_TARGET:-} 对应的目标服务器 agent 以 root 运行,或保持 DRY_RUN=true。" >&2 + exit 1 + fi +} + install_file() { local source="$1" local target="$2" @@ -66,21 +78,6 @@ install_file() { fi } -install_build_dependencies() { - echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers" - if command -v apt-get >/dev/null 2>&1; then - run_cmd apt-get update - run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates - elif command -v dnf >/dev/null 2>&1; then - run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates - elif command -v yum >/dev/null 2>&1; then - run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates - else - echo "[server-provision] 未找到 apt-get/dnf/yum,无法自动安装 clang/lld。请手动安装后重跑构建。" >&2 - exit 1 - fi -} - install_nginx_brotli_modules() { echo "[server-provision] 安装 Nginx Brotli 动态模块依赖" if command -v apt-get >/dev/null 2>&1; then @@ -90,41 +87,6 @@ install_nginx_brotli_modules() { 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 - export PATH="${tool_dir}:${PATH}" - fi - done - - if command -v sccache >/dev/null 2>&1; then - echo "[server-provision] sccache 已存在: $(command -v sccache)" - return - fi - - if [[ -x /root/.cargo/bin/sccache ]]; then - echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache" - return - fi - - echo "[server-provision] 未找到 sccache,准备通过 cargo install sccache 安装。" - 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 - exit 1 - fi -} - sync_otelcol_install() { local target_bin="/usr/local/bin/otelcol-contrib" local source_bin="${OTELCOL_BIN_SOURCE}" @@ -142,7 +104,7 @@ sync_otelcol_install() { if [[ ! -x "${resolved_source}" ]]; then echo "[server-provision] otelcol-contrib 不存在或不可执行: ${source_bin}" >&2 - echo "[server-provision] 请先在构建机准备好 otelcol-contrib ${version},再通过 provision-tools 上传到目标机。" >&2 + echo "[server-provision] 请确认 Prepare Provision Tools 已在目标 agent 生成 otelcol-contrib ${version}: ${source_bin}" >&2 exit 1 fi @@ -671,9 +633,8 @@ 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 +require_root_for_real_provision 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 /var/lib/genarrative/tracking-outbox /var/lib/genarrative/database-backups if ! id spacetimedb >/dev/null 2>&1; then diff --git a/scripts/miniprogram-web-view-auth.test.ts b/scripts/miniprogram-web-view-auth.test.ts index 388677e0..ccf24a85 100644 --- a/scripts/miniprogram-web-view-auth.test.ts +++ b/scripts/miniprogram-web-view-auth.test.ts @@ -17,18 +17,24 @@ type MiniProgramPage = { data: Record; setData: (patch: Record) => void; onLoad: (query?: Record) => Promise; + onShareAppMessage: () => Record; + onShareTimeline: () => Record; onShow: () => void; consumePayResult: () => void; }; function createWxMock() { return { + getAccountInfoSync: vi.fn(() => ({ + miniProgram: { envVersion: 'release' }, + })), getStorageSync: vi.fn(() => ''), getSystemInfoSync: vi.fn(() => ({ platform: 'ios' })), login: vi.fn(), navigateBack: vi.fn(), removeStorageSync: vi.fn(), request: vi.fn(), + showShareMenu: vi.fn(), setStorageSync: vi.fn(), }; } @@ -54,6 +60,8 @@ function loadWebViewPage( if (requestPath === '../../config') { return { API_BASE_URL: 'https://www.genarrative.world/', + DEV_API_BASE_URL: 'https://dev.genarrative.world/', + DEV_WEB_VIEW_ENTRY_URL: 'https://dev.genarrative.world/', MINI_PROGRAM_APP_ID: 'wx-test-app', MINI_PROGRAM_ENV: 'release', WEB_VIEW_ENTRY_URL: 'https://www.genarrative.world/', @@ -91,7 +99,7 @@ describe('mini-program web-view auth page', () => { vi.clearAllMocks(); }); - test('默认进入时刷新微信小程序登录态后打开 web-view', async () => { + test('默认进入时不预登录,直接打开未登录 web-view', async () => { const wxMock = createWxMock(); wxMock.login.mockImplementation(({ success }) => { success({ code: 'wx-login-code' }); @@ -109,19 +117,58 @@ describe('mini-program web-view auth page', () => { await page.onLoad({}); - expect(wxMock.login).toHaveBeenCalledTimes(1); - expect(wxMock.request).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://www.genarrative.world/api/auth/wechat/miniprogram-login', - method: 'POST', - data: { code: 'wx-login-code' }, - }), - ); + expect(wxMock.login).not.toHaveBeenCalled(); + expect(wxMock.request).not.toHaveBeenCalled(); expect(page.data.loading).toBe(false); expect(page.data.phoneBindingRequired).toBe(false); expect(page.data.webViewUrl).toBe( - 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program#auth_provider=wechat&auth_token=jwt-active-wechat&auth_binding_status=active', + 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', ); + expect(wxMock.showShareMenu).toHaveBeenCalledWith({ + withShareTicket: true, + menus: ['shareAppMessage', 'shareTimeline'], + }); + }); + + test('默认进入时即便微信新身份待绑手机号,也不弹出绑定手机号页', async () => { + const wxMock = createWxMock(); + wxMock.login.mockImplementation(({ success }) => { + success({ code: 'wx-login-code' }); + }); + wxMock.request.mockImplementation(({ success }) => { + success({ + statusCode: 200, + data: { + token: 'jwt-pending-wechat', + bindingStatus: 'pending_bind_phone', + }, + }); + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({}); + + expect(wxMock.login).not.toHaveBeenCalled(); + expect(wxMock.request).not.toHaveBeenCalled(); + expect(page.data.loading).toBe(false); + expect(page.data.phoneBindingRequired).toBe(false); + expect(page.data.webViewUrl).toBe( + 'https://www.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program', + ); + }); + + test('web-view 页面分享好友和朋友圈都回到小程序 web-view 入口', () => { + const wxMock = createWxMock(); + const page = loadWebViewPage(wxMock); + + expect(page.onShareAppMessage()).toEqual({ + title: '陶泥儿', + path: '/pages/web-view/index', + }); + expect(page.onShareTimeline()).toEqual({ + title: '陶泥儿', + query: '', + }); }); test('默认匿名进入 web-view 仍不依赖 API_BASE_URL 配置', async () => { @@ -140,6 +187,51 @@ describe('mini-program web-view auth page', () => { ); }); + test('体验版自动切到 dev 子域名并透传 trial 环境', async () => { + const wxMock = createWxMock(); + wxMock.getAccountInfoSync.mockReturnValue({ + miniProgram: { envVersion: 'trial' }, + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({}); + + expect(page.data.webViewUrl).toBe( + 'https://dev.genarrative.world/?clientType=mini_program&clientRuntime=wechat_mini_program&miniProgramEnv=trial', + ); + }); + + test('开发版自动切到 dev 子域名并把 develop 规整为 dev', async () => { + const wxMock = createWxMock(); + wxMock.getAccountInfoSync.mockReturnValue({ + miniProgram: { envVersion: 'develop' }, + }); + wxMock.login.mockImplementation(({ success }) => { + success({ code: 'wx-login-code' }); + }); + wxMock.request.mockImplementation(({ success }) => { + success({ + statusCode: 200, + data: { + token: 'jwt-pending-wechat', + bindingStatus: 'pending_bind_phone', + }, + }); + }); + const page = loadWebViewPage(wxMock); + + await page.onLoad({ authAction: 'login', returnTo: 'previous' }); + + expect(wxMock.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://dev.genarrative.world/api/auth/wechat/miniprogram-login', + header: expect.objectContaining({ + 'x-mini-program-env': 'dev', + }), + }), + ); + }); + test('onShow 二次检查支付结果并写回 web-view hash', () => { const wxMock = createWxMock(); wxMock.getStorageSync.mockImplementation((key) => diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index e80fbd9c..4024b992 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2436,6 +2436,7 @@ dependencies = [ "sha2", "time", "tokio", + "tracing", ] [[package]] diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index c07bdcf4..28b54d93 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -54,7 +54,7 @@ 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", "sync", "fs", "io-util"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] } tokio-stream = { workspace = true } futures-util = { workspace = true } time = { workspace = true, features = ["formatting"] } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6bc88ace..2d478ed2 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -877,6 +877,46 @@ mod tests { ); } + #[tokio::test] + async fn readyz_reports_readiness_and_draining_state() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let app = build_router(state.clone()); + + let ready_response = app + .clone() + .oneshot( + Request::builder() + .uri("/readyz") + .header("x-request-id", "req-ready") + .body(Body::empty()) + .expect("readyz request should build"), + ) + .await + .expect("readyz request should succeed"); + assert_eq!(ready_response.status(), StatusCode::OK); + let ready_body = read_json_response(ready_response).await; + assert_eq!(ready_body["ok"], Value::Bool(true)); + assert_eq!(ready_body["ready"], Value::Bool(true)); + + state.mark_not_ready(); + let draining_response = app + .oneshot( + Request::builder() + .uri("/readyz") + .header("x-request-id", "req-draining") + .body(Body::empty()) + .expect("readyz request should build"), + ) + .await + .expect("readyz request should succeed"); + assert_eq!(draining_response.status(), StatusCode::SERVICE_UNAVAILABLE); + let draining_body = read_json_response(draining_response).await; + assert_eq!( + draining_body["error"]["details"]["reason"], + "api_server_draining" + ); + } + #[tokio::test] async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() { let app = build_internal_creative_agent_app(); diff --git a/server-rs/crates/api-server/src/backpressure.rs b/server-rs/crates/api-server/src/backpressure.rs index 3fc2b689..1f6baf7a 100644 --- a/server-rs/crates/api-server/src/backpressure.rs +++ b/server-rs/crates/api-server/src/backpressure.rs @@ -102,7 +102,7 @@ fn reject_overloaded_request(request: &Request) -> Response { } fn should_bypass_backpressure(request: &Request) -> bool { - request.uri().path() == "/healthz" + matches!(request.uri().path(), "/healthz" | "/readyz") } fn classify_request_permit_pool(path: &str) -> HttpRequestPermitPoolKind { @@ -200,6 +200,7 @@ mod tests { .route("/held", get(held_request)) .route("/fast", get(fast_request)) .route("/healthz", get(fast_request)) + .route("/readyz", get(fast_request)) .layer(middleware::from_fn_with_state( backpressure_state, limit_concurrent_requests, @@ -297,6 +298,13 @@ mod tests { .expect("healthz request should complete"); assert_eq!(health_response.status(), StatusCode::OK); + let ready_response = app + .clone() + .oneshot(test_request("/readyz")) + .await + .expect("readyz request should complete"); + assert_eq!(ready_response.status(), StatusCode::OK); + gate.release.notify_one(); let completed_response = held_response .await diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 263c7556..3fe02061 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -25,6 +25,7 @@ pub struct AppConfig { pub gallery_max_concurrent_requests: Option, pub detail_max_concurrent_requests: Option, pub admin_max_concurrent_requests: Option, + pub shutdown_outbox_flush_timeout: Duration, pub tracking_outbox_enabled: bool, pub tracking_outbox_dir: PathBuf, pub tracking_outbox_batch_size: usize, @@ -169,6 +170,7 @@ impl Default for AppConfig { gallery_max_concurrent_requests: None, detail_max_concurrent_requests: None, admin_max_concurrent_requests: None, + shutdown_outbox_flush_timeout: Duration::from_millis(5_000), tracking_outbox_enabled: true, tracking_outbox_dir: PathBuf::from("server-rs/.data/tracking-outbox"), tracking_outbox_batch_size: 500, @@ -365,6 +367,11 @@ impl AppConfig { { config.admin_max_concurrent_requests = Some(max_concurrent_requests); } + if let Some(timeout_ms) = + read_first_positive_u64_env(&["GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"]) + { + config.shutdown_outbox_flush_timeout = Duration::from_millis(timeout_ms); + } if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_TRACKING_OUTBOX_ENABLED"]) { config.tracking_outbox_enabled = enabled; } @@ -1324,6 +1331,7 @@ mod tests { 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_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"); 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"); @@ -1336,6 +1344,7 @@ mod tests { 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_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS", "3000"); std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_ENABLED", "false"); std::env::set_var( "GENARRATIVE_TRACKING_OUTBOX_DIR", @@ -1354,6 +1363,10 @@ mod tests { 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_eq!( + config.shutdown_outbox_flush_timeout, + std::time::Duration::from_millis(3_000) + ); assert!(!config.tracking_outbox_enabled); assert_eq!( config.tracking_outbox_dir, @@ -1374,6 +1387,7 @@ mod tests { 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_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS"); 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"); diff --git a/server-rs/crates/api-server/src/health.rs b/server-rs/crates/api-server/src/health.rs index ba043e02..ee83a012 100644 --- a/server-rs/crates/api-server/src/health.rs +++ b/server-rs/crates/api-server/src/health.rs @@ -1,7 +1,15 @@ -use axum::{Json, extract::Extension}; +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; use serde_json::{Value, json}; -use crate::{api_response::json_success_body, request_context::RequestContext}; +use crate::{ + api_response::json_success_body, http_error::AppError, request_context::RequestContext, + state::AppState, +}; pub async fn health_check(Extension(request_context): Extension) -> Json { json_success_body( @@ -12,3 +20,28 @@ pub async fn health_check(Extension(request_context): Extension) }), ) } + +pub async fn readiness_check( + State(state): State, + Extension(request_context): Extension, +) -> Response { + if state.is_ready() { + return json_success_body( + Some(&request_context), + json!({ + "ok": true, + "ready": true, + "service": "genarrative-api-server", + }), + ) + .into_response(); + } + + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("api-server 正在退出,不再接收新流量") + .with_details(json!({ + "reason": "api_server_draining", + "ready": false, + })) + .into_response_with_context(Some(&request_context)) +} diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 0c511311..fc9ee2e4 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -99,25 +99,35 @@ use shared_logging::{OtelConfig, init_tracing}; use socket2::{Domain, Protocol, Socket, Type}; use std::{ collections::HashSet, - env, fs, io, + env, fs, future, io, net::{SocketAddr, TcpListener as StdTcpListener}, - panic, thread, + panic, + sync::Arc, + thread, time::Duration, }; use tokio::net::TcpListener; use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio::time::timeout; -use tracing::{error, info}; +use tracing::{error, info, warn}; use crate::{ app::{build_router, build_spacetime_unavailable_router}, config::AppConfig, state::{AppState, AppStateInitError}, + tracking_outbox::TrackingOutbox, }; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8); +#[derive(Clone)] +struct ShutdownContext { + app_state: Option, + tracking_outbox: Option>, + outbox_flush_timeout: Duration, +} + fn main() -> Result<(), io::Error> { // Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。 let server_thread = thread::Builder::new() @@ -158,19 +168,33 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { let listen_backlog = config.listen_backlog; let worker_threads = config.worker_threads; let otel_enabled = config.otel_enabled; + let outbox_flush_timeout = config.shutdown_outbox_flush_timeout; let listener = build_tcp_listener(bind_address, listen_backlog)?; - let router = match restore_app_state_for_startup(config).await { + let (router, shutdown_context) = match restore_app_state_for_startup(config).await { Ok(state) => { state.puzzle_gallery_cache().spawn_cleanup_task(); - if let Some(outbox) = state.tracking_outbox() { + let tracking_outbox = state.tracking_outbox(); + if let Some(outbox) = tracking_outbox.clone() { outbox.spawn_worker(); } - build_router(state) - } - Err(AppStateInitError::DependencyUnavailable(message)) => { - build_spacetime_unavailable_router(message) + ( + build_router(state.clone()), + ShutdownContext { + app_state: Some(state), + tracking_outbox, + outbox_flush_timeout, + }, + ) } + Err(AppStateInitError::DependencyUnavailable(message)) => ( + build_spacetime_unavailable_router(message), + ShutdownContext { + app_state: None, + tracking_outbox: None, + outbox_flush_timeout, + }, + ), Err(error) => { return Err(std::io::Error::other(format!( "初始化应用状态失败:{error}" @@ -186,7 +210,98 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { "api-server 已完成 tracing 初始化并开始监听" ); - axum::serve(listener, router).await + let result = axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal(shutdown_context.clone())) + .await; + finalize_shutdown(shutdown_context).await; + result +} + +async fn shutdown_signal(context: ShutdownContext) { + let signal = wait_for_shutdown_signal().await; + if let Some(state) = context.app_state.as_ref() { + state.mark_not_ready(); + } + info!( + signal, + "api-server 收到退出信号,已标记 readiness 不可用并开始排空 HTTP 请求" + ); +} + +async fn wait_for_shutdown_signal() -> &'static str { + #[cfg(unix)] + { + tokio::select! { + signal = wait_for_ctrl_c_signal() => signal, + signal = wait_for_sigterm_signal() => signal, + } + } + + #[cfg(not(unix))] + { + wait_for_ctrl_c_signal().await + } +} + +async fn wait_for_ctrl_c_signal() -> &'static str { + if let Err(error) = tokio::signal::ctrl_c().await { + error!(error = %error, "监听 SIGINT 失败,无法通过 Ctrl-C 触发优雅退出"); + future::pending::<()>().await; + } + "sigint" +} + +#[cfg(unix)] +async fn wait_for_sigterm_signal() -> &'static str { + let mut signal = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + Ok(signal) => signal, + Err(error) => { + error!(error = %error, "监听 SIGTERM 失败,无法通过 systemd terminate 触发优雅退出"); + future::pending::<()>().await; + unreachable!("pending future never returns"); + } + }; + signal.recv().await; + "sigterm" +} + +async fn finalize_shutdown(context: ShutdownContext) { + if let Some(state) = context.app_state.as_ref() { + state.mark_not_ready(); + } + + let Some(outbox) = context.tracking_outbox else { + return; + }; + + if context.outbox_flush_timeout.is_zero() { + warn!("api-server 退出时 tracking outbox flush timeout 为 0,跳过主动 flush"); + return; + } + + let timeout_ms = context + .outbox_flush_timeout + .as_millis() + .min(u128::from(u64::MAX)) as u64; + info!(timeout_ms, "api-server 退出前封存并 flush tracking outbox"); + match timeout(context.outbox_flush_timeout, outbox.flush_for_shutdown()).await { + Ok(Ok(())) => { + info!("api-server 退出前 tracking outbox flush 完成"); + } + Ok(Err(error)) => { + warn!( + error = %error, + "api-server 退出前 tracking outbox flush 未完成,已保留本地文件等待下次启动重试" + ); + } + Err(_) => { + warn!( + timeout_ms, + "api-server 退出前 tracking outbox flush 超时,已保留本地文件等待下次启动重试" + ); + } + } } fn build_tcp_listener( diff --git a/server-rs/crates/api-server/src/modules/health.rs b/server-rs/crates/api-server/src/modules/health.rs index 5e2f19ac..dd488807 100644 --- a/server-rs/crates/api-server/src/modules/health.rs +++ b/server-rs/crates/api-server/src/modules/health.rs @@ -1,7 +1,12 @@ use axum::{Router, routing::get}; -use crate::{health::health_check, state::AppState}; +use crate::{ + health::{health_check, readiness_check}, + state::AppState, +}; pub fn router(_state: AppState) -> Router { - Router::new().route("/healthz", get(health_check)) + Router::new() + .route("/healthz", get(health_check)) + .route("/readyz", get(readiness_check)) } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 4d178687..d4bbb445 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -2,7 +2,10 @@ use std::{ collections::HashMap, error::Error, fmt, - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, }; use axum::extract::FromRef; @@ -229,6 +232,7 @@ pub struct AppStateInner { // 配置会在后续中间件、路由和平台适配接入时逐步消费。 #[allow(dead_code)] pub config: AppConfig, + ready: AtomicBool, http_request_permit_pools: HttpRequestPermitPools, auth_jwt_config: JwtConfig, admin_runtime: Option, @@ -399,6 +403,7 @@ impl AppState { Ok(Self(Arc::new(AppStateInner { config, + ready: AtomicBool::new(true), http_request_permit_pools, auth_jwt_config, admin_runtime, @@ -447,6 +452,14 @@ impl AppState { self.http_request_permit_pools.clone() } + pub fn is_ready(&self) -> bool { + self.ready.load(Ordering::Acquire) + } + + pub fn mark_not_ready(&self) { + self.ready.store(false, Ordering::Release); + } + pub async fn upsert_creation_entry_type_config( &self, input: module_runtime::CreationEntryTypeAdminUpsertInput, diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs index cf2b4a97..eb04762b 100644 --- a/server-rs/crates/api-server/src/tracking_outbox.rs +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -159,6 +159,16 @@ impl TrackingOutbox { }); } + pub async fn flush_for_shutdown(&self) -> Result<(), TrackingOutboxError> { + { + let mut inner = self.inner.lock().await; + self.ensure_initialized_locked(&mut inner).await?; + self.seal_active_locked(&mut inner, "shutdown").await?; + } + + self.flush_sealed_files_once().await + } + async fn seal_active_if_due(&self) -> Result<(), TrackingOutboxError> { let mut inner = self.inner.lock().await; self.ensure_initialized_locked(&mut inner).await?; @@ -176,7 +186,11 @@ impl TrackingOutbox { 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 metadata = match fs::metadata(&path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + }; let file_bytes = metadata.len(); let events = match read_outbox_events(&path).await { Ok(events) => events, @@ -203,7 +217,11 @@ impl TrackingOutbox { match self.spacetime_client.record_tracking_events(events).await { Ok(accepted_count) => { - fs::remove_file(&path).await?; + match fs::remove_file(&path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } self.subtract_total_bytes(file_bytes).await; crate::telemetry::record_tracking_outbox_flush( started_at.elapsed(), @@ -596,6 +614,34 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn shutdown_flush_seals_active_file_for_later_retry() { + let dir = test_dir("shutdown"); + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + + outbox.enqueue(sample_event("event-1")).await.unwrap(); + let result = outbox.flush_for_shutdown().await; + + assert!( + matches!(result, Err(TrackingOutboxError::Spacetime(_))), + "missing test SpacetimeDB should keep sealed file for retry" + ); + 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); + + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn directory_size_excludes_quarantined_corrupt_files() { let dir = test_dir("directory-size"); diff --git a/server-rs/crates/platform-image/Cargo.toml b/server-rs/crates/platform-image/Cargo.toml index f71fe161..b5a6feca 100644 --- a/server-rs/crates/platform-image/Cargo.toml +++ b/server-rs/crates/platform-image/Cargo.toml @@ -9,6 +9,6 @@ base64 = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } serde_json = { workspace = true } -tokio = { workspace = true, features = ["time"] } +tokio = { workspace = true, features = ["io-util", "macros", "net", "time"] } tracing = { workspace = true } platform-oss = { workspace = true } diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index b7a31084..afae18da 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -1,11 +1,15 @@ -use reqwest::header; +use reqwest::{header, multipart}; + +const VECTOR_ENGINE_SEND_MAX_ATTEMPTS: u32 = 3; +const VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS: u64 = 500; use super::{ constants::{GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER}, error::PlatformImageError, image_source::resolve_reference_images, request::{ - build_prompt_with_negative, build_vector_engine_image_request_body, normalize_image_size, + build_prompt_with_negative, build_vector_engine_image_edit_request_log_params, + build_vector_engine_image_request_body, normalize_image_size, vector_engine_images_edit_url, vector_engine_images_generation_url, }, response::handle_vector_engine_response, @@ -49,29 +53,49 @@ pub async fn create_vector_engine_image_generation( reference_images, ); let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - { - Ok(response) => response, - Err(error) => { - return Err(map_reqwest_error( - format!("{failure_context}:创建图片生成任务失败").as_str(), - request_url.as_str(), - "request_send", - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_images.len()), - )); + let mut attempt = 1; + let response = loop { + match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + { + Ok(response) => break response, + Err(error) => { + if should_retry_vector_engine_send_error(&error, attempt) { + retry_vector_engine_send_after_delay( + "generation", + request_url.as_str(), + "request_send", + attempt, + &error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + Some(&request_body), + ) + .await; + attempt += 1; + continue; + } + return Err(map_reqwest_error( + format!("{failure_context}:创建图片生成任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + Some(&request_body), + )); + } } }; let response_status = response.status(); @@ -82,6 +106,7 @@ pub async fn create_vector_engine_image_generation( prompt_chars = prompt.chars().count(), size = %normalized_size, reference_image_count = reference_images.len(), + attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, "VectorEngine 图片生成 HTTP 返回" @@ -97,6 +122,7 @@ pub async fn create_vector_engine_image_generation( started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), Some(reference_images.len()), + Some(&request_body), )); } }; @@ -156,51 +182,91 @@ pub async fn create_vector_engine_image_edit_with_references( let request_url = vector_engine_images_edit_url(settings); let normalized_size = normalize_image_size(size); - - let mut form = reqwest::multipart::Form::new() - .text("model", GPT_IMAGE_2_MODEL.to_string()) - .text( - "prompt", - build_prompt_with_negative(prompt, negative_prompt), - ) - .text("n", candidate_count.clamp(1, 4).to_string()) - .text("size", normalized_size.clone()); - - for reference_image in reference_images.iter().take(5) { - let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) - .file_name(reference_image.file_name.clone()) - .mime_str(reference_image.mime_type.as_str()) - .map_err(|error| PlatformImageError::InvalidRequest { - provider: VECTOR_ENGINE_PROVIDER, - message: format!("{failure_context}:构造参考图失败:{error}"), - })?; - form = form.part("image", image_part); - } + let request_params = build_vector_engine_image_edit_request_log_params( + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + ); let reference_image_count = reference_images.iter().take(5).count(); + let reference_image_bytes_total: usize = reference_images + .iter() + .take(5) + .map(|image| image.bytes.len()) + .sum(); let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - { - Ok(response) => response, - Err(error) => { - return Err(map_reqwest_error( - format!("{failure_context}:创建图片编辑任务失败").as_str(), - request_url.as_str(), - "request_send", - error, - started_at.elapsed().as_millis() as u64, - Some(prompt.chars().count()), - Some(reference_image_count), - )); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_model = GPT_IMAGE_2_MODEL, + size = %normalized_size, + candidate_count = candidate_count.clamp(1, 4), + requested_candidate_count = candidate_count, + prompt_chars = prompt.trim().chars().count(), + negative_prompt_chars = negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::chars) + .map(Iterator::count) + .unwrap_or_default(), + reference_image_count, + reference_image_bytes_total, + request_params = %request_params, + failure_context, + "VectorEngine 图片编辑请求参数" + ); + let mut attempt = 1; + let response = loop { + let form = build_vector_engine_image_edit_form( + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + failure_context, + )?; + match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + { + Ok(response) => break response, + Err(error) => { + if should_retry_vector_engine_send_error(&error, attempt) { + retry_vector_engine_send_after_delay( + "edit", + request_url.as_str(), + "request_send", + attempt, + &error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + ) + .await; + attempt += 1; + continue; + } + return Err(map_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + Some(&request_params), + )); + } } }; let response_status = response.status(); @@ -211,6 +277,9 @@ pub async fn create_vector_engine_image_edit_with_references( prompt_chars = prompt.chars().count(), size = %normalized_size, reference_image_count, + reference_image_bytes_total, + request_params = %request_params, + attempt, elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, "VectorEngine 图片编辑 HTTP 返回" @@ -226,6 +295,7 @@ pub async fn create_vector_engine_image_edit_with_references( started_at.elapsed().as_millis() as u64, Some(prompt.chars().count()), Some(reference_image_count), + Some(&request_params), )); } }; @@ -243,3 +313,75 @@ pub async fn create_vector_engine_image_edit_with_references( ) .await } + +fn build_vector_engine_image_edit_form( + prompt: &str, + negative_prompt: Option<&str>, + normalized_size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], + failure_context: &str, +) -> Result { + let mut form = multipart::Form::new() + .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text( + "prompt", + build_prompt_with_negative(prompt, negative_prompt), + ) + .text("n", candidate_count.clamp(1, 4).to_string()) + .text("size", normalized_size.to_string()); + + for reference_image in reference_images.iter().take(5) { + let image_part = multipart::Part::bytes(reference_image.bytes.clone()) + .file_name(reference_image.file_name.clone()) + .mime_str(reference_image.mime_type.as_str()) + .map_err(|error| PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:构造参考图失败:{error}"), + })?; + form = form.part("image", image_part); + } + + Ok(form) +} + +fn should_retry_vector_engine_send_error(error: &reqwest::Error, attempt: u32) -> bool { + attempt < VECTOR_ENGINE_SEND_MAX_ATTEMPTS && (error.is_timeout() || error.is_connect()) +} + +async fn retry_vector_engine_send_after_delay( + request_kind: &'static str, + request_url: &str, + failure_stage: &'static str, + attempt: u32, + error: &reqwest::Error, + elapsed_ms: u64, + prompt_chars: Option, + reference_image_count: Option, + request_params: Option<&serde_json::Value>, +) { + let delay_ms = VECTOR_ENGINE_SEND_RETRY_BASE_DELAY_MS * u64::from(attempt); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + request_kind, + failure_stage, + attempt, + max_attempts = VECTOR_ENGINE_SEND_MAX_ATTEMPTS, + retry_delay_ms = delay_ms, + timeout = error.is_timeout(), + connect = error.is_connect(), + request = error.is_request(), + body = error.is_body(), + status = error.status().map(|status| status.as_u16()).unwrap_or_default(), + error = %error, + elapsed_ms, + prompt_chars, + reference_image_count, + request_params = %request_params + .map(|value| value.to_string()) + .unwrap_or_default(), + "VectorEngine 图片请求发送失败,准备重试" + ); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; +} diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index 10a5c06b..656d07d7 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -1,6 +1,9 @@ use serde_json::{Map, Value, json}; -use super::{constants::GPT_IMAGE_2_MODEL, types::VectorEngineImageSettings}; +use super::{ + constants::GPT_IMAGE_2_MODEL, + types::{ReferenceImage, VectorEngineImageSettings}, +}; pub fn build_vector_engine_image_request_body( prompt: &str, @@ -56,6 +59,52 @@ pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> St } } +pub(crate) fn build_vector_engine_image_edit_request_log_params( + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], +) -> Value { + let prompt = prompt.trim(); + let negative_prompt = negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()); + let references: Vec = reference_images + .iter() + .take(5) + .enumerate() + .map(|(index, image)| { + json!({ + "index": index, + "field": "image", + "fileName": image.file_name.as_str(), + "mimeType": image.mime_type.as_str(), + "bytes": image.bytes.len(), + }) + }) + .collect(); + let reference_image_bytes_total: usize = reference_images + .iter() + .take(5) + .map(|image| image.bytes.len()) + .sum(); + + json!({ + "model": GPT_IMAGE_2_MODEL, + "prompt": prompt, + "negativePrompt": negative_prompt.unwrap_or_default(), + "promptChars": prompt.chars().count(), + "negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count), + "n": candidate_count.clamp(1, 4), + "requestedCandidateCount": candidate_count, + "size": size, + "referenceImageCount": references.len(), + "referenceImageBytesTotal": reference_image_bytes_total, + "referenceImages": references, + }) +} + pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { let prompt = prompt.trim(); let Some(negative_prompt) = negative_prompt @@ -67,3 +116,49 @@ pub(crate) fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<& format!("{prompt}\n避免:{negative_prompt}") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::vector_engine::types::ReferenceImage; + + #[test] + fn edit_request_log_params_include_reference_image_sizes_without_secrets_or_bytes() { + let params = build_vector_engine_image_edit_request_log_params( + " 拼图参考图重绘 ", + Some(" 文字,水印 "), + "1024x1024", + 9, + &[ + ReferenceImage { + bytes: vec![1, 2, 3, 4, 5], + mime_type: "image/png".to_string(), + file_name: "reference-a.png".to_string(), + }, + ReferenceImage { + bytes: vec![8; 7], + mime_type: "image/jpeg".to_string(), + file_name: "reference-b.jpg".to_string(), + }, + ], + ); + + assert_eq!(params["model"], GPT_IMAGE_2_MODEL); + assert_eq!(params["prompt"], "拼图参考图重绘"); + assert_eq!(params["negativePrompt"], "文字,水印"); + assert_eq!(params["n"], 4); + assert_eq!(params["requestedCandidateCount"], 9); + assert_eq!(params["size"], "1024x1024"); + assert_eq!(params["referenceImageCount"], 2); + assert_eq!(params["referenceImageBytesTotal"], 12); + assert_eq!(params["referenceImages"][0]["field"], "image"); + assert_eq!(params["referenceImages"][0]["fileName"], "reference-a.png"); + assert_eq!(params["referenceImages"][0]["mimeType"], "image/png"); + assert_eq!(params["referenceImages"][0]["bytes"], 5); + + let serialized = params.to_string(); + assert!(!serialized.contains("api_key")); + assert!(!serialized.contains("Bearer")); + assert!(!serialized.contains("[1,2,3,4,5]")); + } +} diff --git a/server-rs/crates/platform-image/src/vector_engine/transport.rs b/server-rs/crates/platform-image/src/vector_engine/transport.rs index a40819da..c74d6e04 100644 --- a/server-rs/crates/platform-image/src/vector_engine/transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/transport.rs @@ -1,5 +1,7 @@ use std::{error::Error, time::Duration}; +use serde_json::Value; + use super::{ audit::build_failure_audit, constants::VECTOR_ENGINE_PROVIDER, error::PlatformImageError, types::VectorEngineImageSettings, @@ -27,6 +29,7 @@ pub(super) fn map_reqwest_error( latency_ms: u64, prompt_chars: Option, reference_image_count: Option, + request_params: Option<&Value>, ) -> PlatformImageError { let is_timeout = error.is_timeout(); let is_connect = error.is_connect(); @@ -70,6 +73,9 @@ pub(super) fn map_reqwest_error( elapsed_ms = latency_ms, prompt_chars, reference_image_count, + request_params = %request_params + .map(|value| value.to_string()) + .unwrap_or_default(), "VectorEngine 图片请求发送失败" ); diff --git a/server-rs/crates/platform-image/tests/vector_engine.rs b/server-rs/crates/platform-image/tests/vector_engine.rs index e9bfb1e0..c53d63c2 100644 --- a/server-rs/crates/platform-image/tests/vector_engine.rs +++ b/server-rs/crates/platform-image/tests/vector_engine.rs @@ -1,8 +1,20 @@ use platform_image::vector_engine::{ - GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, - build_vector_engine_image_request_body, vector_engine_images_edit_url, + GPT_IMAGE_2_MODEL, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, + build_vector_engine_image_http_client, build_vector_engine_image_request_body, + create_vector_engine_image_edit, vector_engine_images_edit_url, vector_engine_images_generation_url, }; +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; #[test] fn vector_engine_module_exposes_provider_protocol_helpers() { @@ -30,3 +42,70 @@ fn vector_engine_module_exposes_provider_protocol_helpers() { "https://vector.example/v1/images/edits" ); } + +#[tokio::test] +async fn vector_engine_image_edit_retries_send_timeout_once_and_succeeds() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("mock server should bind"); + let server_addr = listener + .local_addr() + .expect("mock server address should be readable"); + let request_count = Arc::new(AtomicUsize::new(0)); + let request_count_for_server = Arc::clone(&request_count); + + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let request_index = request_count_for_server.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buffer = [0_u8; 4096]; + let _ = stream.read(&mut buffer).await; + if request_index == 0 { + tokio::time::sleep(Duration::from_millis(120)).await; + return; + } + + let body = r#"{"data":[{"b64_json":"iVBORw0KGgpyZXN0"}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + + let settings = VectorEngineImageSettings { + base_url: format!("http://{server_addr}/v1"), + api_key: "test-key".to_string(), + request_timeout_ms: 40, + }; + let http_client = + build_vector_engine_image_http_client(&settings).expect("client should build"); + let reference_image = ReferenceImage { + bytes: b"reference".to_vec(), + mime_type: "image/png".to_string(), + file_name: "reference.png".to_string(), + }; + + let generated = create_vector_engine_image_edit( + &http_client, + &settings, + "测试提示词", + None, + "1024x1024", + &reference_image, + "测试 VectorEngine 图片编辑失败", + ) + .await + .expect("second attempt should return generated image"); + + assert_eq!(generated.images.len(), 1); + assert_eq!(generated.images[0].mime_type, "image/png"); + assert_eq!(request_count.load(Ordering::SeqCst), 2); + server.abort(); +} diff --git a/server-rs/crates/platform-oss/Cargo.toml b/server-rs/crates/platform-oss/Cargo.toml index 216e5955..2b8a9b9e 100644 --- a/server-rs/crates/platform-oss/Cargo.toml +++ b/server-rs/crates/platform-oss/Cargo.toml @@ -12,6 +12,7 @@ serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } time = { workspace = true, features = ["formatting"] } +tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index 025481d3..84d38def 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -22,6 +22,7 @@ 5. 服务端 `PutObject` 上传 helper 6. `x-oss-meta-*` 元数据归一化与大小限制校验 7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成 +8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志 当前仍未落地的内容: @@ -34,8 +35,9 @@ 1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。 2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。 3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。 - 4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。 - 5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。 +4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。 +5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。 +6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 ## 3. 边界约束 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index a9b3935e..656db455 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, error::Error, fmt}; +use std::{collections::BTreeMap, error::Error, fmt, time::Instant}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use hmac::{Hmac, Mac}; @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; +use tracing::{info, warn}; type HmacSha256 = Hmac; @@ -19,6 +20,7 @@ const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256"; const OSS_V4_REQUEST: &str = "aliyun_v4_request"; const OSS_V4_SERVICE: &str = "oss"; const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; +const OSS_PROVIDER: &str = "aliyun-oss"; pub const LEGACY_PUBLIC_PREFIXES: [&str; 13] = [ "generated-character-drafts", @@ -369,105 +371,154 @@ impl OssClient { &self, request: OssPostObjectRequest, ) -> Result { - let max_size_bytes = request - .max_size_bytes - .unwrap_or(self.config.default_post_max_size_bytes); - let expire_seconds = request - .expire_seconds - .unwrap_or(self.config.default_post_expire_seconds); - let success_action_status = request - .success_action_status - .unwrap_or(self.config.default_success_action_status); + let started_at = Instant::now(); + let requested_prefix = request.prefix.as_str(); + let requested_content_type = request + .content_type + .as_deref() + .map(str::trim) + .unwrap_or("") + .to_string(); + let requested_metadata_count = request.metadata.len(); - if max_size_bytes == 0 { - return Err(OssError::InvalidRequest( - "maxSizeBytes 必须大于 0".to_string(), - )); + let result = (|| { + let max_size_bytes = request + .max_size_bytes + .unwrap_or(self.config.default_post_max_size_bytes); + let expire_seconds = request + .expire_seconds + .unwrap_or(self.config.default_post_expire_seconds); + let success_action_status = request + .success_action_status + .unwrap_or(self.config.default_success_action_status); + + if max_size_bytes == 0 { + return Err(OssError::InvalidRequest( + "maxSizeBytes 必须大于 0".to_string(), + )); + } + + if expire_seconds == 0 { + return Err(OssError::InvalidRequest( + "expireSeconds 必须大于 0".to_string(), + )); + } + + if !(100..=999).contains(&success_action_status) { + return Err(OssError::InvalidRequest( + "successActionStatus 必须是三位 HTTP 状态码".to_string(), + )); + } + + let sanitized_segments = request + .path_segments + .iter() + .map(|segment| sanitize_path_segment(segment)) + .filter(|segment| !segment.is_empty()) + .collect::>(); + let file_name = sanitize_file_name(&request.file_name)?; + let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); + let legacy_public_path = format!("/{}", object_key); + let content_type = normalize_optional_value(request.content_type); + let metadata = normalize_metadata(request.metadata)?; + + let expires_at = OffsetDateTime::now_utc() + .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( + |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), + )?)) + .ok_or_else(|| { + OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()) + })?; + let expires_at = expires_at.format(&Rfc3339).map_err(|error| { + OssError::SerializePolicy(format!("格式化过期时间失败:{error}")) + })?; + + let signed_at = OffsetDateTime::now_utc(); + let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?; + let signature_date = build_v4_signature_date(signed_at)?; + let credential = format!("{}/{}", self.config.access_key_id, signature_scope); + let policy_json = build_policy_json( + &self.config.bucket, + &object_key, + &expires_at, + max_size_bytes, + success_action_status, + content_type.as_deref(), + &metadata, + &credential, + &signature_date, + ); + let policy = serde_json::to_string(&policy_json).map_err(|error| { + OssError::SerializePolicy(format!("序列化 policy 失败:{error}")) + })?; + let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes()); + let signature = sign_v4_content( + &self.config.access_key_secret, + &signature_scope, + &encoded_policy, + )?; + + Ok(OssPostObjectResponse { + signature_version: "v4", + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + host: self.config.upload_host(), + object_key: object_key.clone(), + legacy_public_path, + content_type: content_type.clone(), + access: request.access, + key_prefix: build_key_prefix(request.prefix, &sanitized_segments), + expires_at, + max_size_bytes, + success_action_status, + form_fields: OssPostObjectFormFields { + key: object_key, + policy: encoded_policy, + signature_version: OSS_V4_ALGORITHM.to_string(), + credential, + date: signature_date, + signature, + success_action_status: success_action_status.to_string(), + content_type, + metadata, + }, + }) + })(); + + match &result { + Ok(response) => info!( + provider = OSS_PROVIDER, + operation = "sign_post_object", + bucket = %response.bucket, + endpoint = %response.endpoint, + object_key = %response.object_key, + key_prefix = %response.key_prefix, + access = oss_access_label(response.access), + content_type = %response.content_type.as_deref().unwrap_or(""), + max_size_bytes = response.max_size_bytes, + success_action_status = response.success_action_status, + metadata_count = response.form_fields.metadata.len(), + expires_at = %response.expires_at, + elapsed_ms = elapsed_ms(started_at), + "OSS PostObject 签名完成" + ), + Err(error) => warn!( + provider = OSS_PROVIDER, + operation = "sign_post_object", + bucket = %self.config.bucket(), + endpoint = %self.config.endpoint(), + key_prefix = requested_prefix, + content_type = %requested_content_type, + metadata_count = requested_metadata_count, + error_kind = oss_error_kind_label(error), + message = %error, + elapsed_ms = elapsed_ms(started_at), + "OSS PostObject 签名失败" + ), } - if expire_seconds == 0 { - return Err(OssError::InvalidRequest( - "expireSeconds 必须大于 0".to_string(), - )); - } - - if !(100..=999).contains(&success_action_status) { - return Err(OssError::InvalidRequest( - "successActionStatus 必须是三位 HTTP 状态码".to_string(), - )); - } - - let sanitized_segments = request - .path_segments - .iter() - .map(|segment| sanitize_path_segment(segment)) - .filter(|segment| !segment.is_empty()) - .collect::>(); - let file_name = sanitize_file_name(&request.file_name)?; - let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); - let legacy_public_path = format!("/{}", object_key); - let content_type = normalize_optional_value(request.content_type); - let metadata = normalize_metadata(request.metadata)?; - - let expires_at = OffsetDateTime::now_utc() - .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( - |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), - )?)) - .ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?; - let expires_at = expires_at - .format(&Rfc3339) - .map_err(|error| OssError::SerializePolicy(format!("格式化过期时间失败:{error}")))?; - - let signed_at = OffsetDateTime::now_utc(); - let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?; - let signature_date = build_v4_signature_date(signed_at)?; - let credential = format!("{}/{}", self.config.access_key_id, signature_scope); - let policy_json = build_policy_json( - &self.config.bucket, - &object_key, - &expires_at, - max_size_bytes, - success_action_status, - content_type.as_deref(), - &metadata, - &credential, - &signature_date, - ); - let policy = serde_json::to_string(&policy_json) - .map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?; - let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes()); - let signature = sign_v4_content( - &self.config.access_key_secret, - &signature_scope, - &encoded_policy, - )?; - - Ok(OssPostObjectResponse { - signature_version: "v4", - provider: "aliyun-oss", - bucket: self.config.bucket.clone(), - endpoint: self.config.endpoint.clone(), - host: self.config.upload_host(), - object_key: object_key.clone(), - legacy_public_path, - content_type: content_type.clone(), - access: request.access, - key_prefix: build_key_prefix(request.prefix, &sanitized_segments), - expires_at, - max_size_bytes, - success_action_status, - form_fields: OssPostObjectFormFields { - key: object_key, - policy: encoded_policy, - signature_version: OSS_V4_ALGORITHM.to_string(), - credential, - date: signature_date, - signature, - success_action_status: success_action_status.to_string(), - content_type, - metadata, - }, - }) + result } // 私有 bucket 的对象读取统一走短期签名 URL,避免把长期主凭证下发给浏览器。 @@ -475,81 +526,119 @@ impl OssClient { &self, request: OssSignedGetObjectUrlRequest, ) -> Result { - let expire_seconds = request - .expire_seconds - .unwrap_or(self.config.default_read_expire_seconds); + let started_at = Instant::now(); + let requested_object_key = request + .object_key + .trim() + .trim_start_matches('/') + .trim() + .to_string(); - if expire_seconds == 0 { - return Err(OssError::InvalidRequest( - "expireSeconds 必须大于 0".to_string(), - )); + let result = (|| { + let expire_seconds = request + .expire_seconds + .unwrap_or(self.config.default_read_expire_seconds); + + if expire_seconds == 0 { + return Err(OssError::InvalidRequest( + "expireSeconds 必须大于 0".to_string(), + )); + } + + let object_key = normalize_object_key(&request.object_key)?; + let expires_at = OffsetDateTime::now_utc() + .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( + |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), + )?)) + .ok_or_else(|| { + OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()) + })?; + let expires_at_text = expires_at + .format(&Rfc3339) + .map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?; + + let signed_at = OffsetDateTime::now_utc(); + let signed_at_text = build_v4_signature_date(signed_at)?; + let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?; + let credential = format!("{}/{}", self.config.access_key_id, signature_scope); + let mut query = BTreeMap::from([ + ("x-oss-additional-headers".to_string(), "host".to_string()), + ( + "x-oss-signature-version".to_string(), + OSS_V4_ALGORITHM.to_string(), + ), + ("x-oss-credential".to_string(), credential), + ("x-oss-date".to_string(), signed_at_text), + ("x-oss-expires".to_string(), expire_seconds.to_string()), + ]); + let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key)); + let object_url_path = format!("/{}", encode_url_path(&object_key)); + let additional_headers = "host"; + let canonical_headers = + format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint()); + let canonical_query = build_canonical_query_string(&query); + let canonical_request = build_v4_canonical_request( + Method::GET.as_str(), + &canonical_uri, + &canonical_query, + &canonical_headers, + additional_headers, + OSS_UNSIGNED_PAYLOAD, + ); + let string_to_sign = build_v4_string_to_sign( + query["x-oss-date"].as_str(), + &signature_scope, + &canonical_request, + ); + let signature = sign_v4_content( + &self.config.access_key_secret, + &signature_scope, + &string_to_sign, + )?; + query.insert("x-oss-signature".to_string(), signature); + let signed_url = format!( + "{}{}?{}", + self.config.upload_host(), + object_url_path, + build_canonical_query_string(&query) + ); + + Ok(OssSignedGetObjectUrlResponse { + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + host: self.config.upload_host(), + object_key, + expires_at: expires_at_text, + signed_url, + }) + })(); + + match &result { + Ok(response) => info!( + provider = OSS_PROVIDER, + operation = "sign_get_object_url", + bucket = %response.bucket, + endpoint = %response.endpoint, + object_key = %response.object_key, + expires_at = %response.expires_at, + elapsed_ms = elapsed_ms(started_at), + "OSS GetObject 读签名完成" + ), + Err(error) => warn!( + provider = OSS_PROVIDER, + operation = "sign_get_object_url", + bucket = %self.config.bucket(), + endpoint = %self.config.endpoint(), + object_key = %requested_object_key, + error_kind = oss_error_kind_label(error), + message = %error, + elapsed_ms = elapsed_ms(started_at), + "OSS GetObject 读签名失败" + ), } - let object_key = normalize_object_key(&request.object_key)?; - let expires_at = OffsetDateTime::now_utc() - .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( - |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), - )?)) - .ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?; - let expires_at_text = expires_at - .format(&Rfc3339) - .map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?; - - let signed_at = OffsetDateTime::now_utc(); - let signed_at_text = build_v4_signature_date(signed_at)?; - let signature_scope = build_v4_signature_scope(&self.config.endpoint, signed_at)?; - let credential = format!("{}/{}", self.config.access_key_id, signature_scope); - let mut query = BTreeMap::from([ - ("x-oss-additional-headers".to_string(), "host".to_string()), - ( - "x-oss-signature-version".to_string(), - OSS_V4_ALGORITHM.to_string(), - ), - ("x-oss-credential".to_string(), credential), - ("x-oss-date".to_string(), signed_at_text), - ("x-oss-expires".to_string(), expire_seconds.to_string()), - ]); - let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key)); - let object_url_path = format!("/{}", encode_url_path(&object_key)); - let additional_headers = "host"; - let canonical_headers = - format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint()); - let canonical_query = build_canonical_query_string(&query); - let canonical_request = build_v4_canonical_request( - Method::GET.as_str(), - &canonical_uri, - &canonical_query, - &canonical_headers, - additional_headers, - OSS_UNSIGNED_PAYLOAD, - ); - let string_to_sign = build_v4_string_to_sign( - query["x-oss-date"].as_str(), - &signature_scope, - &canonical_request, - ); - let signature = sign_v4_content( - &self.config.access_key_secret, - &signature_scope, - &string_to_sign, - )?; - query.insert("x-oss-signature".to_string(), signature); - let signed_url = format!( - "{}{}?{}", - self.config.upload_host(), - object_url_path, - build_canonical_query_string(&query) - ); - - Ok(OssSignedGetObjectUrlResponse { - provider: "aliyun-oss", - bucket: self.config.bucket.clone(), - endpoint: self.config.endpoint.clone(), - host: self.config.upload_host(), - object_key, - expires_at: expires_at_text, - signed_url, - }) + result } // 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。 @@ -558,59 +647,107 @@ impl OssClient { client: &reqwest::Client, request: OssHeadObjectRequest, ) -> Result { - let object_key = normalize_object_key(&request.object_key)?; - let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) - .map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?; - let response = send_signed_request( - client, - &self.config, - Method::HEAD, - Some(&object_key), - target_url, - ) - .await?; + let started_at = Instant::now(); + let requested_object_key = request + .object_key + .trim() + .trim_start_matches('/') + .trim() + .to_string(); + let mut response_status = None; - if response.status() == reqwest::StatusCode::NOT_FOUND { - return Err(OssError::ObjectNotFound(format!( - "OSS 对象不存在:{}", - request.object_key - ))); + let result = async { + let object_key = normalize_object_key(&request.object_key)?; + let target_url = + build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( + |error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")), + )?; + let response = send_signed_request( + client, + &self.config, + Method::HEAD, + Some(&object_key), + target_url, + ) + .await?; + response_status = Some(response.status().as_u16()); + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(OssError::ObjectNotFound(format!( + "OSS 对象不存在:{}", + request.object_key + ))); + } + + if !response.status().is_success() { + return Err(OssError::Request(format!( + "OSS HEAD Object 失败,状态码:{}", + response.status() + ))); + } + + let headers = response.headers(); + let content_length = headers + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let content_type = headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + let etag = headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim_matches('"').to_string()); + let last_modified = headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + + Ok(OssHeadObjectResponse { + bucket: self.config.bucket.clone(), + object_key, + content_length, + content_type, + etag, + last_modified, + }) + } + .await; + + match &result { + Ok(response) => info!( + provider = OSS_PROVIDER, + operation = "head_object", + bucket = %response.bucket, + endpoint = %self.config.endpoint(), + object_key = %response.object_key, + status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()), + status_class = http_status_class_from_option(response_status), + content_length = response.content_length, + content_type = %response.content_type.as_deref().unwrap_or(""), + etag_present = response.etag.is_some(), + last_modified_present = response.last_modified.is_some(), + elapsed_ms = elapsed_ms(started_at), + "OSS HEAD Object 完成" + ), + Err(error) => warn!( + provider = OSS_PROVIDER, + operation = "head_object", + bucket = %self.config.bucket(), + endpoint = %self.config.endpoint(), + object_key = %requested_object_key, + status = response_status.unwrap_or_default(), + status_class = http_status_class_from_option(response_status), + error_kind = oss_error_kind_label(error), + message = %error, + elapsed_ms = elapsed_ms(started_at), + "OSS HEAD Object 失败" + ), } - if !response.status().is_success() { - return Err(OssError::Request(format!( - "OSS HEAD Object 失败,状态码:{}", - response.status() - ))); - } - - let headers = response.headers(); - let content_length = headers - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - let content_type = headers - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()); - let etag = headers - .get(reqwest::header::ETAG) - .and_then(|value| value.to_str().ok()) - .map(|value| value.trim_matches('"').to_string()); - let last_modified = headers - .get(reqwest::header::LAST_MODIFIED) - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()); - - Ok(OssHeadObjectResponse { - bucket: self.config.bucket.clone(), - object_key, - content_length, - content_type, - etag, - last_modified, - }) + result } // AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。 @@ -619,73 +756,128 @@ impl OssClient { client: &reqwest::Client, request: OssPutObjectRequest, ) -> Result { - if request.body.is_empty() { - return Err(OssError::InvalidRequest( - "服务端上传对象内容不能为空".to_string(), - )); + let started_at = Instant::now(); + let requested_prefix = request.prefix.as_str(); + let requested_content_type = request + .content_type + .as_deref() + .map(str::trim) + .unwrap_or("") + .to_string(); + let requested_content_length = request.body.len(); + let requested_metadata_count = request.metadata.len(); + let mut response_status = None; + + let result = async { + if request.body.is_empty() { + return Err(OssError::InvalidRequest( + "服务端上传对象内容不能为空".to_string(), + )); + } + + let sanitized_segments = request + .path_segments + .iter() + .map(|segment| sanitize_path_segment(segment)) + .filter(|segment| !segment.is_empty()) + .collect::>(); + let file_name = sanitize_file_name(&request.file_name)?; + let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); + let content_type = normalize_optional_value(request.content_type); + let metadata = normalize_metadata(request.metadata)?; + let target_url = + build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( + |error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")), + )?; + let content_length = u64::try_from(request.body.len()) + .map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?; + let builder = signed_request_builder( + client, + &self.config, + Method::PUT, + Some(&object_key), + target_url, + content_type.as_deref(), + &metadata, + )? + .header(reqwest::header::CONTENT_LENGTH, content_length) + .body(request.body); + + let response = builder + .send() + .await + .map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?; + response_status = Some(response.status().as_u16()); + + if !response.status().is_success() { + return Err(OssError::Request(format!( + "OSS PutObject 失败,状态码:{}", + response.status() + ))); + } + + let headers = response.headers(); + let etag = headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim_matches('"').to_string()); + let last_modified = headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + + Ok(OssPutObjectResponse { + provider: OSS_PROVIDER, + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + host: self.config.upload_host(), + legacy_public_path: format!("/{object_key}"), + object_key, + content_type, + content_length, + access: request.access, + etag, + last_modified, + }) + } + .await; + + match &result { + Ok(response) => info!( + provider = OSS_PROVIDER, + operation = "put_object", + bucket = %response.bucket, + endpoint = %response.endpoint, + object_key = %response.object_key, + access = oss_access_label(response.access), + status = response_status.unwrap_or(reqwest::StatusCode::OK.as_u16()), + status_class = http_status_class_from_option(response_status), + content_length = response.content_length, + content_type = %response.content_type.as_deref().unwrap_or(""), + etag_present = response.etag.is_some(), + last_modified_present = response.last_modified.is_some(), + elapsed_ms = elapsed_ms(started_at), + "OSS PutObject 上传完成" + ), + Err(error) => warn!( + provider = OSS_PROVIDER, + operation = "put_object", + bucket = %self.config.bucket(), + endpoint = %self.config.endpoint(), + key_prefix = requested_prefix, + content_length = requested_content_length, + content_type = %requested_content_type, + metadata_count = requested_metadata_count, + status = response_status.unwrap_or_default(), + status_class = http_status_class_from_option(response_status), + error_kind = oss_error_kind_label(error), + message = %error, + elapsed_ms = elapsed_ms(started_at), + "OSS PutObject 上传失败" + ), } - let sanitized_segments = request - .path_segments - .iter() - .map(|segment| sanitize_path_segment(segment)) - .filter(|segment| !segment.is_empty()) - .collect::>(); - let file_name = sanitize_file_name(&request.file_name)?; - let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); - let content_type = normalize_optional_value(request.content_type); - let metadata = normalize_metadata(request.metadata)?; - let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) - .map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?; - let content_length = u64::try_from(request.body.len()) - .map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?; - let builder = signed_request_builder( - client, - &self.config, - Method::PUT, - Some(&object_key), - target_url, - content_type.as_deref(), - &metadata, - )? - .header(reqwest::header::CONTENT_LENGTH, content_length) - .body(request.body); - - let response = builder - .send() - .await - .map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?; - - if !response.status().is_success() { - return Err(OssError::Request(format!( - "OSS PutObject 失败,状态码:{}", - response.status() - ))); - } - - let headers = response.headers(); - let etag = headers - .get(reqwest::header::ETAG) - .and_then(|value| value.to_str().ok()) - .map(|value| value.trim_matches('"').to_string()); - let last_modified = headers - .get(reqwest::header::LAST_MODIFIED) - .and_then(|value| value.to_str().ok()) - .map(|value| value.to_string()); - - Ok(OssPutObjectResponse { - provider: "aliyun-oss", - bucket: self.config.bucket.clone(), - endpoint: self.config.endpoint.clone(), - host: self.config.upload_host(), - legacy_public_path: format!("/{object_key}"), - object_key, - content_type, - content_length, - access: request.access, - etag, - last_modified, - }) + result } } @@ -717,6 +909,43 @@ impl OssError { } } +fn elapsed_ms(started_at: Instant) -> u64 { + started_at.elapsed().as_millis().min(u64::MAX as u128) as u64 +} + +fn oss_access_label(access: OssObjectAccess) -> &'static str { + match access { + OssObjectAccess::Public => "public", + OssObjectAccess::Private => "private", + } +} + +fn oss_error_kind_label(error: &OssError) -> &'static str { + match error.kind() { + OssErrorKind::InvalidConfig => "invalid_config", + OssErrorKind::InvalidRequest => "invalid_request", + OssErrorKind::ObjectNotFound => "object_not_found", + OssErrorKind::Request => "request", + OssErrorKind::SerializePolicy => "serialize_policy", + OssErrorKind::Sign => "sign", + } +} + +fn http_status_class_from_option(status: Option) -> &'static str { + status.map(http_status_class).unwrap_or("unknown") +} + +fn http_status_class(status: u16) -> &'static str { + match status { + 100..=199 => "1xx", + 200..=299 => "2xx", + 300..=399 => "3xx", + 400..=499 => "4xx", + 500..=599 => "5xx", + _ => "unknown", + } +} + fn build_policy_json( bucket: &str, object_key: &str, @@ -1295,6 +1524,18 @@ mod tests { ); } + #[test] + fn structured_log_labels_are_stable() { + assert_eq!( + oss_error_kind_label(&OssError::InvalidRequest("bad input".to_string())), + "invalid_request" + ); + assert_eq!(oss_access_label(OssObjectAccess::Private), "private"); + assert_eq!(http_status_class(204), "2xx"); + assert_eq!(http_status_class(404), "4xx"); + assert_eq!(http_status_class_from_option(None), "unknown"); + } + fn build_client() -> OssClient { OssClient::new( OssConfig::new( diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 0701a005..33747465 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -243,6 +243,8 @@ test('creation start card renders reference-aligned banner and template metadata expect(html).toContain('拼图关卡创作'); expect(html).toContain('10-20泥点数'); expect(html).toContain('即将开放'); + expect(html).toContain('data-locked="true"'); + expect(html).toContain('暂未开放'); expect(html).not.toContain('可创建'); expect(html).not.toContain('可创作'); expect(html).not.toContain('creation-event-banner__counter'); @@ -250,6 +252,49 @@ test('creation start card renders reference-aligned banner and template metadata expect(html).not.toContain('platform-creation-reference-card'); }); +test('locked creation template card replaces mud point cost with unavailable state', () => { + const lockedEntryConfig = { + ...testEntryConfig, + creationTypes: [ + { + id: 'airp', + title: 'AI RPG', + subtitle: '原生角色扮演', + badge: '即将开放', + imageSrc: '/creation-type-references/airp.webp', + visible: true, + open: false, + sortOrder: 70, + categoryId: 'recommended', + categoryLabel: '热门推荐', + categorySortOrder: 20, + updatedAtMicros: 1, + }, + ], + } satisfies CreationEntryConfig; + const html = renderToStaticMarkup( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + entryConfig={lockedEntryConfig} + creationTypes={derivePlatformCreationTypes( + lockedEntryConfig.creationTypes, + )} + mode="start-only" + />, + ); + + expect(html).toContain('data-locked="true"'); + expect(html).toContain('即将开放'); + expect(html).toContain('暂未开放'); + expect(html).not.toContain('10-20泥点数'); +}); + test('creation start card falls back to legacy single banner when eventBanners is empty', () => { const html = renderToStaticMarkup( { diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index 4b423f45..76f5254b 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,4 +1,4 @@ -import { Coins, Trophy } from 'lucide-react'; +import { Coins, LockKeyhole, Trophy } from 'lucide-react'; import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react'; import type { @@ -100,17 +100,17 @@ export function CustomWorldCreationStartCard({ activeCategoryId ?? (hasRecentCreationTypes ? CREATION_ENTRY_RECENT_TAB_ID - : creationTypeGroups[0]?.id ?? null); + : (creationTypeGroups[0]?.id ?? null)); const isRecentTabActive = hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID; const activeGroup = isRecentTabActive ? null - : creationTypeGroups.find((group) => group.id === activeTabId) ?? + : (creationTypeGroups.find((group) => group.id === activeTabId) ?? creationTypeGroups[0] ?? - null; + null); const visibleCreationTypes = isRecentTabActive ? recentCreationTypes - : activeGroup?.items ?? []; + : (activeGroup?.items ?? []); const eventBanners = useMemo( () => resolveCreationEntryEventBanners(entryConfig), [entryConfig], @@ -318,18 +318,20 @@ export function CustomWorldCreationStartCard({
{visibleCreationTypes.map((item) => { const disabled = item.locked || busy; + const lockedBadge = item.badge.trim() || '暂未开放'; return (
diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 37bfb3ee..64d065a3 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -6782,6 +6782,27 @@ test('creation draft hub skips visual novel shelves when entry is not open', asy expect(screen.queryByText('该玩法入口暂不可用')).toBeNull(); }); +test('platform home suppresses creation entry disabled bootstrap errors', async () => { + vi.mocked(listRpgEntryWorldLibrary).mockRejectedValue( + new Error( + '该玩法入口暂不可用 creation_entry_disabled(requestId: req-closed)', + ), + ); + vi.mocked(listRpgCreationWorks).mockRejectedValue( + new Error('creation_entry_disabled'), + ); + + render(); + + await waitFor(() => { + expect(listRpgEntryWorldLibrary).toHaveBeenCalled(); + }); + + expect(screen.queryByText(/平台首页/u)).toBeNull(); + expect(screen.queryByText(/creation_entry_disabled/u)).toBeNull(); + expect(screen.queryByText(/该玩法入口暂不可用/u)).toBeNull(); +}); + test('published puzzle works appear on home and mobile game category channel', async () => { const user = userEvent.setup(); const publishedPuzzleWork = { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index a9a42e9c..ed1b0287 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -827,6 +827,7 @@ function renderLoggedOutHomeView( | 'recommendRuntimeContent' | 'activeRecommendEntryKey' | 'isStartingRecommendEntry' + | 'isRecommendRuntimeReady' | 'recommendRuntimeError' | 'onSelectNextRecommendEntry' | 'onSelectPreviousRecommendEntry' @@ -887,6 +888,7 @@ function renderLoggedOutHomeView( } activeRecommendEntryKey={overrides.activeRecommendEntryKey} isStartingRecommendEntry={overrides.isStartingRecommendEntry} + isRecommendRuntimeReady={overrides.isRecommendRuntimeReady} recommendRuntimeError={overrides.recommendRuntimeError} onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry} onSelectPreviousRecommendEntry={ @@ -3142,6 +3144,41 @@ test('logged in create tab shows real wallet balance beside the brand', () => { expect(topbar?.textContent).toContain('1,234泥点'); }); +test('create tab wallet chip opens reward code when recharge entry is hidden', async () => { + const user = userEvent.setup(); + mockNarrowMobileLayout(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /^70泥点$/u })); + + expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy(); + expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); +}); + +test('create tab wallet chip opens recharge when recharge entry is enabled', async () => { + const user = userEvent.setup(); + mockWechatDesktopLayout(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /^70泥点$/u })); + + expect(await screen.findByText('账户充值')).toBeTruthy(); + expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1); + expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull(); +}); + test('mobile discover search submits public work code', async () => { const user = userEvent.setup(); const onSearchPublicCode = vi.fn(); @@ -3673,7 +3710,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () => ); expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); - expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); + expect( + document.querySelector('.platform-recommend-runtime-cover'), + ).toBeTruthy(); + expect(screen.queryByText('加载中...')).toBeNull(); expect( document.querySelector('.platform-public-work-card__cover'), ).toBeNull(); @@ -3682,7 +3722,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () => expect(onOpenGalleryDetail).not.toHaveBeenCalled(); }); -test('mobile recommend loading state is themed instead of hardcoded black', () => { +test('mobile recommend startup keeps cover visible without loading copy', () => { renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', @@ -3690,8 +3730,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () = recommendRuntimeContent: null, }); - expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); - expect(screen.getByText('加载中...')).toBeTruthy(); + expect( + document.querySelector('.platform-recommend-runtime-cover'), + ).toBeTruthy(); + expect(screen.queryByText('加载中...')).toBeNull(); + expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); +}); + +test('mobile recommend next level keeps runtime visual stable when active work changes', async () => { + const animationCallbacks: FrameRequestCallback[] = []; + Object.defineProperty(window, 'requestAnimationFrame', { + configurable: true, + writable: true, + value: vi.fn((callback: FrameRequestCallback) => { + animationCallbacks.push(callback); + return animationCallbacks.length; + }), + }); + Object.defineProperty(window, 'cancelAnimationFrame', { + configurable: true, + writable: true, + value: vi.fn(), + }); + const firstEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-feed-1', + profileId: 'puzzle-profile-feed-1', + ownerUserId: 'user-feed-1', + publicWorkCode: 'PZ-FEED1', + worldName: '当前拼图', + coverImageSrc: 'current-cover.png', + } satisfies PlatformPublicGalleryCard; + const similarEntry = { + ...puzzlePublicEntry, + workId: 'puzzle-work-similar-1', + profileId: 'puzzle-profile-similar-1', + ownerUserId: 'user-feed-2', + publicWorkCode: 'PZ-SIMILAR1', + worldName: '相似拼图', + coverImageSrc: 'similar-cover.png', + } satisfies PlatformPublicGalleryCard; + + const { rerender } = renderLoggedOutHomeView(vi.fn(), { + latestEntries: [firstEntry, similarEntry], + activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1', + isRecommendRuntimeReady: true, + }); + + act(() => { + animationCallbacks.splice(0).forEach((callback) => callback(16)); + }); + await waitFor(() => { + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).toContain('platform-recommend-runtime-cover--hidden'); + }); + + rerender( + undefined), + musicVolume: 0.42, + setMusicVolume: vi.fn(), + platformTheme: 'light', + setPlatformTheme: vi.fn(), + isHydratingSettings: false, + isPersistingSettings: false, + settingsError: null, + }} + > + } + activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1" + isRecommendRuntimeReady + onOpenLibraryDetail={vi.fn()} + onSearchPublicCode={vi.fn()} + /> + , + ); + + const rail = document.querySelector( + '.platform-recommend-swipe-rail', + ) as HTMLElement | null; + expect(rail?.className).toContain('platform-recommend-swipe-rail--settled'); + expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)'); + expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy(); + expect( + document.querySelector('.platform-recommend-runtime-cover')?.className, + ).toContain('platform-recommend-runtime-cover--hidden'); }); test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index f17271b9..ab035b9f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -39,6 +39,7 @@ import { type CSSProperties, type PointerEvent, type ReactNode, + Suspense, useCallback, useEffect, useMemo, @@ -260,6 +261,7 @@ export interface RpgEntryHomeViewProps { recommendRuntimeContent?: ReactNode; activeRecommendEntryKey?: string | null; isStartingRecommendEntry?: boolean; + isRecommendRuntimeReady?: boolean; recommendRuntimeError?: string | null; onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void; onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void; @@ -886,6 +888,115 @@ function RecommendRuntimePreviewCard({ ); } +function RecommendRuntimeCover({ + entry, + className = '', +}: { + entry: PlatformPublicGalleryCard; + className?: string; +}) { + const coverImage = resolvePlatformWorldCoverImage(entry); + const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry); + + return ( +