Merge branch 'master' into codex/external-generation-run-audit
This commit is contained in:
+11
-2
@@ -46,8 +46,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS="30"
|
||||
AUTH_REFRESH_COOKIE_PATH="/api/auth"
|
||||
AUTH_REFRESH_COOKIE_SAME_SITE="Lax"
|
||||
AUTH_REFRESH_COOKIE_SECURE="false"
|
||||
# Rust 鉴权快照路径;包含 password_hash 与 refresh token hash,只能放服务端私有目录。
|
||||
GENARRATIVE_AUTH_STORE_PATH="server-rs/.data/auth-store.json"
|
||||
# 开发期便捷开关:true 时允许 /api/auth/entry 对未知手机号用本次密码直接创建账号;生产必须保持 false。
|
||||
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED="false"
|
||||
|
||||
@@ -145,6 +143,17 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
||||
|
||||
# SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=""
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX="database-backups"
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL="false"
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=""
|
||||
GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE=""
|
||||
|
||||
# Optional model name for custom-world scene image generation.
|
||||
DASHSCOPE_IMAGE_MODEL="wan2.7-image"
|
||||
|
||||
|
||||
@@ -507,6 +507,13 @@
|
||||
- 验证方式:执行 `npm run spacetime:generate -- --rust-only`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、认证相关定向测试和 `npm run check:encoding`。
|
||||
- 关联文档:`docs/technical/AUTH_SPACETIMEDB_FORMAL_TABLE_RECOVERY_STAGE3_2026-04-24.md`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md`。
|
||||
|
||||
## 2026-05-27 auth_store_snapshot 改为行级记录,不再保留 default 聚合单行
|
||||
|
||||
- 背景:`auth_store_snapshot/default` 聚合 JSON 行会把整份认证快照收敛到单键,过期快照一旦被导入就可能覆盖 `user_account` / `auth_identity` / `refresh_session` 的整表状态。
|
||||
- 决策:`auth_store_snapshot` 只保留行级记录,按 `meta/next_user_id`、`user/<user_id>`、`phone/<phone+user>`、`session/<session_id>`、`session_hash/<hash+session>`、`wechat/<provider_uid+user>`、`union/<union+user>` 拆分存储;`api-server` 启动恢复只认正式认证表,`auth_store_snapshot` 仅作为行级备查,不再作为文件快照替代源。
|
||||
- 影响范围:`spacetime-module` auth procedures、`spacetime-client` auth facade、`api-server` 启动恢复、后端架构文档、开发运维文档、认证排障记忆。
|
||||
- 验证方式:`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:encoding`。
|
||||
|
||||
## 2026-05-13 微信小程序支付以后端通知为唯一入账事实
|
||||
|
||||
- 背景:“我的”账户充值需要接入微信小程序支付,同时保留本地 / H5 mock 支付联调能力。
|
||||
@@ -1070,4 +1077,12 @@
|
||||
- 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。
|
||||
- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/api-server/src/modules/wooden_fish.rs`、`src/services/wooden-fish/woodenFishClient.ts`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。
|
||||
- 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
||||
|
||||
## 2026-05-27 认证快照完全去文件化并仅保留行级备查
|
||||
|
||||
- 背景:`api-server` 依赖本地 `auth-store.json` 或 `GENARRATIVE_AUTH_STORE_PATH` 恢复认证真相会在 SpacetimeDB 不可用时把旧快照回灌到 `auth_identity` / `user_account`,导致用户数据被清空或覆盖。
|
||||
- 决策:`api-server` 启动时只允许从 SpacetimeDB 正式认证表恢复;`module-auth` 不再维护本地持久化文件,只保留内存工作集和 JSON 导入 / 导出;`spacetime-module` 的认证快照只保留行级 `auth_store_snapshot` 备查,不再提供旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 兼容入口。
|
||||
- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
|
||||
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
@@ -512,10 +512,17 @@
|
||||
|
||||
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
|
||||
- 原因:重置/修改密码会更新 `password_hash`、`password_login_enabled` 和 `token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()`,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
|
||||
- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照;启动恢复时从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB。
|
||||
- 处理:`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` 成功后必须同步认证快照。2026-05-27 起,启动恢复只允许从 SpacetimeDB 正式认证表恢复;`auth_store_snapshot` 只保留行级记录,不再写 `default` 聚合单行,也不再把本地文件 `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作恢复源。若启动时连不上 SpacetimeDB,`api-server` 等待启动恢复超时后进入依赖不可用模式,所有请求返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。
|
||||
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
|
||||
- 关联:`server-rs/crates/api-server/src/password_management.rs`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。
|
||||
|
||||
## 认证本地文件快照已废弃,旧 procedure 也已删
|
||||
|
||||
- 现象:有些旧代码和生成 bindings 里还会残留 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot`,或者把 `auth-store.json` 误当成认证恢复源。
|
||||
- 原因:认证恢复已经彻底收口到 SpacetimeDB 正式表和 `module-auth` 的 JSON 导入 / 导出路径;本地文件持久化会和正式表投影打架,SpacetimeDB 不可用时还可能把旧快照回灌到用户表。
|
||||
- 处理:先用 `npm run spacetime:generate -- --rust-only` 刷新 bindings,确认 `server-rs/crates/spacetime-client/src/module_bindings.rs` 里已没有旧 procedure 导出;`module-auth` 只保留内存态,不再写本地快照文件。
|
||||
- 验证:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、`npm run check:encoding`。
|
||||
|
||||
## 抓大鹅生成页只显示服务暂不可用先查 reason 和外部服务配置
|
||||
|
||||
- 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。
|
||||
|
||||
@@ -24,7 +24,6 @@ EXPOSE 8082
|
||||
ENV GENARRATIVE_ENV=container \
|
||||
GENARRATIVE_API_HOST=0.0.0.0 \
|
||||
GENARRATIVE_API_PORT=8082 \
|
||||
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json \
|
||||
GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox
|
||||
|
||||
CMD ["api-server"]
|
||||
|
||||
@@ -27,7 +27,6 @@ GENARRATIVE_INTERNAL_API_SECRET=CHANGE_ME_FOR_CONTAINER
|
||||
GENARRATIVE_JWT_ISSUER=genarrative-container
|
||||
GENARRATIVE_JWT_SECRET=CHANGE_ME_FOR_CONTAINER
|
||||
AUTH_REFRESH_COOKIE_SECURE=false
|
||||
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
|
||||
|
||||
# 默认连接 compose 内部 SpacetimeDB;宿主机发布模块使用 127.0.0.1:13101。
|
||||
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
|
||||
|
||||
@@ -52,7 +52,6 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- api-auth-store:/var/lib/genarrative/auth
|
||||
- api-tracking-outbox:/var/lib/genarrative/tracking-outbox
|
||||
ulimits:
|
||||
nofile:
|
||||
@@ -142,6 +141,5 @@ services:
|
||||
|
||||
volumes:
|
||||
spacetime-data:
|
||||
api-auth-store:
|
||||
api-tracking-outbox:
|
||||
nginx-logs:
|
||||
|
||||
Vendored
+12
-1
@@ -34,7 +34,6 @@ AUTH_REFRESH_SESSION_TTL_DAYS=30
|
||||
AUTH_REFRESH_COOKIE_PATH=/api/auth
|
||||
AUTH_REFRESH_COOKIE_SAME_SITE=Lax
|
||||
AUTH_REFRESH_COOKIE_SECURE=true
|
||||
GENARRATIVE_AUTH_STORE_PATH=/var/lib/genarrative/auth/auth-store.json
|
||||
GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=false
|
||||
|
||||
GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3101
|
||||
@@ -119,3 +118,15 @@ ALIYUN_OSS_READ_EXPIRE_SECONDS=600
|
||||
ALIYUN_OSS_POST_EXPIRE_SECONDS=600
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS=200
|
||||
|
||||
# SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false
|
||||
# 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||
GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE=spacetimedb.service
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Genarrative SpacetimeDB OSS Backup
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/genarrative/current
|
||||
EnvironmentFile=/etc/genarrative/api-server.env
|
||||
ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service
|
||||
|
||||
# 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/stdb /var/lib/genarrative
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Run Genarrative SpacetimeDB OSS Backup Daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:20:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=600
|
||||
Unit=genarrative-database-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
||||
@@ -234,6 +234,10 @@ npm run check:server-rs-ddd
|
||||
- Rust 结构体:`AuthStoreSnapshot`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs`
|
||||
|
||||
认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。
|
||||
|
||||
`auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/<user_id>`、`phone/<phone+user>`、`session/<session_id>`、`session_hash/<hash+session>`、`wechat/<provider_uid+user>`、`union/<union+user>`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。
|
||||
|
||||
### `bark_battle_draft_config`
|
||||
|
||||
- Rust 结构体:`BarkBattleDraftConfigRow`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 本地开发验证与生产运维
|
||||
# 本地开发验证与生产运维
|
||||
|
||||
更新时间:`2026-05-15`
|
||||
|
||||
@@ -193,6 +193,31 @@ UI 相关修改要重点验证:
|
||||
6. Jenkins 数据库导入 / 导出流水线会先加载 `scripts/jenkins-prepare-toolchain-env.sh`,显式补齐 Jenkins 用户的 Node、Cargo、SpacetimeDB 工具链目录;如果目标机器安装路径不同,用 `GENARRATIVE_JENKINS_TOOL_PATHS` 传入额外 `bin` 目录。
|
||||
7. 本地 `npm run dev` / `npm run dev:api-server` 若没有显式 `GENARRATIVE_SPACETIME_TOKEN`,会在 SpacetimeDB 就绪后调用 `/v1/identity` 创建当前进程专用 Web API identity token,并只注入本次 `api-server` 环境,不写回 `.env.local`。启动日志只打印 identity 前缀,禁止打印 token 明文;若仍出现 `subscribe ... 401 Unauthorized`,先确认是否绕过了项目 dev 脚本或是否连接到非本次启动的 SpacetimeDB server。
|
||||
|
||||
### SpacetimeDB 数据目录 OSS 备份
|
||||
|
||||
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
|
||||
|
||||
```bash
|
||||
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service
|
||||
```
|
||||
|
||||
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。`Genarrative-Stdb-Module-Publish` 默认也会在 `spacetime publish` 前执行同一脚本;备份失败会阻断 publish,只有显式勾选 `SKIP_DATABASE_BACKUP` 或脚本参数 `--skip-backup` 才跳过。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
|
||||
|
||||
生产环境变量模板在 `deploy/env/api-server.env.example`:
|
||||
|
||||
```env
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||
```
|
||||
|
||||
`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。
|
||||
|
||||
## 生产运维
|
||||
|
||||
生产部署当前口径:
|
||||
@@ -264,6 +289,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
||||
- `GENARRATIVE_SPACETIME_SERVER_URL`
|
||||
- `GENARRATIVE_SPACETIME_DATABASE`
|
||||
- `GENARRATIVE_SPACETIME_TOKEN`
|
||||
- `GENARRATIVE_DATABASE_BACKUP_*`
|
||||
- `GENARRATIVE_LLM_*`
|
||||
- `APIMART_*`
|
||||
- `VECTOR_ENGINE_*`
|
||||
@@ -342,7 +368,7 @@ systemctl restart genarrative-api.service
|
||||
journalctl -u genarrative-api.service --since '30 seconds ago' --no-pager | grep -E 'tracking outbox|Permission denied|os error 13'
|
||||
```
|
||||
|
||||
`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 与 auth-store 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox`、`/var/lib/genarrative/auth` 归属 `genarrative:genarrative`。
|
||||
`Genarrative-Server-Provision` 和 `Genarrative-Api-Deploy` 会在保留旧 `/etc/genarrative/api-server.env` 的前提下补齐缺失的 tracking outbox 运行态路径,并确保 `/var/lib/genarrative/tracking-outbox` 归属 `genarrative:genarrative`。用户认证真相源只允许在 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)恢复;不要再配置或依赖 `GENARRATIVE_AUTH_STORE_PATH` / `auth-store.json`,`module-auth` 也不再维护本地文件持久化;`auth_store_snapshot` 只保留行级记录,不再保存为单行 `default` 聚合快照,且旧 `get_auth_store_snapshot` / `upsert_auth_store_snapshot` / `import_auth_store_snapshot` 入口已经删除。如果 `api-server` 启动时连不上 SpacetimeDB,会等待启动恢复,超时后继续监听但进入依赖不可用模式,所有请求统一返回 `503 SERVICE_UNAVAILABLE`,错误详情包含 `reason=spacetime_startup_unavailable`,以避免用空本地状态或旧快照覆盖认证表。
|
||||
|
||||
常用检查思路:
|
||||
|
||||
@@ -386,3 +412,5 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
|
||||
## 文档维护
|
||||
|
||||
当前 `docs/` 只保留少量融合文档。新增稳定知识时优先更新现有文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。阶段性流水账、一次性修复记录和已关闭实验不要再新增成长期文档。
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
||||
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
|
||||
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作继续收口到左滑或长按操作层。
|
||||
3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑或长按仅作为辅助操作层。
|
||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pipeline {
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
@@ -27,6 +27,7 @@ pipeline {
|
||||
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致')
|
||||
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户')
|
||||
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布')
|
||||
booleanParam(name: 'SKIP_DATABASE_BACKUP', defaultValue: false, description: '是否跳过 publish 前 OSS 数据库备份;默认不跳过,备份失败会阻断发布')
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -138,6 +139,7 @@ pipeline {
|
||||
steps {
|
||||
script {
|
||||
def clearArg = params.CLEAR_DATABASE ? '--clear-database' : ''
|
||||
def backupArg = params.SKIP_DATABASE_BACKUP ? '--skip-backup' : ''
|
||||
def rootArg = "--root-dir \"${params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb'}\""
|
||||
def runAsArg = params.SPACETIME_RUN_AS_USER?.trim()
|
||||
? "--run-as-user \"${params.SPACETIME_RUN_AS_USER.trim()}\""
|
||||
@@ -155,7 +157,8 @@ pipeline {
|
||||
${rootArg} \\
|
||||
${runAsArg} \\
|
||||
${serverArg} \\
|
||||
${clearArg}
|
||||
${clearArg} \\
|
||||
${backupArg}
|
||||
'
|
||||
"""
|
||||
}
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@
|
||||
"codegraph:init": "codegraph init -i .",
|
||||
"codegraph:index": "codegraph index .",
|
||||
"codegraph:sync": "codegraph sync .",
|
||||
"codegraph:status": "codegraph status ."
|
||||
"codegraph:status": "codegraph status .",
|
||||
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -461,6 +461,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
||||
|
||||
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
|
||||
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
|
||||
@@ -480,7 +481,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
||||
|
||||
## 生产部署口径
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env node
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createHash, createHmac} from 'node:crypto';
|
||||
import {createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
||||
import {basename, dirname, isAbsolute, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const REPO_ROOT = resolve(__dirname, '..');
|
||||
const DEFAULT_LOCAL_DATA_DIR = resolve(REPO_ROOT, 'server-rs/.spacetimedb/local/data');
|
||||
const DEFAULT_LOCAL_WORK_DIR = resolve(REPO_ROOT, 'server-rs/.data/database-backups');
|
||||
const DEFAULT_PRODUCTION_DATA_DIR = '/stdb';
|
||||
const DEFAULT_PRODUCTION_WORK_DIR = '/var/lib/genarrative/database-backups';
|
||||
const OSS_ALGORITHM = 'OSS4-HMAC-SHA256';
|
||||
const OSS_SERVICE = 'oss';
|
||||
const OSS_REQUEST = 'aliyun_v4_request';
|
||||
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
function usage() {
|
||||
console.log(`用法:
|
||||
npm run database:backup:oss -- [--data-dir <path>] [--work-dir <path>] [--bucket <bucket>] [--object-prefix <prefix>] [--keep-local]
|
||||
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service]
|
||||
|
||||
说明:
|
||||
将 SpacetimeDB 数据目录打包成 .tar.gz,并上传到阿里云 OSS 指定 bucket。
|
||||
默认读取 .env / .env.local / .env.secrets.local;生产服务可传 --env-file /etc/genarrative/api-server.env。
|
||||
shell 环境变量优先级最高,不会被 env 文件覆盖。
|
||||
|
||||
常用环境变量:
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR 数据目录;生产建议 /stdb
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR 本地临时备份目录;生产建议 /var/lib/genarrative/database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET 备份 bucket;未设置时回退 ALIYUN_OSS_BUCKET
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX 对象前缀,默认 database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT OSS endpoint;未设置时回退 ALIYUN_OSS_ENDPOINT
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL true 时保留本地 tar.gz
|
||||
ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET
|
||||
`);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath, target, protectedKeys) {
|
||||
if (!existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
const rawText = readFileSync(filePath, 'utf8');
|
||||
for (const rawLine of rawText.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const [, key, rawValue] = match;
|
||||
if (protectedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
|
||||
}
|
||||
}
|
||||
|
||||
function loadRepoEnv() {
|
||||
const env = {...process.env};
|
||||
const protectedKeys = new Set(
|
||||
Object.entries(process.env)
|
||||
.filter(([, value]) => String(value ?? '').trim())
|
||||
.map(([key]) => key),
|
||||
);
|
||||
for (const fileName of ['.env', '.env.local', '.env.secrets.local']) {
|
||||
loadEnvFile(resolve(REPO_ROOT, fileName), env, protectedKeys);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function loadEffectiveEnv(envFiles) {
|
||||
const env = loadRepoEnv();
|
||||
const protectedKeys = new Set(
|
||||
Object.entries(process.env)
|
||||
.filter(([, value]) => String(value ?? '').trim())
|
||||
.map(([key]) => key),
|
||||
);
|
||||
for (const filePath of envFiles) {
|
||||
loadEnvFile(resolvePath(filePath), env, protectedKeys);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
dataDir: '',
|
||||
workDir: '',
|
||||
bucket: '',
|
||||
endpoint: '',
|
||||
objectPrefix: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
envFiles: [],
|
||||
keepLocal: false,
|
||||
stopService: '',
|
||||
database: '',
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = () => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${arg} 缺少参数值`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case '--help':
|
||||
case '-h':
|
||||
usage();
|
||||
process.exit(0);
|
||||
break;
|
||||
case '--data-dir':
|
||||
options.dataDir = readValue();
|
||||
break;
|
||||
case '--work-dir':
|
||||
options.workDir = readValue();
|
||||
break;
|
||||
case '--bucket':
|
||||
options.bucket = readValue();
|
||||
break;
|
||||
case '--endpoint':
|
||||
options.endpoint = readValue();
|
||||
break;
|
||||
case '--object-prefix':
|
||||
options.objectPrefix = readValue();
|
||||
break;
|
||||
case '--access-key-id':
|
||||
options.accessKeyId = readValue();
|
||||
break;
|
||||
case '--access-key-secret':
|
||||
options.accessKeySecret = readValue();
|
||||
break;
|
||||
case '--env-file':
|
||||
options.envFiles.push(readValue());
|
||||
break;
|
||||
case '--database':
|
||||
options.database = readValue();
|
||||
break;
|
||||
case '--stop-service':
|
||||
options.stopService = readValue();
|
||||
break;
|
||||
case '--keep-local':
|
||||
options.keepLocal = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
options.dryRun = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values) {
|
||||
return values.map((value) => String(value ?? '').trim()).find(Boolean) ?? '';
|
||||
}
|
||||
|
||||
function resolvePath(value) {
|
||||
return isAbsolute(value) ? value : resolve(REPO_ROOT, value);
|
||||
}
|
||||
|
||||
function normalizeEndpoint(raw) {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^https?:\/\//u, '')
|
||||
.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function sanitizeObjectPart(value, fallback) {
|
||||
const sanitized = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/-+/gu, '-')
|
||||
.replace(/^-|-$/gu, '');
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function timestampForFile(date = new Date()) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
function buildBackupNames({database, dataDir, objectPrefix}) {
|
||||
const timestamp = timestampForFile();
|
||||
const databasePart = sanitizeObjectPart(database || basename(dataDir), 'spacetimedb');
|
||||
const fileName = `${databasePart}-${timestamp}.tar.gz`;
|
||||
const prefix = String(objectPrefix || 'database-backups')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/gu, '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((part) => sanitizeObjectPart(part, 'backup'))
|
||||
.join('/');
|
||||
const objectKey = [prefix, databasePart, fileName].filter(Boolean).join('/');
|
||||
return {fileName, objectKey};
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? REPO_ROOT,
|
||||
env: options.env ?? process.env,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? 'pipe',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`${command} 启动失败: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
||||
throw new Error(`${command} 退出码 ${result.status}: ${output}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stopServiceIfNeeded(serviceName) {
|
||||
if (!serviceName) {
|
||||
return false;
|
||||
}
|
||||
console.log(`[database-backup] 停止服务以获取冷备份: ${serviceName}`);
|
||||
runCommand('systemctl', ['stop', serviceName], {stdio: 'inherit'});
|
||||
return true;
|
||||
}
|
||||
|
||||
function startServiceIfNeeded(serviceName, wasStopped) {
|
||||
if (!serviceName || !wasStopped) {
|
||||
return;
|
||||
}
|
||||
console.log(`[database-backup] 恢复服务: ${serviceName}`);
|
||||
runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'});
|
||||
}
|
||||
|
||||
function createArchive({dataDir, workDir, fileName}) {
|
||||
if (!existsSync(dataDir)) {
|
||||
throw new Error(`数据库数据目录不存在: ${dataDir}`);
|
||||
}
|
||||
const stat = statSync(dataDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`数据库数据路径不是目录: ${dataDir}`);
|
||||
}
|
||||
mkdirSync(workDir, {recursive: true});
|
||||
const archivePath = resolve(workDir, fileName);
|
||||
const parentDir = dirname(dataDir);
|
||||
const entryName = basename(dataDir);
|
||||
console.log(`[database-backup] 打包: ${dataDir} -> ${archivePath}`);
|
||||
runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], {stdio: 'inherit'});
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
function hmac(key, content, encoding) {
|
||||
return createHmac('sha256', key).update(content).digest(encoding);
|
||||
}
|
||||
|
||||
function sha256Hex(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function regionFromEndpoint(endpoint) {
|
||||
const match = /^oss-([a-z0-9-]+)\./u.exec(endpoint);
|
||||
if (!match) {
|
||||
throw new Error(`无法从 OSS endpoint 推断 region: ${endpoint}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function formatScopeDate(date) {
|
||||
return timestampForFile(date).slice(0, 8);
|
||||
}
|
||||
|
||||
function formatOssDate(date) {
|
||||
return timestampForFile(date).replace(/[-:]/gu, '');
|
||||
}
|
||||
|
||||
function encodePath(path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment).replace(/[!'()*]/gu, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function canonicalHeaderValue(value) {
|
||||
return String(value).trim().replace(/\s+/gu, ' ');
|
||||
}
|
||||
|
||||
function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date}) {
|
||||
const region = regionFromEndpoint(endpoint);
|
||||
const scopeDate = formatScopeDate(date);
|
||||
const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`;
|
||||
const canonicalUri = `/${encodeURIComponent(bucket)}/${encodePath(objectKey)}`;
|
||||
const signedHeaders = Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), canonicalHeaderValue(value)]),
|
||||
);
|
||||
const canonicalHeaders = Object.entries(signedHeaders)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${value}\n`)
|
||||
.join('');
|
||||
const additionalHeaders = 'host';
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
'',
|
||||
canonicalHeaders,
|
||||
additionalHeaders,
|
||||
UNSIGNED_PAYLOAD,
|
||||
].join('\n');
|
||||
const stringToSign = [OSS_ALGORITHM, headers['x-oss-date'], scope, sha256Hex(canonicalRequest)].join('\n');
|
||||
const signature = hmac(Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), scopeDate);
|
||||
const regionKey = hmac(signature, region);
|
||||
const serviceKey = hmac(regionKey, OSS_SERVICE);
|
||||
const signingKey = hmac(serviceKey, OSS_REQUEST);
|
||||
const finalSignature = hmac(signingKey, stringToSign, 'hex');
|
||||
return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`;
|
||||
}
|
||||
|
||||
async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}) {
|
||||
const fileStat = statSync(archivePath);
|
||||
const now = new Date();
|
||||
const targetUrl = `https://${bucket}.${endpoint}/${encodePath(objectKey)}`;
|
||||
const headers = {
|
||||
host: `${bucket}.${endpoint}`,
|
||||
'content-type': 'application/gzip',
|
||||
'x-oss-content-sha256': UNSIGNED_PAYLOAD,
|
||||
'x-oss-date': formatOssDate(now),
|
||||
'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
|
||||
};
|
||||
const authorization = buildAuthorization({
|
||||
method: 'PUT',
|
||||
bucket,
|
||||
endpoint,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers,
|
||||
date: now,
|
||||
});
|
||||
|
||||
console.log(`[database-backup] 上传 OSS: oss://${bucket}/${objectKey}`);
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...headers,
|
||||
authorization,
|
||||
'content-length': String(fileStat.size),
|
||||
},
|
||||
body: createReadStream(archivePath),
|
||||
duplex: 'half',
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSS 上传失败 HTTP ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
bucket,
|
||||
objectKey,
|
||||
contentLength: fileStat.size,
|
||||
etag: response.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const env = loadEffectiveEnv(args.envFiles);
|
||||
const isProductionLike = existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32';
|
||||
const dataDir = resolvePath(firstNonEmpty(
|
||||
args.dataDir,
|
||||
env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR,
|
||||
isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR,
|
||||
));
|
||||
const workDir = resolvePath(firstNonEmpty(
|
||||
args.workDir,
|
||||
env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR,
|
||||
isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR,
|
||||
));
|
||||
const bucket = firstNonEmpty(args.bucket, env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, env.ALIYUN_OSS_BUCKET);
|
||||
const endpoint = normalizeEndpoint(firstNonEmpty(args.endpoint, env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, env.ALIYUN_OSS_ENDPOINT));
|
||||
const accessKeyId = firstNonEmpty(args.accessKeyId, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, env.ALIYUN_OSS_ACCESS_KEY_ID);
|
||||
const accessKeySecret = firstNonEmpty(args.accessKeySecret, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, env.ALIYUN_OSS_ACCESS_KEY_SECRET);
|
||||
const objectPrefix = firstNonEmpty(args.objectPrefix, env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, 'database-backups');
|
||||
const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir));
|
||||
const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true';
|
||||
|
||||
for (const [label, value] of Object.entries({bucket, endpoint, accessKeyId, accessKeySecret})) {
|
||||
if (!value) {
|
||||
throw new Error(`缺少 ${label} 配置`);
|
||||
}
|
||||
}
|
||||
|
||||
const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix});
|
||||
console.log(`[database-backup] 数据目录: ${dataDir}`);
|
||||
console.log(`[database-backup] 本地临时目录: ${workDir}`);
|
||||
console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('[database-backup] dry-run,仅校验配置,不打包上传。');
|
||||
return;
|
||||
}
|
||||
|
||||
let archivePath = '';
|
||||
let serviceStopped = false;
|
||||
try {
|
||||
serviceStopped = stopServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE));
|
||||
archivePath = createArchive({dataDir, workDir, fileName});
|
||||
} finally {
|
||||
startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped);
|
||||
}
|
||||
|
||||
const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret});
|
||||
console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`);
|
||||
|
||||
const manifestPath = `${archivePath}.manifest.json`;
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
`${JSON.stringify({
|
||||
createdAt: new Date().toISOString(),
|
||||
dataDir,
|
||||
bucket: result.bucket,
|
||||
objectKey: result.objectKey,
|
||||
contentLength: result.contentLength,
|
||||
etag: result.etag,
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
if (!keepLocal) {
|
||||
rmSync(archivePath, {force: true});
|
||||
rmSync(manifestPath, {force: true});
|
||||
console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。');
|
||||
} else {
|
||||
console.log(`[database-backup] 已保留本地备份: ${archivePath}`);
|
||||
console.log(`[database-backup] 已保留备份清单: ${manifestPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[database-backup] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -205,7 +205,7 @@ ensure_runtime_dir() {
|
||||
|
||||
ensure_runtime_env_and_dirs() {
|
||||
local api_env_file="$1"
|
||||
local tracking_enabled tracking_outbox_dir auth_store_path auth_store_dir
|
||||
local tracking_enabled tracking_outbox_dir
|
||||
|
||||
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
|
||||
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
|
||||
@@ -214,19 +214,12 @@ ensure_runtime_env_and_dirs() {
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
|
||||
|
||||
tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")"
|
||||
tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")"
|
||||
if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then
|
||||
ensure_runtime_dir "${tracking_outbox_dir}" "0750"
|
||||
fi
|
||||
|
||||
auth_store_path="$(read_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH")"
|
||||
if [[ -n "${auth_store_path}" ]]; then
|
||||
auth_store_dir="$(dirname "${auth_store_path}")"
|
||||
ensure_runtime_dir "${auth_store_dir}" "0750"
|
||||
fi
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -5,13 +5,14 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database]
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--skip-backup]
|
||||
|
||||
说明:
|
||||
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
|
||||
默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。
|
||||
默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。
|
||||
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
|
||||
publish 前默认执行一次 OSS 冷备份;备份失败会阻断 publish。仅明确传入 --skip-backup 时跳过。
|
||||
失败时保留维护模式。
|
||||
EOF
|
||||
}
|
||||
@@ -43,6 +44,7 @@ SERVER_URL="http://127.0.0.1:3101"
|
||||
SPACETIME_ROOT_DIR="/stdb"
|
||||
RUN_AS_USER="spacetimedb"
|
||||
CLEAR_DATABASE=0
|
||||
SKIP_BACKUP=0
|
||||
DEPLOY_COMPLETED=0
|
||||
PUBLISH_TMP_DIR=""
|
||||
|
||||
@@ -81,6 +83,10 @@ while [[ $# -gt 0 ]]; do
|
||||
CLEAR_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
--skip-backup)
|
||||
SKIP_BACKUP=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[production-stdb-publish] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
@@ -130,6 +136,26 @@ trap on_exit EXIT
|
||||
|
||||
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
|
||||
|
||||
if [[ "${SKIP_BACKUP}" -ne 1 ]]; then
|
||||
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
fi
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] publish 前执行 OSS 冷备份"
|
||||
node "${BACKUP_SCRIPT}" \
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--stop-service spacetimedb.service
|
||||
else
|
||||
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] 校验 wasm"
|
||||
(
|
||||
cd "${SOURCE_DIR}"
|
||||
|
||||
@@ -325,7 +325,6 @@ ensure_api_runtime_env_defaults() {
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
|
||||
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
|
||||
}
|
||||
|
||||
parse_json_string_field() {
|
||||
@@ -642,8 +641,20 @@ render_api_service() {
|
||||
deploy/systemd/genarrative-api.service
|
||||
}
|
||||
|
||||
render_database_backup_service() {
|
||||
local current_escaped env_escaped
|
||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||
env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
|
||||
sed \
|
||||
-e "s|/opt/genarrative/current|${current_escaped}|g" \
|
||||
-e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \
|
||||
deploy/systemd/genarrative-database-backup.service
|
||||
}
|
||||
|
||||
require_path deploy/systemd/spacetimedb.service
|
||||
require_path deploy/systemd/genarrative-api.service
|
||||
require_path deploy/systemd/genarrative-database-backup.service
|
||||
require_path deploy/systemd/genarrative-database-backup.timer
|
||||
require_path deploy/systemd/otelcol-contrib.service
|
||||
require_path deploy/otelcol/genarrative-debug.yaml
|
||||
require_path deploy/nginx/genarrative.conf
|
||||
@@ -663,7 +674,7 @@ run_cmd id
|
||||
install_build_dependencies
|
||||
install_nginx_brotli_modules
|
||||
install_sccache
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox
|
||||
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
|
||||
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
|
||||
@@ -693,11 +704,15 @@ sync_spacetime_install "${SPACETIME_ROOT}"
|
||||
|
||||
spacetimedb_service="$(mktemp)"
|
||||
api_service="$(mktemp)"
|
||||
database_backup_service="$(mktemp)"
|
||||
render_spacetimedb_service >"${spacetimedb_service}"
|
||||
render_api_service >"${api_service}"
|
||||
render_database_backup_service >"${database_backup_service}"
|
||||
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
|
||||
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
|
||||
rm -f "${spacetimedb_service}" "${api_service}"
|
||||
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
|
||||
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
|
||||
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}"
|
||||
|
||||
if [[ ! -f "${API_ENV_FILE}" ]]; then
|
||||
echo "+ create ${API_ENV_FILE} from example"
|
||||
@@ -732,7 +747,7 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl enable otelcol-contrib.service
|
||||
fi
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl restart otelcol-contrib.service
|
||||
fi
|
||||
|
||||
@@ -2,11 +2,12 @@ use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::{Extension, FromRef},
|
||||
http::Request,
|
||||
http::{Request, StatusCode},
|
||||
middleware,
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde_json::json;
|
||||
use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
trace::{DefaultOnRequest, TraceLayer},
|
||||
@@ -18,6 +19,7 @@ use crate::{
|
||||
backpressure::limit_concurrent_requests,
|
||||
creation_entry_config::require_creation_entry_route_enabled,
|
||||
error_middleware::normalize_error_response,
|
||||
http_error::AppError,
|
||||
modules,
|
||||
request_context::{RequestContext, attach_request_context, resolve_request_id},
|
||||
response_headers::propagate_request_id_header,
|
||||
@@ -164,6 +166,96 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub fn build_spacetime_unavailable_router(message: String) -> Router {
|
||||
Router::new()
|
||||
.fallback(spacetime_unavailable_handler)
|
||||
.layer(Extension(SpacetimeUnavailableState {
|
||||
message: message.into(),
|
||||
}))
|
||||
// 依赖不可用模式不挂业务 state,统一返回 503,并继续保留 request_id / API 版本 / 耗时响应头。
|
||||
.layer(middleware::from_fn(normalize_error_response))
|
||||
.layer(middleware::from_fn(propagate_request_id_header))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<Body>| {
|
||||
let request_id =
|
||||
resolve_request_id(request).unwrap_or_else(|| "unknown".to_string());
|
||||
let route = crate::telemetry::observability_route(request.uri().path());
|
||||
let scheme = crate::telemetry::resolve_request_scheme(request.headers());
|
||||
let span_name = format!("{} {}", request.method(), route);
|
||||
|
||||
info_span!(
|
||||
"http.request",
|
||||
otel.kind = "server",
|
||||
otel.name = %span_name,
|
||||
otel.status_code = tracing::field::Empty,
|
||||
http.response.status_code = tracing::field::Empty,
|
||||
method = %request.method(),
|
||||
http.request.method = %request.method(),
|
||||
http.route = %route,
|
||||
url.scheme = %scheme,
|
||||
url.path = %request.uri().path(),
|
||||
request_id = %request_id,
|
||||
status = tracing::field::Empty,
|
||||
latency_ms = tracing::field::Empty,
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(
|
||||
|response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
span.record("status", status);
|
||||
span.record("http.response.status_code", status);
|
||||
span.record(
|
||||
"otel.status_code",
|
||||
if response.status().is_server_error() {
|
||||
"ERROR"
|
||||
} else {
|
||||
"OK"
|
||||
},
|
||||
);
|
||||
span.record("latency_ms", latency_ms);
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|failure: ServerErrorsFailureClass,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
error!(
|
||||
parent: span,
|
||||
latency_ms,
|
||||
failure = %failure,
|
||||
"http request failed"
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.layer(middleware::from_fn(attach_request_context))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SpacetimeUnavailableState {
|
||||
message: std::sync::Arc<str>,
|
||||
}
|
||||
|
||||
async fn spacetime_unavailable_handler(
|
||||
Extension(state): Extension<SpacetimeUnavailableState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Response {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("SpacetimeDB 暂不可用,api-server 正在等待数据库恢复")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"reason": "spacetime_startup_unavailable",
|
||||
"message": state.message.as_ref(),
|
||||
}))
|
||||
.into_response_with_context(Some(&request_context))
|
||||
}
|
||||
|
||||
async fn record_api_tracking_after_success(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -368,7 +460,7 @@ mod tests {
|
||||
|
||||
use crate::{config::AppConfig, state::AppState};
|
||||
|
||||
use super::build_router;
|
||||
use super::{build_router, build_spacetime_unavailable_router};
|
||||
|
||||
const TEST_PASSWORD: &str = "secret123";
|
||||
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
|
||||
@@ -564,6 +656,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
|
||||
let app = build_spacetime_unavailable_router("SpacetimeDB 启动恢复认证快照超时".to_string());
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/auth/login-options")
|
||||
.header("x-request-id", "req-spacetime-unavailable")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("req-spacetime-unavailable")
|
||||
);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(body["error"]["code"], "SERVICE_UNAVAILABLE");
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"spacetime_startup_unavailable"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["provider"], "spacetimedb");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creation_entry_route_disabled_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
@@ -11,7 +11,6 @@ use platform_speech::{
|
||||
};
|
||||
|
||||
const DEFAULT_INTERNAL_API_SECRET: &str = "genarrative-dev-internal-bridge";
|
||||
const DEFAULT_AUTH_STORE_PATH: &str = "server-rs/.data/auth-store.json";
|
||||
const SPACETIME_LOCAL_CONFIG_FILE: &str = "spacetime.local.json";
|
||||
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
|
||||
|
||||
@@ -45,7 +44,6 @@ pub struct AppConfig {
|
||||
pub refresh_cookie_secure: bool,
|
||||
pub refresh_cookie_same_site: String,
|
||||
pub refresh_session_ttl_days: u32,
|
||||
pub auth_store_path: PathBuf,
|
||||
pub dev_password_entry_auto_register_enabled: bool,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub sms_auth_provider: String,
|
||||
@@ -184,7 +182,6 @@ impl Default for AppConfig {
|
||||
refresh_cookie_secure: false,
|
||||
refresh_cookie_same_site: "Lax".to_string(),
|
||||
refresh_session_ttl_days: 30,
|
||||
auth_store_path: PathBuf::from(DEFAULT_AUTH_STORE_PATH),
|
||||
dev_password_entry_auto_register_enabled: false,
|
||||
sms_auth_enabled: false,
|
||||
sms_auth_provider: "mock".to_string(),
|
||||
@@ -433,9 +430,6 @@ impl AppConfig {
|
||||
config.refresh_session_ttl_days = refresh_session_ttl_days;
|
||||
}
|
||||
|
||||
if let Some(auth_store_path) = read_first_non_empty_env(&["GENARRATIVE_AUTH_STORE_PATH"]) {
|
||||
config.auth_store_path = PathBuf::from(auth_store_path);
|
||||
}
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED"])
|
||||
{
|
||||
|
||||
@@ -236,7 +236,6 @@ mod tests {
|
||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use time::OffsetDateTime;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -394,12 +393,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn build_test_state(label: &str) -> AppState {
|
||||
let mut config = AppConfig::default();
|
||||
config.auth_store_path = PathBuf::from(format!(
|
||||
".codex-temp/api-server-auth-store-creation-doc-{label}.json"
|
||||
));
|
||||
let _ = std::fs::remove_file(&config.auth_store_path);
|
||||
|
||||
AppState::new(config).expect("state should build")
|
||||
let _ = label;
|
||||
AppState::new(AppConfig::default()).expect("state should build")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +107,13 @@ use std::{
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{info, warn};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||
use crate::{
|
||||
app::{build_router, build_spacetime_unavailable_router},
|
||||
config::AppConfig,
|
||||
state::{AppState, AppStateInitError},
|
||||
};
|
||||
|
||||
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||
const AUTH_STORE_STARTUP_RESTORE_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
@@ -156,14 +160,21 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
let otel_enabled = config.otel_enabled;
|
||||
let listener = build_tcp_listener(bind_address, listen_backlog)?;
|
||||
|
||||
let state = restore_app_state_for_startup(config)
|
||||
.await
|
||||
.map_err(|error| std::io::Error::other(format!("初始化应用状态失败:{error}")))?;
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
let router = build_router(state);
|
||||
let router = match restore_app_state_for_startup(config).await {
|
||||
Ok(state) => {
|
||||
state.puzzle_gallery_cache().spawn_cleanup_task();
|
||||
if let Some(outbox) = state.tracking_outbox() {
|
||||
outbox.spawn_worker();
|
||||
}
|
||||
build_router(state)
|
||||
}
|
||||
Err(AppStateInitError::DependencyUnavailable(message)) => {
|
||||
build_spacetime_unavailable_router(message)
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(std::io::Error::other(format!("初始化应用状态失败:{error}")));
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
%bind_address,
|
||||
@@ -192,7 +203,6 @@ fn build_tcp_listener(
|
||||
async fn restore_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
let fallback_config = config.clone();
|
||||
match timeout(
|
||||
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
|
||||
AppState::try_restore_auth_store_from_spacetime(config),
|
||||
@@ -201,11 +211,13 @@ async fn restore_app_state_for_startup(
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
error!(
|
||||
timeout_seconds = AUTH_STORE_STARTUP_RESTORE_TIMEOUT.as_secs(),
|
||||
"启动恢复认证快照超时,跳过远端恢复并继续启动 api-server"
|
||||
"启动等待 SpacetimeDB 恢复认证快照超时,api-server 将进入依赖不可用模式"
|
||||
);
|
||||
AppState::new(fallback_config)
|
||||
Err(state::AppStateInitError::DependencyUnavailable(
|
||||
"SpacetimeDB 启动恢复认证快照超时".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
error::Error,
|
||||
fmt, fs,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::extract::FromRef;
|
||||
@@ -300,6 +299,7 @@ pub enum AppStateInitError {
|
||||
Jwt(JwtError),
|
||||
RefreshCookie(RefreshCookieError),
|
||||
AuthStore(String),
|
||||
DependencyUnavailable(String),
|
||||
SmsProvider(SmsProviderError),
|
||||
WechatPay(String),
|
||||
Oss(OssError),
|
||||
@@ -308,12 +308,12 @@ pub enum AppStateInitError {
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
#[cfg(test)]
|
||||
let auth_store = InMemoryAuthStore::default();
|
||||
#[cfg(not(test))]
|
||||
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
Self::new_with_auth_store(config, auth_store)
|
||||
Self::new_with_empty_auth_store(config)
|
||||
}
|
||||
|
||||
pub fn new_with_empty_auth_store(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
// 中文注释:api-server 不再把本地 auth-store.json 当作用户认证真相源,启动恢复只允许来自 SpacetimeDB。
|
||||
Self::new_with_auth_store(config, InMemoryAuthStore::default())
|
||||
}
|
||||
|
||||
fn new_with_auth_store(
|
||||
@@ -549,8 +549,8 @@ impl AppState {
|
||||
OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000,
|
||||
)
|
||||
.map_err(|_| SpacetimeClientError::Runtime("认证快照更新时间超出 i64 范围".to_string()))?;
|
||||
// 本地 auth_store 是当前认证请求的即时真相源;SpacetimeDB 正式认证表用于跨进程恢复。
|
||||
// 远端数据库挂起或网络异常时,只降级远端恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
|
||||
// 当前进程内 auth_store 是认证请求的即时工作集;SpacetimeDB 正式认证表用于跨进程恢复。
|
||||
// 远端数据库挂起或网络异常时,只降级后续恢复能力,不能让已成功的登录/刷新/退出回滚为失败。
|
||||
#[cfg(not(test))]
|
||||
if let Err(error) = self
|
||||
.spacetime_client
|
||||
@@ -577,64 +577,42 @@ impl AppState {
|
||||
pool_size: config.spacetime_pool_size,
|
||||
procedure_timeout: config.spacetime_procedure_timeout,
|
||||
});
|
||||
let mut candidates = Vec::new();
|
||||
let mut spacetime_restore_available = false;
|
||||
let mut restore_errors = Vec::new();
|
||||
|
||||
match spacetime_client
|
||||
.export_auth_store_snapshot_from_tables()
|
||||
.await
|
||||
{
|
||||
Ok(snapshot) => {
|
||||
spacetime_restore_available = true;
|
||||
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
|
||||
snapshot,
|
||||
AuthStoreRestoreSource::SpacetimeTables,
|
||||
)? {
|
||||
candidates.push(candidate);
|
||||
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
|
||||
info!(
|
||||
source = candidate.source.as_str(),
|
||||
updated_at_micros = candidate.updated_at_micros,
|
||||
"已恢复认证快照"
|
||||
);
|
||||
return Ok(state);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "从 SpacetimeDB 表恢复认证快照失败");
|
||||
restore_errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
match spacetime_client.get_auth_store_snapshot().await {
|
||||
Ok(snapshot) => {
|
||||
if let Some(candidate) = auth_store_candidate_from_snapshot_record(
|
||||
snapshot,
|
||||
AuthStoreRestoreSource::SpacetimeSnapshot,
|
||||
)? {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(error = %error, "从 SpacetimeDB 快照记录恢复认证快照失败");
|
||||
}
|
||||
if !spacetime_restore_available {
|
||||
return Err(AppStateInitError::DependencyUnavailable(format!(
|
||||
"SpacetimeDB 认证恢复不可用:{}",
|
||||
restore_errors.join("; ")
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(candidate) = auth_store_candidate_from_local_file(&config)? {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
|
||||
if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
|
||||
let source = candidate.source;
|
||||
let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
|
||||
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
|
||||
info!(
|
||||
source = source.as_str(),
|
||||
updated_at_micros = candidate.updated_at_micros,
|
||||
"已恢复认证快照"
|
||||
);
|
||||
if should_sync_to_spacetime {
|
||||
if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
|
||||
warn!(
|
||||
error = %error,
|
||||
"本地认证快照回写 SpacetimeDB 失败,当前启动继续"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(state);
|
||||
}
|
||||
|
||||
Self::new(config)
|
||||
Self::new_with_empty_auth_store(config)
|
||||
}
|
||||
|
||||
pub fn refresh_session_service(&self) -> &RefreshSessionService {
|
||||
@@ -988,16 +966,12 @@ impl AppState {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AuthStoreRestoreSource {
|
||||
SpacetimeTables,
|
||||
SpacetimeSnapshot,
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
impl AuthStoreRestoreSource {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::SpacetimeTables => "spacetime_tables",
|
||||
Self::SpacetimeSnapshot => "spacetime_snapshot",
|
||||
Self::LocalFile => "local_file",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1029,57 +1003,14 @@ fn auth_store_candidate_from_snapshot_record(
|
||||
}))
|
||||
}
|
||||
|
||||
fn auth_store_candidate_from_local_file(
|
||||
config: &AppConfig,
|
||||
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
|
||||
if !config.auth_store_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let updated_at_micros = fs::metadata(&config.auth_store_path)
|
||||
.ok()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.and_then(system_time_to_unix_micros);
|
||||
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
|
||||
.map_err(AppStateInitError::AuthStore)?;
|
||||
|
||||
Ok(Some(AuthStoreRestoreCandidate {
|
||||
source: AuthStoreRestoreSource::LocalFile,
|
||||
updated_at_micros,
|
||||
auth_store,
|
||||
}))
|
||||
}
|
||||
|
||||
fn system_time_to_unix_micros(system_time: SystemTime) -> Option<i64> {
|
||||
let duration = system_time.duration_since(UNIX_EPOCH).ok()?;
|
||||
i64::try_from(duration.as_micros()).ok()
|
||||
}
|
||||
|
||||
fn select_auth_store_restore_candidate(
|
||||
candidates: Vec<AuthStoreRestoreCandidate>,
|
||||
) -> Option<AuthStoreRestoreCandidate> {
|
||||
candidates.into_iter().max_by_key(|candidate| {
|
||||
(
|
||||
candidate.updated_at_micros.unwrap_or(i64::MIN),
|
||||
auth_store_restore_source_priority(candidate.source),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
|
||||
match source {
|
||||
AuthStoreRestoreSource::SpacetimeTables => 3,
|
||||
AuthStoreRestoreSource::SpacetimeSnapshot => 2,
|
||||
AuthStoreRestoreSource::LocalFile => 1,
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AppStateInitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Jwt(error) => write!(f, "{error}"),
|
||||
Self::RefreshCookie(error) => write!(f, "{error}"),
|
||||
Self::AuthStore(error) | Self::WechatPay(error) => write!(f, "{error}"),
|
||||
Self::AuthStore(error) | Self::DependencyUnavailable(error) | Self::WechatPay(error) => {
|
||||
write!(f, "{error}")
|
||||
}
|
||||
Self::SmsProvider(error) => write!(f, "{error}"),
|
||||
Self::Oss(error) => write!(f, "{error}"),
|
||||
Self::Llm(error) => write!(f, "{error}"),
|
||||
|
||||
@@ -12,8 +12,6 @@ pub use events::*;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
@@ -33,7 +31,6 @@ use tracing::{info, warn};
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InMemoryAuthStore {
|
||||
inner: Arc<Mutex<InMemoryAuthStoreState>>,
|
||||
persistence_path: Option<Arc<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -887,7 +884,6 @@ impl Default for InMemoryAuthStore {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())),
|
||||
persistence_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -936,14 +932,6 @@ impl InMemoryAuthStoreState {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_temp_persistence_path(path: &Path) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("auth-store.json");
|
||||
path.with_file_name(format!("{file_name}.tmp"))
|
||||
}
|
||||
|
||||
impl InMemoryAuthStore {
|
||||
pub fn from_snapshot_json(snapshot_json: &str) -> Result<Self, String> {
|
||||
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(snapshot_json)
|
||||
@@ -952,25 +940,6 @@ impl InMemoryAuthStore {
|
||||
inner: Arc::new(Mutex::new(
|
||||
InMemoryAuthStoreState::from_persistent_snapshot(snapshot),
|
||||
)),
|
||||
persistence_path: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_persistence_path(path: impl Into<PathBuf>) -> Result<Self, String> {
|
||||
let path = path.into();
|
||||
let state = if path.is_file() {
|
||||
let raw_text =
|
||||
fs::read_to_string(&path).map_err(|error| format!("读取认证快照失败:{error}"))?;
|
||||
let snapshot = serde_json::from_str::<PersistentAuthStoreSnapshot>(&raw_text)
|
||||
.map_err(|error| format!("解析认证快照失败:{error}"))?;
|
||||
InMemoryAuthStoreState::from_persistent_snapshot(snapshot)
|
||||
} else {
|
||||
InMemoryAuthStoreState::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Mutex::new(state)),
|
||||
persistence_path: Some(Arc::new(path)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -985,30 +954,8 @@ impl InMemoryAuthStore {
|
||||
}
|
||||
|
||||
fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> {
|
||||
let Some(path) = self.persistence_path.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(parent_dir) = path.parent() {
|
||||
fs::create_dir_all(parent_dir).map_err(|error| {
|
||||
format!(
|
||||
"创建认证快照目录失败:{},路径:{}",
|
||||
error,
|
||||
parent_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let snapshot = state.to_persistent_snapshot();
|
||||
let raw_text = serde_json::to_string_pretty(&snapshot)
|
||||
.map_err(|error| format!("序列化认证快照失败:{error}"))?;
|
||||
let temp_path = build_temp_persistence_path(path);
|
||||
fs::write(&temp_path, raw_text)
|
||||
.map_err(|error| format!("写入认证快照临时文件失败:{error}"))?;
|
||||
fs::rename(&temp_path, path).map_err(|error| {
|
||||
let _ = fs::remove_file(&temp_path);
|
||||
format!("替换认证快照文件失败:{error}")
|
||||
})
|
||||
let _ = state;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_password_state(
|
||||
@@ -2545,15 +2492,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_store_restores_user_and_refresh_session_after_restart() {
|
||||
let store_path = std::env::temp_dir().join(format!(
|
||||
"genarrative-auth-store-{}.json",
|
||||
new_uuid_simple_string()
|
||||
));
|
||||
let _ = std::fs::remove_file(&store_path);
|
||||
|
||||
let store = InMemoryAuthStore::from_persistence_path(store_path.clone())
|
||||
.expect("persistent store should initialize");
|
||||
async fn snapshot_json_restores_user_and_refresh_session_after_roundtrip() {
|
||||
let store = InMemoryAuthStore::default();
|
||||
let user = create_phone_login_user(store.clone(), "13800138003").await;
|
||||
let password_service = build_password_service(store.clone());
|
||||
let refresh_service = build_refresh_service(store.clone());
|
||||
@@ -2576,10 +2516,12 @@ mod tests {
|
||||
OffsetDateTime::now_utc(),
|
||||
)
|
||||
.expect("refresh session should be persisted");
|
||||
drop(store);
|
||||
|
||||
let restored_store = InMemoryAuthStore::from_persistence_path(store_path.clone())
|
||||
.expect("persistent store should restore");
|
||||
let snapshot_json = store
|
||||
.export_snapshot_json()
|
||||
.expect("snapshot export should succeed");
|
||||
let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
|
||||
.expect("snapshot json should restore");
|
||||
let restored_user = build_password_service(restored_store.clone())
|
||||
.get_user_by_id(&user.id)
|
||||
.expect("restored user query should succeed")
|
||||
@@ -2597,8 +2539,6 @@ mod tests {
|
||||
)
|
||||
.expect("restored refresh session should rotate");
|
||||
assert_eq!(rotated.user.id, user.id);
|
||||
|
||||
let _ = std::fs::remove_file(&store_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user