From 425699b2e2639c17949748cb7219bf3168ad91cf Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 12 Jul 2026 22:24:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=91=E5=B8=83=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=E7=AD=89=E5=BE=85=E4=B8=8E=E5=85=85=E5=80=BC=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E7=9B=91=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 API readiness 探测增加单次超时,避免端口已建立时无限挂起 修复 SpacetimeDB 枚举状态订阅导致的充值过期监听失败 在 pause-after-stdb 审批提示中强调运行时与迁移身份互斥 补齐生产发布门禁、回归测试与运维排障文档 --- docs/project-memory/shared-memory/pitfalls.md | 7 +++++++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...nkinsfile.production-full-build-and-deploy | 2 +- scripts/check-production-api-deploy.mjs | 4 ++-- scripts/check-production-ops-guardrails.mjs | 13 ++++++++++++ scripts/deploy/production-api-deploy.sh | 2 +- .../src/profile_recharge_expiration.rs | 21 +++++++++++++++---- 7 files changed, 42 insertions(+), 9 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 352e56d07..5aedaed41 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2105,6 +2105,13 @@ - 验证:确认 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` / `external-generation-controller` 不启动充值过期监听;创建 pending 充值单后只由 scheduled reducer 产生 `expired`,HTTP api-server listener 记录 `expiration_checked_at` 或补入账。 - 关联:`server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +## 充值订单状态枚举不能用字符串 SQL 字面量订阅 + +- 现象:API 已 ready,但日志每 5 秒出现 `profile recharge expiration listener failed to subscribe`,并提示 `pending` 不能解析为 `profile_recharge_order.status` 的枚举类型;scheduled reducer 仍会把订单改成 `expired`,但微信查单补偿监听没有运行。 +- 原因:SpacetimeDB 2.6 不会把订阅 SQL 中的 `'pending'` / `'expired'` 字符串自动转换为生成绑定的 sum-type enum;两个按状态过滤的订阅都在应用阶段失败。 +- 处理:后端订阅完整 `profile_recharge_order` 表,并继续只在 SDK `on_update` 中处理 `Pending -> Expired`。完整订阅让同一行跨状态变化时稳定产生 update callback,也避免依赖不受支持的枚举字符串 SQL。 +- 验证:运行 `cargo test -p spacetime-client profile_recharge_expiration --manifest-path server-rs/Cargo.toml`,发布后确认 API 日志不再出现订阅解析错误,并用真实 pending 订单验证 scheduled reducer 过期后写入 `expiration_checked_at`。 + ## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布 - 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5a640a933..98f476cc3 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -401,7 +401,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. 生产运行时不把 bootstrap secret 明文写进 `/etc/genarrative/*.env`。`api-server.env` 和 worker env 只登记固定 FILE 路径 `GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt`;若检测到明文 `GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET` 或其他 FILE 路径,Server-Provision / API deploy 必须失败。Full Build 先执行 Stdb publish、后执行 API deploy,因此两段必须透传同一 `API_ENV_FILE` / `WORKER_ENV_FILE`;Stdb Publish 把 Secret File 路径作为 `--migration-bootstrap-secret-file` 传给随包 `production-stdb-publish.sh`。脚本先进入维护模式、按所选模式完成发布前冷备份、校验 checksum 并发布 module;成功后拒绝符号链接目标,把 secret 安装成 `root:genarrative 0440`、目录收紧为 `root:genarrative 0750`,原子补齐 API / worker env 的固定 FILE 配置,再快照并重启发布前为 active 的 API、controller 和 worker。`systemctl is-active` 只有明确返回合法的非 active 状态时才允许跳过,查询错误或 active worker 的 `list-units` 失败都必须保留维护模式并阻断;所有原 active 服务重启后必须重新确认为 `active`。如果 API 原本 active,还必须在 `maintenance-off` 前通过本机 `http://127.0.0.1:8082/healthz` readiness;可用 `--api-health-url` / `GENARRATIVE_STDB_PUBLISH_API_HEALTH_URL` 调整本机 URL,并用 `--api-readiness-timeout-seconds` / `GENARRATIVE_STDB_PUBLISH_API_READINESS_TIMEOUT_SECONDS` 调整超时。这样旧服务器首次 rollout 也不会等到后续 API deploy 才拿到 FILE;只覆盖 secret 文件或只补 env 而不重启都不生效,因为 `AppConfig` 在进程启动时读取 secret。人工执行 `npm run build:production-release -- --component spacetime-module --name ` 且未显式提供 secret / SHA-256 时,原始随机 secret 只写入 gitignored 的 `server-rs/.spacetimedb/build-secrets/.txt`,目录权限 `0700`、文件权限 `0600`,发布包和 `release-manifest.json` 都不收录它;必须把该受保护文件另行交给 publish 阶段。旧 `npm run deploy:rust:remote` Ubuntu 直传入口也使用同一 sidecar 目录,发布包内不含原文;上传模式通过独立 SSH 标准输入把 secret 原子安装到远端发布目录并收紧为 `0600`,`--skip-upload` 时必须单独受保护交付。 本地 dev 的原始值也只能进入 api-server,不得扩散给 Web / Vite,任何控制台、Jenkins 日志、归档或生成 README 都不得输出明文。相关变更至少运行 `bash -n scripts/deploy/production-stdb-publish.sh scripts/deploy/production-api-deploy.sh scripts/deploy-rust-remote.sh scripts/jenkins-server-provision.sh`、`node --check scripts/dev.mjs scripts/check-production-ops-guardrails.mjs`、`npm run check:production-ops`、`npm run check:encoding` 和 `git diff --check`。 -生产 runtime writer 不能通过替换 bootstrap secret 或重启服务隐式轮换。migration operator 与 runtime writer 必须身份互斥:operator 不能成为 writer,当前 writer 不能授权为 operator;已有任一 operator 后,bootstrap secret 不得新增或接管 operator。先准备新 api-server identity,并使用当前已授权 migration operator 的 CLI 登录态执行 `node scripts/deploy/production-runtime-writer-identity-rotate.mjs --database --server-url --operator-identity --operator-user-id --next-writer-identity --confirm-next-writer-identity --note `。CLI 会校验当前登录 identity、双录新 identity 和审计原因;模块 procedure 还会拒绝把 writer 设为任一已登记 migration operator。成功后必须核对 `editor_generation_runtime_identity_rotation` 的旧 writer、新 writer、operator identity、操作人、原因和服务端时间,再切换 API token;轮换只改 writer,不改模型价格。 +生产 runtime writer 不能通过替换 bootstrap secret 或重启服务隐式轮换。migration operator 与 runtime writer 必须身份互斥:operator 不能成为 writer,当前 writer 不能授权为 operator;已有任一 operator 后,bootstrap secret 不得新增或接管 operator。先准备新 api-server identity,并使用当前已授权 migration operator 的 CLI 登录态执行 `node scripts/deploy/production-runtime-writer-identity-rotate.mjs --database --server-url --operator-identity --operator-user-id --next-writer-identity --confirm-next-writer-identity --note `。CLI 会校验当前登录 identity、双录新 identity 和审计原因;模块 procedure 还会拒绝把 writer 设为任一已登记 migration operator。成功后必须核对 `editor_generation_runtime_identity_rotation` 的旧 writer、新 writer、operator identity、操作人、原因和服务端时间,再切换 API token;轮换只改 writer,不改模型价格。`pause-after-stdb` 人工维护不得把 `api-server.env` 的 `GENARRATIVE_SPACETIME_TOKEN` identity 授权为 migration operator;首次初始化前数据库还没有 writer 记录,模块无法提前识别这枚 identity 的未来用途,误授权会让 API 启动持续报“数据库迁移操作员 identity 不能初始化为模型生成运行时服务 identity”。若已误授权,必须在维护完成后由该 identity 自撤 migration operator 权限,再确认 rollout gate。API deploy 的本机 readiness 探测每次请求固定 `--max-time 2`,即使端口已建立但 API 尚未响应也会回到有限重试,不得使用无超时 curl。 `Genarrative-Web-Build` 打包 `web.tar.gz` 前、`Genarrative-Web-Deploy` 解包后都会把 Web 静态目录规范为目录 `755`、文件 `644`。如果前端页面能打开但 public 图片、字体或音频返回 `403 Forbidden`,优先检查当前 `/srv/genarrative/web` 指向的 release 中对应文件权限是否被异常归档为 `600`,临时恢复可对该 release 的 `web` 目录执行目录 `755`、文件 `644` 的权限修正。 diff --git a/jenkins/Jenkinsfile.production-full-build-and-deploy b/jenkins/Jenkinsfile.production-full-build-and-deploy index dd6bc36fd..476dc1b7e 100644 --- a/jenkins/Jenkinsfile.production-full-build-and-deploy +++ b/jenkins/Jenkinsfile.production-full-build-and-deploy @@ -208,7 +208,7 @@ pipeline { } steps { timeout(time: 4, unit: 'HOURS') { - input message: 'SpacetimeDB module 已发布,站点和外部生成服务保持维护态。请在目标部署 agent 使用本次 Stdb artifact 中的外部生成任务维护脚本完成 dry-run、分批 apply 与摘要回填;确认无 has_more 后再继续部署 API。', ok: '确认维护完成,继续部署 API', submitter: params.STDB_API_ROLLOUT_APPROVERS.trim(), submitterParameter: 'STDB_API_ROLLOUT_APPROVED_BY' + input message: 'SpacetimeDB module 已发布,站点和外部生成服务保持维护态。请使用与 api-server GENARRATIVE_SPACETIME_TOKEN 不同的 migration operator identity,在目标部署 agent 用本次 Stdb artifact 中的维护脚本完成 dry-run、分批 apply 与摘要回填;确认无 has_more,并确认 API runtime identity 未被授权为 migration operator 后再继续部署 API。', ok: '确认维护完成,继续部署 API', submitter: params.STDB_API_ROLLOUT_APPROVERS.trim(), submitterParameter: 'STDB_API_ROLLOUT_APPROVED_BY' } } } diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index f08715dfd..ccab78a46 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -333,8 +333,8 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() { ); assertIncludes( commandsLog, - 'curl -fsS http://127.0.0.1:18082/readyz', - '部署脚本必须执行 readiness curl。', + 'curl -fsS --max-time 2 http://127.0.0.1:18082/readyz', + '部署脚本必须为 readiness curl 设置单次超时,避免端口已建立但服务未响应时无限等待。', ); if (existsSync(fixture.maintenanceFile)) { diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index be546af1b..4352e947c 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -167,6 +167,12 @@ const checks = [ includes: 'readiness 通过,按参数保持维护模式', reason: 'API deploy 保留维护模式时必须在 readiness 通过后给出明确状态。', }, + { + file: 'scripts/deploy/production-api-deploy.sh', + includes: 'curl -fsS --max-time 2 "${HEALTH_URL}"', + reason: + 'API readiness 单次请求必须有超时,避免端口已建立但服务尚未响应时绕过重试上限无限挂起。', + }, { file: 'jenkins/Jenkinsfile.production-api-deploy', includes: @@ -383,6 +389,13 @@ const checks = [ "booleanParam(name: 'EXIT_MAINTENANCE_MODE_AFTER_COMPLETION', defaultValue: true", reason: 'Full Build 必须显式提供完整发布成功后是否退出维护模式的选项。', }, + { + file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', + includes: + '确认 API runtime identity 未被授权为 migration operator 后再继续部署 API', + reason: + 'Stdb/API 人工 gate 必须提醒审批人保持 migration operator 与运行时服务 identity 互斥。', + }, { file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', includes: diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index c7a7e80c6..b705d78ee 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -1126,7 +1126,7 @@ wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}" echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}" for _ in {1..30}; do - if curl -fsS "${HEALTH_URL}" >/dev/null; then + if curl -fsS --max-time 2 "${HEALTH_URL}" >/dev/null; then if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then echo "[production-api-deploy] readiness 通过,按参数保持维护模式: ${MAINTENANCE_FILE}" else diff --git a/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs b/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs index 6ff4b41f6..b376ab52d 100644 --- a/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs +++ b/server-rs/crates/spacetime-client/src/profile_recharge_expiration.rs @@ -2,10 +2,23 @@ use super::*; use spacetimedb_sdk::TableWithPrimaryKey; use tokio::sync::mpsc; -const PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES: [&str; 2] = [ - "SELECT * FROM profile_recharge_order WHERE status = 'pending'", - "SELECT * FROM profile_recharge_order WHERE status = 'expired'", -]; +// SpacetimeDB 2.6 cannot parse string SQL literals as generated sum-type enum values. +// Keeping the row subscribed across the status transition also guarantees an update callback. +const PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES: [&str; 1] = + ["SELECT * FROM profile_recharge_order"]; + +#[cfg(test)] +mod tests { + use super::PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES; + + #[test] + fn expiration_listener_keeps_orders_subscribed_across_enum_status_updates() { + assert_eq!( + PROFILE_RECHARGE_EXPIRATION_EVENT_SUBSCRIPTION_QUERIES, + ["SELECT * FROM profile_recharge_order"] + ); + } +} pub struct ProfileRechargeExpirationSubscription { connection: DbConnection,