This commit is contained in:
2026-04-21 19:17:31 +08:00
parent d234d27cc0
commit 89129ef1f4
83 changed files with 13329 additions and 176 deletions

View File

@@ -0,0 +1,140 @@
# 资产对象业务实体绑定 reducer 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 `M6` 中“对象绑定业务实体 reducer”的首版落地方案。
当前已经完成:
1. 浏览器可通过 `PostObject` 把文件直传到私有 OSS。
2. `POST /api/assets/objects/confirm` 已能确认对象存在。
3. `asset_object` 已按 `bucket + object_key` 写入 SpacetimeDB。
下一步要补上的最小闭环是:
1. 已确认的 `asset_object` 能绑定到某个业务实体。
2. 绑定关系由 SpacetimeDB 持久化。
3. Axum 提供最小 HTTP facade避免前端直接拼 SpacetimeDB reducer 参数。
## 2. 当前阶段不直接创建强业务表的原因
当前先落通用 `asset_entity_binding`,不直接创建 `character_visual_asset / scene_image_asset / sprite_sheet_asset`
原因固定如下:
1. 角色、场景、精灵等强业务表的完整字段还没有冻结。
2. 当前最紧急的工程闭环是“确认后的对象能被实体引用”,不是完整发布模型。
3. 通用绑定表可以先承接旧接口迁移中的 `entityId + slot` 关系,后续再由强业务表逐步替换或派生。
## 3. 表设计
首版新增 private table
1. `asset_entity_binding`
字段如下:
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `binding_id` | `String` | 是 | 主键,固定 `assetbind_` 前缀。 |
| `asset_object_id` | `String` | 是 | 被绑定的 `asset_object.asset_object_id`。 |
| `entity_kind` | `String` | 是 | 业务实体类型,例如 `character``scene``profile`。 |
| `entity_id` | `String` | 是 | 业务实体 ID。 |
| `slot` | `String` | 是 | 实体上的资产槽位,例如 `primary_visual``cover``sprite_sheet`。 |
| `asset_kind` | `String` | 是 | 资产类型,例如 `character_visual`。 |
| `owner_user_id` | `Option<String>` | 否 | 归属用户,当前仅作为服务端传入的记录字段。 |
| `profile_id` | `Option<String>` | 否 | 归属 profile。 |
| `created_at` | `Timestamp` | 是 | 首次绑定时间。 |
| `updated_at` | `Timestamp` | 是 | 最近绑定更新时间。 |
索引如下:
1. `entity_kind + entity_id + slot`
用于按实体槽位查当前绑定。
2. `asset_object_id`
用于按对象反查被哪些业务实体引用。
## 4. 幂等规则
绑定写入按以下规则执行:
1. `asset_object_id` 必须已存在于 `asset_object`
2. `entity_kind + entity_id + slot` 作为首版幂等定位键。
3. 同一实体槽位重复绑定时,不新增第二行。
4. 重复绑定会复用原 `binding_id``created_at`,更新 `asset_object_id / asset_kind / owner_user_id / profile_id / updated_at`
5. 不同槽位可以绑定同一个 `asset_object_id`
## 5. reducer / procedure 设计
SpacetimeDB 新增:
1. `bind_asset_object_to_entity`
reducer只返回 `Result<(), String>`,供后续模块内部复用。
2. `bind_asset_object_to_entity_and_return`
procedure面向 Axum 同步接口返回最终绑定快照。
procedure 返回结构采用:
1. `ok`
2. `record`
3. `error_message`
`asset_object` 确认 procedure 保持一致,便于 `spacetime-client` 做统一错误映射。
## 6. Axum HTTP facade
首版新增接口:
`POST /api/assets/objects/bind`
请求体:
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `assetObjectId` | `String` | 是 | 已确认对象 ID。 |
| `entityKind` | `String` | 是 | 业务实体类型。 |
| `entityId` | `String` | 是 | 业务实体 ID。 |
| `slot` | `String` | 是 | 资产槽位。 |
| `assetKind` | `String` | 是 | 资产类型。 |
| `ownerUserId` | `String` | 否 | 当前阶段由后端调用方显式传入。 |
| `profileId` | `String` | 否 | 当前阶段由后端调用方显式传入。 |
响应体核心字段:
1. `bindingId`
2. `assetObjectId`
3. `entityKind`
4. `entityId`
5. `slot`
6. `assetKind`
7. `ownerUserId`
8. `profileId`
9. `createdAt`
10. `updatedAt`
## 7. 当前阶段安全边界
当前接口是 Axum facade不是前端直接调用 SpacetimeDB reducer 的最终权限模型。
约束如下:
1. 当前不把长期 OSS AK/SK 下发给客户端。
2. 当前不让客户端直接写 private table。
3. `owner_user_id` 当前只作为记录字段,不作为可信授权依据。
4. 后续接入 SpacetimeDB 身份透传后,绑定 reducer 的授权必须改为基于可信身份,不信任客户端传入的用户 ID。
## 8. 完成定义
首版完成条件:
1. `module-assets` 提供绑定输入、快照、结果结构与字段校验。
2. `spacetime-module` 新增 `asset_entity_binding` 表与绑定 reducer/procedure。
3. `spacetime-client` 生成最新 Rust bindings 并封装绑定 procedure。
4. `api-server` 暴露 `POST /api/assets/objects/bind`
5. 本地测试覆盖字段错误与 “asset_object 不存在不能绑定”。
## 9. 一句话结论
当前阶段先用通用 `asset_entity_binding` 把已确认 OSS 对象绑定到业务实体槽位,强业务资产表等字段稳定后再继续拆分。

View File

@@ -0,0 +1,185 @@
# 资产对象上传完成确认接口设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `M6` 中“上传完成后的对象确认接口”冻结到可直接编码的级别。
当前要解决的不是完整资产发布链,而是最小闭环:
1. 浏览器先通过 `PostObject` 把文件上传到私有 OSS
2. 服务端确认对象真实存在
3. 服务端把对象元数据写入当前阶段的 `asset_object` 真相存储
4. 后续业务绑定与 SpacetimeDB reducer 再基于这条已确认对象继续扩展
## 2. 当前前提
已落地事实:
1. `POST /api/assets/direct-upload-tickets` 已能签发浏览器 `PostObject` 直传票据。
2. `platform-oss` 已能生成私有读签名 URL。
3. `asset_object` 已在 `spacetime-module` 中落下首版表骨架。
当前仍未落地:
1. 上传完成确认接口
2. 对象 HEAD 校验
3. `asset_object` 实际写入路径
4. 业务实体绑定
## 3. 接口职责
`POST /api/assets/objects/confirm` 当前阶段只负责三件事:
1. 校验请求给出的 `bucket + object_key` 是否合法
2. 调 OSS 做一次私有 `HEAD Object` 校验,确认对象真实存在
3. 把对象元数据写入当前阶段的 `asset_object` 进程内存储,并返回确认结果
当前阶段明确不做:
1. 不做业务实体绑定
2. 不做图片尺寸探测
3. 不做 hash 计算
4. 不做重复对象合并
5. 不直接调用 SpacetimeDB reducer
## 4. 请求体设计
请求路径:
`POST /api/assets/objects/confirm`
请求体:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `bucket` | `String` | 否 | 当前阶段允许不传;不传时默认回落到服务端 OSS bucket。 |
| `objectKey` | `String` | 是 | 正式对象路径真相字段。 |
| `contentType` | `String` | 否 | 客户端已知 MIME可回写到对象元数据。 |
| `contentLength` | `u64` | 否 | 客户端可传期望大小;当前仅用于一致性校验,不作为唯一真相来源。 |
| `contentHash` | `String` | 否 | 后续内容摘要预留字段。 |
| `assetKind` | `String` | 是 | 业务资产类型,例如 `character_visual`。 |
| `accessPolicy` | `String` | 否 | 默认 `private`。 |
| `sourceJobId` | `String` | 否 | 来源任务 ID。 |
| `ownerUserId` | `String` | 否 | 归属用户 ID。 |
| `profileId` | `String` | 否 | 归属 profile ID。 |
| `entityId` | `String` | 否 | 归属业务实体 ID。 |
补充约束:
1. `bucket` 当前若传入,必须与服务端已配置 bucket 一致。
2. `objectKey` 必须落在受支持的 `generated-*` 前缀下。
3. `assetKind` 当前不能为空。
## 5. 校验顺序
接口校验顺序固定如下:
1. 检查 OSS 配置是否存在
2. 校验请求参数基础合法性
3. 校验 `bucket` 与服务端配置 bucket 是否一致
4. 调用 OSS `HEAD Object`
5. 若客户端传了 `contentLength`,则与 OSS 返回的真实 `Content-Length` 做一致性校验
6. 通过后写入 `asset_object`
## 6. OSS 校验结果口径
OSS `HEAD Object` 当前至少回填:
1. `content_length`
2. `content_type`
3. `last_modified_at`
4. `etag`
当前阶段以 OSS 返回值为准:
1. `content_length` 真相取 OSS
2. `content_type` 优先取 OSSOSS 未返回时再回退请求体
3. `content_hash` 暂不强行等于 `etag`
原因:
1. `etag` 对 multipart 上传和不同上传模式并不总等价于内容 hash
2. 当前阶段先留出字段,不把错误假设固化进 schema
## 7. 写入规则
确认成功后写入 `asset_object`
1. `asset_object_id` 由服务端生成,固定 `assetobj_` 前缀
2. `bucket``object_key` 按正式真相写入
3. `access_policy` 当前默认 `private`
4. `content_length` 以 OSS HEAD 为准
5. `content_type` 优先 OSS HEAD
6. `version` 当前固定为 `1`
7. `created_at` / `updated_at` 在确认时写当前 UTC 时间
当前阶段重复确认同一 `bucket + object_key` 的行为固定为:
1. 若已存在,则返回已存在记录并更新 `updated_at`
2. 不生成第二条重复对象记录
## 8. 响应体设计
成功响应核心字段:
1. `assetObjectId`
2. `bucket`
3. `objectKey`
4. `accessPolicy`
5. `contentType`
6. `contentLength`
7. `contentHash`
8. `assetKind`
9. `sourceJobId`
10. `ownerUserId`
11. `profileId`
12. `entityId`
13. `version`
14. `createdAt`
15. `updatedAt`
## 9. 错误口径
### 9.1 请求参数错误
返回 `400`
1. `objectKey` 为空
2. `objectKey` 不在受支持前缀下
3. `assetKind` 为空
4. `bucket` 与当前配置 bucket 不一致
5. 客户端声明的 `contentLength` 与 OSS HEAD 不一致
### 9.2 OSS 未配置
返回 `503`
### 9.3 OSS 对象不存在
返回 `404`
### 9.4 OSS 探测失败
返回 `502`
## 10. 当前阶段实现边界
当前阶段实现固定为:
1. `platform-oss` 增加服务端 `HEAD Object` helper
2. `module-assets` 提供进程内 `asset_object` 确认服务
3. `api-server` 接入 `POST /api/assets/objects/confirm`
下一阶段再继续:
1. 对接真实 SpacetimeDB reducer
2. 业务实体绑定 reducer
3. 更细的元数据探测
## 11. 关联文档
1. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
2. [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
3. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)

View File

@@ -0,0 +1,237 @@
# Axum 到 SpacetimeDB 的资产对象确认调用方案
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 `POST /api/assets/objects/confirm` 从当前“进程内确认写入”切换到“真实 SpacetimeDB 持久化”的最小落地方案。
当前要解决的问题只有一个:
1. `api-server` 在完成 OSS `HEAD Object` 校验后,如何把确认结果稳定写入 `spacetime-module.asset_object`
这份文档需要把以下信息冻结到可以直接编码的级别:
1. 本地 SpacetimeDB server 口径
2. 本地数据库名
3. `spacetime-module` 内部 reducer / procedure 的职责分工
4. `spacetime-client` 在当前阶段的最小实现方式
5. `api-server` 的切换边界
## 2. 当前约束
已确认事实如下:
1. 阿里云 OSS 当前按私有 bucket 接入。
2. `api-server` 当前已经完成:
- `POST /api/assets/direct-upload-tickets`
- `GET /api/assets/read-url`
- `POST /api/assets/objects/confirm`
3. `platform-oss` 已具备:
- `PostObject` 直传签名
- 私有 `GET` 签名 URL
- 私有 `HEAD Object` 探测
4. `spacetime-module` 当前已具备:
- `asset_object` 首版表骨架
- `bucket + object_key` 双列定位索引
5. 当前 `module-assets` 的对象确认仍然写入进程内 store不是正式数据库真相。
## 3. 当前阶段的职责拆分
### 3.1 Axum 负责的部分
`api-server` 当前阶段继续负责以下职责:
1. 接收 HTTP 请求
2. 校验请求体字段
3. 校验 `bucket` 与服务端配置的一致性
4. 调用 OSS `HEAD Object`
5. 组装“已确认对象元数据”
6. 调用 SpacetimeDB 持久化入口
7. 把持久化结果转换成当前 HTTP 响应 contract
### 3.2 SpacetimeDB 负责的部分
`spacetime-module` 当前阶段只负责以下纯状态职责:
1. 依据 `bucket + object_key` 查重
2. 已存在则复用原 `asset_object_id``created_at`
3. 已存在则更新 `updated_at` 与最新元数据
4. 不存在则插入新对象行
5. 返回持久化后的对象记录
### 3.3 不允许的职责漂移
当前阶段明确不允许:
1. 在 reducer / procedure 内直接访问 OSS
2. 在 reducer / procedure 内直接访问 HTTP 请求头、Cookie 或 Axum context
3.`api-server` 内重新实现第二套 `asset_object` 去重规则
4. 通过 CLI 文本解析做正式持久化主链
## 4. 本地开发口径冻结
### 4.1 本地 server 地址
从当前版本开始,`server-rs/scripts/spacetime-dev.ps1``server-rs/scripts/spacetime-dev.sh` 的默认监听口径统一为:
1. `127.0.0.1:3000`
原因固定如下:
1. `spacetime` CLI 的默认 `local` server 昵称当前指向 `http://127.0.0.1:3000`
2. 若脚本默认改到 `3001`,则 `publish / call / generate` 与本地调试口径会长期错位
3. `api-server` 默认占用 `3000` 仅限当前进程,不影响 SpacetimeDB 独立开发脚本通过单独终端启动
补充说明:
1. 若需要与本地 Axum 同时运行,可显式传参改端口。
2. 默认口径必须先回到 CLI 约定,避免文档、脚本和发布命令长期分叉。
### 4.2 本地数据库名
当前资产对象确认链路的本地数据库名固定为:
1. `genarrative-dev`
原因固定如下:
1. 名称满足 `spacetime publish` 的数据库命名规则
2. 后续 auth / runtime / asset 的 schema 可以先统一聚合到同一开发数据库
3. 当前仓库还没有按模块拆分多个独立数据库的明确方案
### 4.3 当前阶段的标准命令
本地开发标准命令固定如下:
```bash
spacetime start --listen-addr 127.0.0.1:3000
spacetime publish genarrative-dev --server local --yes --module-path server-rs/crates/spacetime-module
spacetime generate --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --module-path server-rs/crates/spacetime-module --include-private --yes
```
## 5. 为什么当前阶段不用 CLI 文本解析
当前阶段不采用 `spacetime call / spacetime sql` 的正式主链方案,原因固定如下:
1. CLI 输出更适合人工调试,不适合作为稳定业务协议层
2. `api-server` 若依赖命令行文本解析,会把错误处理、超时、返回值解析和平台兼容复杂度放大
3. 当前 `asset_object` 是 private table不适合继续叠加一层 CLI 查询拼装返回值
因此当前阶段改为:
1. 用 SpacetimeDB Rust SDK + codegen bindings 做正式调用
2.`procedure` 返回确认结果
3.`try_with_tx` 在 procedure 内完成原子 upsert
## 6. `spacetime-module` 的调用面设计
### 6.1 reducer
当前阶段保留一个内部 reducer
1. `confirm_asset_object`
职责:
1. 承载真实 upsert 规则
2. 便于后续被其他 reducer 或 scheduled logic 复用
3. 明确 `asset_object` 的唯一写入规则在模块内收口
返回规则:
1. reducer 只返回 `Result<(), String>`
2. 不直接返回对象记录
### 6.2 procedure
当前阶段新增一个对 Axum 友好的 procedure
1. `confirm_asset_object_and_return`
职责:
1. 接收 Axum 已经确认好的对象元数据
2.`try_with_tx` 中调用共享 upsert 逻辑
3. 返回持久化后的 `asset_object` DTO
原因固定如下:
1. `POST /api/assets/objects/confirm` 是同步确认接口,需要立即返回 `assetObjectId` 等字段
2. reducer 本身不返回业务数据
3. procedure 可以在不做外部 IO 的前提下返回 `SpacetimeType` 结果
## 7. `spacetime-client` 当前阶段设计
`spacetime-client` 当前阶段只实现一条最小链路:
1. 连接指定 SpacetimeDB server
2. 等待 SDK `on_connect` 回调确认连接已经收到 `IdentityToken`
3.`on_connect` 后调用 `confirm_asset_object_and_return`
4. 获取返回 DTO
实现约束固定如下:
1. 不允许在 `DbConnection::build()` 返回后立刻发 procedure因为 build 只代表 WebSocket 初始化完成,不代表 SpacetimeDB 身份握手已经完成。
2. procedure 调用、异步连接失败、断线和超时必须收口到同一个结果通道,避免 HTTP 请求在 SDK idle timeout 后才失败。
3. 当前阶段每次 HTTP 确认请求可以建立一条短连接,待真实链路验证稳定后再评估连接池或长连接复用。
当前阶段不做:
1. 通用订阅框架
2. 多数据库路由
3. 通用 reducer / procedure 适配器
4. 前端直连复用层
## 8. `api-server` 的切换规则
从当前版本开始,`POST /api/assets/objects/confirm` 的真实主链改为:
1. `api-server`
2. `platform-oss HEAD Object`
3. `spacetime-client`
4. `spacetime-module.confirm_asset_object_and_return`
5. `asset_object`
当前进程内 `AssetObjectService` 退化为:
1. 共享字段校验
2. 共享 Axum 侧对象确认编排
3. 本地无 SpacetimeDB 配置时的临时 fallback 不再作为默认主链
## 9. 环境变量口径
当前阶段新增以下环境变量:
1. `GENARRATIVE_SPACETIME_SERVER_URL`
默认 `http://127.0.0.1:3000`
2. `GENARRATIVE_SPACETIME_DATABASE`
默认 `genarrative-dev`
3. `GENARRATIVE_SPACETIME_TOKEN`
可选;未配置时默认匿名连接
补充说明:
1. 当前本地开发可以先匿名连接。
2. 后续若要做 Axum -> SpacetimeDB 的身份透传,再单独冻结 JWT / OIDC token 的传递策略。
## 10. 当前阶段验收标准
当以下条件满足时,本方案视为落地完成:
1. `[x]` `spacetime-module` 新增 `confirm_asset_object` reducer 与 `confirm_asset_object_and_return` procedure
2. `[x]` `spacetime-client` 能调用该 procedure 并拿到返回值
3. `[x]` `api-server` 默认不再依赖进程内对象 store 作为正式真相
4. `[x]` `server-rs/scripts/spacetime-dev.*` 默认端口回到 `3000`
5. `[x]` 本地可以通过 publish + API 测试跑通真实 `asset_object` 写入
`2026-04-21` 已完成验收:
1. `cargo test -p api-server confirm_asset_object_live_roundtrip_persists_confirmed_record --manifest-path server-rs/Cargo.toml -- --ignored --nocapture --test-threads=1` 通过。
2. `spacetime sql genarrative-dev --server local -y "SELECT asset_object_id, bucket, object_key, asset_kind, content_length FROM asset_object"` 可查到 `bucket = "xushi-dev"``generated-characters/confirm-live-test/.../master.txt` 的确认记录。
## 11. 一句话结论
当前阶段 `POST /api/assets/objects/confirm` 的正式主链应当是:
**Axum 完成 OSS 校验,再通过 Rust SDK 调用 SpacetimeDB procedure在模块内部用事务 upsert `asset_object` 并把最终对象记录同步返回。**

View File

@@ -0,0 +1,152 @@
# M6 OSS 服务端上传与 STS 接口收口设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 `M6` 中“上传与对象确认”剩余两项的工程落地口径:
1. `STS` 临时授权接口
2. 服务端上传 helper
当前产品需求已经明确为:
1. 服务器向 AI 请求图片生成。
2. 服务器把生成资源上传到 OSS。
3. Web 端只负责访问和下载,不直接持有 OSS 写权限。
因此本轮不再把 STS 作为默认上传主链,而是把它实现为一个明确禁用的接口 contract避免后续误把临时写凭证下发给浏览器。
## 2. 当前已具备能力
当前 `M6` 上传与对象确认链路已具备:
1. `POST /api/assets/direct-upload-tickets`
2. OSS `PostObject` 浏览器直传签名
3. OSS 私有 `GET` 短期签名读取 URL
4. `GET /api/assets/read-url`
5. OSS `HEAD Object` 校验
6. `POST /api/assets/objects/confirm`
7. `asset_object` 写入 SpacetimeDB
8. `POST /api/assets/objects/bind`
9. `asset_entity_binding` 业务实体槽位绑定
## 3. STS 临时授权接口口径
### 3.1 路径
`POST /api/assets/sts-upload-credentials`
### 3.2 当前阶段行为
当前阶段固定返回 `403`,不下发任何临时写凭证。
响应错误详情固定包含:
| 字段 | 说明 |
| --- | --- |
| `provider` | 固定为 `aliyun-sts` |
| `enabled` | 固定为 `false` |
| `reason` | 当前不开放浏览器 OSS 写权限的中文原因 |
### 3.3 为什么不是 501
这里不用 `501 Not Implemented`,因为接口 contract 已实现;未开放的是能力开关,而不是路由缺失。
使用 `403` 的含义是:
1. 调用方不应尝试从 Web 端申请 OSS 写权限。
2. 当前上传主链应走服务端上传或既有 `PostObject` 直传票据。
3. 后续若确实要开放 STS需要先补专门的权限边界设计。
## 4. 服务端上传 helper 口径
### 4.1 所属 crate
服务端上传能力落在:
`server-rs/crates/platform-oss`
原因:
1. OSS 签名、元数据、对象键归一化都属于平台适配能力。
2. `api-server`、AI worker、后续资产任务系统都应复用同一套 helper。
3. 不允许在各业务模块里重复实现 OSS 签名。
### 4.2 输入字段
服务端上传 helper 使用 `OssPutObjectRequest`,字段固定为:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `prefix` | `LegacyAssetPrefix` | 是 | 兼容旧 `/generated-*` 的对象前缀 |
| `path_segments` | `Vec<String>` | 是 | 对象键中间路径片段 |
| `file_name` | `String` | 是 | 文件名,内部会做安全归一化 |
| `content_type` | `Option<String>` | 否 | MIME写入 `Content-Type` |
| `access` | `OssObjectAccess` | 是 | 当前仅作为 contract 字段,不默认改变 bucket ACL |
| `metadata` | `BTreeMap<String, String>` | 是 | 自动归一化为 `x-oss-meta-*` |
| `body` | `Vec<u8>` | 是 | 要写入 OSS 的对象内容 |
### 4.3 输出字段
服务端上传 helper 返回 `OssPutObjectResponse`
| 字段 | 说明 |
| --- | --- |
| `provider` | 固定为 `aliyun-oss` |
| `bucket` | 服务端配置的 bucket |
| `endpoint` | 服务端配置的 endpoint |
| `host` | bucket host |
| `objectKey` | 正式对象键 |
| `legacyPublicPath` | 兼容旧前端路径习惯的 `/generated-*` 路径 |
| `contentType` | 归一化后的 MIME |
| `contentLength` | 实际上传字节数 |
| `access` | 请求传入的访问策略 contract |
| `etag` | OSS 响应头中的 ETag当前不强制等同内容 hash |
| `lastModified` | OSS 响应头中的 Last-Modified |
## 5. 对象确认衔接
服务端上传 helper 只负责把二进制写入 OSS不直接写 SpacetimeDB。
调用方在上传成功后必须继续执行对象确认:
1.`POST /api/assets/objects/confirm`
2. 或在 Axum 内部复用同等确认逻辑
3. 确认链路必须继续通过 `HEAD Object` 读取 OSS 真值
4. `asset_object` 仍以 `bucket + object_key` 为正式真相
这样可以避免:
1. helper 上传成功但资产对象未落库
2. AI worker 绕过确认链路写出不完整记录
3. 把 OSS 响应中的派生 URL 当成对象真相
## 6. 与 Web 端的边界
Web 端当前只允许:
1. 请求后端业务接口触发生成
2. 通过 `GET /api/assets/read-url` 换取私有读签名 URL
3. 使用签名 URL 展示或下载对象
Web 端当前不允许:
1. 持有长期 `AccessKeyId / AccessKeySecret`
2. 持有 STS 写权限
3. 直接把 `/generated-*` 当成私有 OSS 可匿名读取 URL
## 7. 当前阶段完成定义
当以下内容落地后,本轮“上传与对象确认”剩余部分视为完成:
1. `POST /api/assets/sts-upload-credentials` 返回明确禁用 contract。
2. `platform-oss` 具备服务端 `PUT Object` helper。
3. 服务端上传 helper 复用现有对象键、元数据、签名与错误规范。
4. 文档与 `M6` 任务清单同步更新。
## 8. 关联文档
1. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
2. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
3. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)

View File

@@ -0,0 +1,152 @@
# Axum 手机验证码登录最小闭环设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 Rust `api-server + module-auth` 当前阶段手机号验证码登录的最小实现边界,避免在接入真实阿里云短信 adapter 前,把接口 contract、mock 行为和后续 SpacetimeDB 数据模型混在一起。
## 2. 当前阶段范围
本阶段只落以下内容:
1. `POST /api/auth/phone/send-code`
2. `POST /api/auth/phone/login`
3. 进程内 mock 验证码存储与校验
4. 登录成功后签发 access token 与 refresh cookie
本阶段明确不包含:
1. 真实阿里云短信发送
2. 图形验证码
3. 风控封禁
4. 手机号换绑
5. 微信补绑手机号
## 3. 固定 mock 规则
当前阶段统一使用以下 mock 约束:
1. 固定验证码:`123456`
2. 验证码 TTL`5` 分钟
3. 冷却秒数:`60`
4. 手机号必须按中国大陆手机号规则校验
## 4. 接口 contract
### 4.1 `POST /api/auth/phone/send-code`
请求体:
```json
{
"phone": "13800138000",
"scene": "login"
}
```
当前允许的 `scene`
1. `login`
2. `bind_phone`
3. `change_phone`
成功响应:
```json
{
"ok": true,
"cooldownSeconds": 60,
"expiresInSeconds": 300,
"providerRequestId": null
}
```
### 4.2 `POST /api/auth/phone/login`
请求体:
```json
{
"phone": "13800138000",
"code": "123456"
}
```
成功响应:
```json
{
"token": "access-token",
"user": {
"id": "user_00000001",
"username": "phone_00000002",
"displayName": "138****8000",
"phoneNumberMasked": "138****8000",
"loginMethod": "phone",
"bindingStatus": "active",
"wechatBound": false
}
}
```
同时必须写回 refresh cookie。
## 5. 用户创建与复用规则
1. 手机号首次登录时自动建号
2. 同一手机号再次登录时复用同一系统用户
3. access token 的 `provider` 固定为 `phone`
4. 用户快照中的 `loginMethod` 固定为 `phone`
## 6. 错误语义
当前阶段统一约束为:
1. 手机号格式错误:`400`
2. 场景非法:`400`
3. 验证码不存在、错误或过期:`400`
4. 服务内部存储或签发失败:`500`
## 7. crate 边界
### 7.1 `module-auth`
负责:
1. 手机号规范化
2. mock 验证码写入与校验
3. 手机号用户创建与复用
### 7.2 `api-server`
负责:
1. HTTP 请求解析
2. 场景字符串映射
3. 登录成功后创建会话、签发 JWT、写回 refresh cookie
### 7.3 `platform-auth`
负责:
1. 签发 access token
2. 生成 refresh cookie
## 8. 测试要求
至少覆盖:
1. `send-code` 成功返回 mock 冷却与过期秒数
2. 默认错误场景返回 `400`
3. 首次手机号登录会自动建号并写 refresh cookie
4. 同一手机号重复登录复用同一用户
## 9. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 侧已提供 `send-code``phone/login`
2. mock 验证码链路可跑通
3. 手机号登录成功后能拿到 access token 与 refresh cookie
4. 文档、任务清单与测试已同步更新

View File

@@ -0,0 +1,108 @@
# Axum 手机验证码冷却与失败次数限制设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 Rust `api-server + module-auth` 当前阶段短信验证码的最小防刷边界,避免在真实阿里云短信 adapter、图形验证码和风控封禁尚未接入前验证码可以被无限重复发送或无限试错。
## 2. 当前阶段范围
本阶段只落以下内容:
1. 同一手机号、同一业务场景的发送冷却。
2. 同一验证码快照的错误次数限制。
3. Axum 对冷却和错误次数耗尽返回 `429`
4. `module-auth``api-server` 回归测试。
本阶段明确不包含:
1. 真实短信供应商发送。
2. IP / 设备维度频率限制。
3. 图形验证码触发。
4. `auth_risk_block` 风控封禁。
5. SpacetimeDB 表写入。
## 3. 固定规则
当前阶段规则冻结为:
1. 验证码仍使用 mock 固定值:`123456`
2. 验证码有效期仍为 `5` 分钟。
3. 发送冷却仍为 `60` 秒。
4. 同一验证码最多允许错误 `5` 次。
5. 错误次数达到上限后立即删除该验证码快照,用户必须重新获取验证码。
## 4. 发送冷却语义
冷却维度:
1. 归一化后的手机号。
2. 业务场景:`login` / `bind_phone` / `change_phone`
处理规则:
1. 若不存在有效验证码,允许发送并写入新快照。
2. 若已有验证码已过期,删除旧快照后允许发送。
3. 若已有验证码未过期,且距离上次发送不足 `60` 秒,返回 `429`
4. 若已有验证码未过期,但已经超过 `60` 秒,允许覆盖旧快照并重置错误次数。
## 5. 错误次数语义
处理规则:
1. 验证码格式非法仍直接返回 `400`,不计入某个手机号快照。
2. 验证码不存在或已过期仍返回 `400`
3. 验证码错误时,给当前快照的失败次数加 `1`
4. 错误次数未达到上限时返回 `400`
5. 错误次数达到 `5` 次时删除验证码快照并返回 `429`
6. 正确验证码登录成功后删除验证码快照。
## 6. HTTP contract
### 6.1 冷却中重复发送
状态码:`429`
响应体沿用当前统一错误体:
```json
{
"ok": false,
"error": {
"code": "TOO_MANY_REQUESTS",
"message": "验证码发送过于频繁,请稍后再试",
"details": {
"retryAfterSeconds": 59
}
}
}
```
同时写回 `Retry-After` 响应头。
### 6.2 验证码错误次数耗尽
状态码:`429`
响应体沿用当前统一错误体:
```json
{
"ok": false,
"error": {
"code": "TOO_MANY_REQUESTS",
"message": "验证码错误次数过多,请重新获取验证码"
}
}
```
## 7. 验收标准
满足以下条件时,本任务视为完成:
1. 同一手机号同一场景在 `60` 秒内重复发送会返回 `429`
2. 同一手机号不同场景互不影响。
3. 同一验证码错误 `5` 次后会返回 `429` 并删除验证码。
4. 重新获取验证码后可以继续正常登录。
5. Rust 定向测试通过。

View File

@@ -0,0 +1,127 @@
# 手机验证码登录阶段 A 落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结当前仓库“手机验证码登录首个可用切片”的实现边界,避免继续出现:
1. 后端短信登录接口已经存在,但默认前台仍被游客兜底抢走入口。
2. 平台公开浏览请求和正式登录态互相污染,导致公开首页能把已登录态误判成失效。
3. smoke 只覆盖旧密码/游客链路,没有真正验证手机号验证码登录主链。
## 2. 当前基线
截至 `2026-04-21`,仓库里已经存在以下能力:
1. `server-node/src/routes/authRoutes.ts`
- 已暴露 `/api/auth/phone/send-code`
- 已暴露 `/api/auth/phone/login`
2. `server-node/src/auth/authService.ts`
- 已实现发送验证码、验证码校验、账号创建/登录、审计、风控与 refresh session 写入
3. `src/components/auth/LoginScreen.tsx`
- 已有手机号输入、验证码输入、获取验证码按钮与登录按钮
4. `src/components/auth/AuthGate.tsx`
- 已能在受保护动作上拉起登录弹窗
问题不在“有没有代码”,而在“默认体验和回归验证是否真的把手机号登录当成正式入口”。
## 3. 本阶段目标
阶段 A 只收敛到以下结果:
1. 未登录用户进入平台时,默认走公开浏览,不再默认隐式创建游客账号。
2. 点击受保护动作时,弹出手机号验证码登录弹窗。
3. 获取验证码后,前台给出简洁明确的发送成功反馈与冷却态。
4. 平台公开只读请求即使返回 `401`,也不能顺手清空当前 access token。
5. `scripts/smoke-server-node.ts` 必须实际覆盖一次手机号验证码发送与登录。
## 4. 明确范围
### 4.1 本次必须落地
1. `AuthGate` 中开发游客兜底改为“显式开关才允许开启”。
2. `LoginScreen` 增加验证码发送成功反馈。
3. `apiClient``skipAuth + skipRefresh` 的公开请求做登录态隔离。
4. smoke 脚本补齐手机号验证码登录主链。
5. `.env.example` 默认值与实际代码语义一致。
### 4.2 本次不做
1. 微信登录正式联调
2. 绑定手机号链路新增产品动作
3. account center 二期能力扩展
4. Rust / Axum 侧手机号登录迁移
## 5. 关键规则
## 5.1 开发游客兜底
当前规则冻结为:
1. 只有 `import.meta.env.DEV === true`
2.`VITE_AUTH_ALLOW_DEV_GUEST === "true"`
同时满足时,才允许自动创建/恢复游客账号。
这意味着:
1. 未配置时默认关闭
2. `.env.example` 必须体现默认关闭
3. 正式手机号验证码登录不会再被隐式游客入口抢走
## 5.2 公开请求与登录态隔离
`listCustomWorldGallery` / `getCustomWorldGalleryDetail` 这类公开只读请求继续固定:
1. `skipAuth: true`
2. `skipRefresh: true`
并补一条实现约束:
1. 若请求本身没有携带 `Authorization`,即使服务端返回 `401`,也不能清空本地 access token
否则会出现:
1. 已登录用户浏览公开首页
2. 某次公开接口异常返回 `401`
3. 前端误清空 token
4. 平台突然退回未登录态
这与公开浏览设计直接冲突。
## 5.3 smoke 最小覆盖
`scripts/smoke-server-node.ts` 必须至少验证:
1. `POST /api/auth/phone/send-code`
2. `POST /api/auth/phone/login`
3. `GET /api/auth/me`
4. 已登录后访问一个受保护运行时接口
5. `POST /api/auth/logout` 后旧 token 失效
说明:
1. smoke 配置固定使用 `smsAuth.provider = mock`
2. 使用 mock 验证码 `123456`
3. 这条 smoke 不依赖真实阿里云短信
## 6. 代码落点
本阶段最少覆盖:
1. `src/components/auth/AuthGate.tsx`
2. `src/components/auth/LoginScreen.tsx`
3. `src/services/apiClient.ts`
4. `scripts/smoke-server-node.ts`
5. `.env.example`
## 7. 验收标准
做到以下几点,本阶段视为完成:
1. 本地未显式开启游客开关时,平台不会自动进游客账号。
2. 未登录点击受保护动作后,出现手机号验证码登录弹窗。
3. 点击“获取验证码”后,弹窗出现发送成功提示与倒计时。
4. 公开广场请求不会因为 `401` 顺手把已登录态清空。
5. smoke 脚本已经包含手机号验证码登录主链。

View File

@@ -4,10 +4,15 @@
## 文档列表
- [PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md](./PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md):冻结手机号验证码登录第一阶段的真实落地边界,明确游客兜底默认关闭、公开请求不污染登录态,以及 smoke 必须覆盖短信登录主链。
- [AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md](./AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md)`/api/auth/login-options` 首版设计,冻结登录方式列表 contract、配置开关来源与返回顺序。
- [AUTH_ME_QUERY_DESIGN_2026-04-21.md](./AUTH_ME_QUERY_DESIGN_2026-04-21.md)`/api/auth/me` 首版查询设计,冻结 Bearer JWT 衔接、`user + availableLoginMethods` 返回 contract以及用户不存在时的 `401` 语义。
- [AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md](./AUTH_LOGOUT_ALL_DESIGN_2026-04-21.md)`/api/auth/logout-all` 全端登出设计,冻结全部 refresh session 吊销、`token_version` 递增、清 cookie 语义与 Rust 首版接口边界。
- [AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md](./AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md)`/api/auth/sessions` 会话列表设计,冻结当前设备识别、多端登录字段映射、`clientLabel` 兼容策略与 Rust 首版接口边界。
- [PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md):手机号验证码登录最小闭环设计,冻结 mock 验证码规则、`send-code` / `phone/login` contract 与 crate 边界。
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429``Retry-After` contract。
- [WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)Rust `api-server` 微信登录实现设计,冻结微信 provider 接入、系统 JWT 签发边界、`wechat/start` / `wechat/callback` / `wechat/bind-phone` 闭环,以及与后续 `SpacetimeDB` claims 透传的关系。
- [WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md](./WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md):微信登录从本地 mock 到真实微信开放平台联调的执行手册,覆盖环境变量、回调域名、代理头要求、验证步骤与常见失败排查。
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。
- [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md):多端登录会话身份模型设计,冻结浏览器、小程序、微信内 H5 的客户端身份字段、请求头约定与展示名派生规则。
- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。
@@ -22,7 +27,9 @@
- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
- [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSSWeb 端只负责签名读下载。
- [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。
- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。
- [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。
- [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。
- [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。
@@ -44,4 +51,4 @@
## 使用建议
- 做实现选型时,优先看这一组。
- 做阶段排期时,把这一组和 `docs/planning/``docs/prd/` 一起看,更容易判断先后顺序。
- 做阶段排期时,把这一组和 `docs/planning/``docs/prd/` 一起看,更容易判断先后顺序。

View File

@@ -0,0 +1,191 @@
# SpacetimeDB 资产对象存储设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结当前 `assets / OSS` 前置落地后的两个关键口径:
1. 阿里云 OSS 当前按**私有 bucket** 模式接入。
2. 后续 `SpacetimeDB` 的资产对象引用统一按 `bucket``object_key` **两列存储**,而不是拼成单个路径字符串。
这份文档直接服务于以下工程收口动作:
1. `platform-oss` 的输出 contract 收敛。
2. `api-server` 的直传票据接口收敛。
3. 后续 `asset_object``asset_manifest`、业务绑定表的字段设计收敛。
## 2. 当前已确认事实
### 2.1 Bucket 访问策略
当前阿里云 OSS bucket
- `bucket``xushi-dev`
- `endpoint``oss-cn-beijing.aliyuncs.com`
已确认:
1. bucket 当前为**私有读写**。
2. 服务端可通过 `AccessKeyId / AccessKeySecret` 正常访问 bucket。
3. 浏览器可通过服务端签发的 `PostObject` 票据完成上传。
4. 未签名的对象公开 URL 返回 `403`,不能当成正式读取链路。
### 2.2 当前工程能力状态
当前 `server-rs` 已落地:
1. `platform-oss`:浏览器 `PostObject` 直传签名
2. `api-server``POST /api/assets/direct-upload-tickets`
3. `platform-oss`:私有对象短期签名读取 URL
4. `api-server``GET /api/assets/read-url`
5. `server-rs/scripts/oss-smoke.ps1`:真实 OSS 联调脚本
6. `platform-oss`:私有 `HEAD Object` 探测
7. `api-server``POST /api/assets/objects/confirm`
8. `module-assets`:进程内 `asset_object` 确认服务
9. `platform-oss`:服务端 `PutObject` 上传 helper
10. `api-server``POST /api/assets/sts-upload-credentials` 禁用式 contract
当前仍未落地:
1. `STS` 真实临时授权下发
2. multipart 分片上传
3. 内容 hash 自动计算与版本字段细化
当前上传完成确认接口的详细请求、校验顺序与写入规则见:
1. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
服务端上传与 STS 禁用式 contract 的详细设计见:
1. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
## 3. 正式存储口径
### 3.1 SpacetimeDB 中的对象引用格式
后续 `SpacetimeDB` 中凡是需要引用 OSS 对象的地方,统一按以下两列存储:
1. `bucket`
2. `object_key`
不采用以下做法作为正式真相:
1. 不存完整公开 URL
2. 不存 `bucket/object_key` 拼接后的单字符串
3. 不存依赖 bucket 权限假设的匿名可读 URL
### 3.2 为什么必须拆成两列
原因固定如下:
1. bucket 后续可能按环境、业务线或冷热分层拆桶。
2. `object_key` 本身才是对象路径主键;`bucket` 是对象所属仓。
3. 后续签名下载 URL、服务端代理读取、对象迁移都需要分别拿到 `bucket``object_key`
4. 若只存拼接字符串,后续查询、迁移、批量回填和索引都更差。
## 4. 推荐表字段
### 4.1 `asset_object`
后续 `asset_object` 至少应包含:
1. `id`
2. `bucket`
3. `object_key`
4. `access_policy`
5. `content_type`
6. `content_length`
7. `content_hash`
8. `version`
9. `source_job_id`
10. `owner_user_id`
11. `profile_id`
12. `entity_id`
13. `asset_kind`
14. `created_at`
15. `updated_at`
当前已落到可编码级别的字段类型、索引和访问级别见:
1. [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
### 4.2 业务资产表
如:
1. `character_visual_asset`
2. `character_animation_asset`
3. `scene_image_asset`
4. `sprite_sheet_asset`
这些表不直接重复存 URL而是引用
1. `asset_object_id`
2. 或明确的 `bucket + object_key`
优先建议:
1. 强业务关系表引用 `asset_object_id`
2. `asset_object` 再统一承载 `bucket + object_key`
## 5. API 输出口径
### 5.1 直传票据接口
`POST /api/assets/direct-upload-tickets` 的正式输出应以以下字段为核心:
1. `bucket`
2. `objectKey`
3. `legacyPublicPath`
4. `access`
5. `formFields`
6. `expiresAt`
补充说明:
1. `publicUrl` 在私有 bucket 模式下不是正式读取真相。
2. 若当前为了调试保留 `publicUrl`,也只能视为“候选直连 URL”不能代表对象一定可匿名读取。
### 5.2 私有读取链路
后续正式读取私有对象时,应采用以下方案之一:
1. `api-server` 输出短期签名 URL
2. `api-server` 做下载代理
3. CDN 私有回源 + 服务端签名
当前仓库已实现第一种最小闭环:
1. 服务端落库保存 `bucket + object_key`
2. Web 端请求 `GET /api/assets/read-url`
3. `api-server` 返回短期 `signedUrl`
4. 浏览器再使用该 `signedUrl` 执行展示或下载
当前阶段不允许:
1. 把长期 `AccessKeyId / AccessKeySecret` 下发给客户端
2. 让浏览器直接持有长期主凭证读取私有对象
## 6. `platform-oss` 的实现约束
从当前版本开始,`platform-oss` 需要遵守以下约束:
1. 默认按私有对象思维设计,不假设对象可匿名读取。
2. `PostObject` 直传输出必须显式带 `bucket``object_key`
3. `publicUrl` 若存在,只能作为派生信息,不能替代 `bucket/object_key`
4. 后续新增签名下载能力时,输入必须是 `bucket + object_key`
## 7. `SpacetimeDB` 表设计约束
结合 `SpacetimeDB` 的 schema 演进约束,当前先冻结:
1. 资产对象表必须尽早固定 `bucket``object_key` 两列。
2. 不要先落单列字符串,后续再拆列,这会放大 schema 迁移成本。
3. 对象 URL、签名 URL、CDN URL 都属于派生读模型,不是主存储字段。
## 8. 一句话结论
当前 `assets / OSS` 的正式真相应当是:
**阿里云 OSS 用私有 bucket 持有二进制对象,`SpacetimeDB` 用 `bucket + object_key` 两列持有对象引用,任何 URL 都只能是运行时派生结果。**

View File

@@ -0,0 +1,175 @@
# `asset_object` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `asset_object` 从“概念字段列表”推进到**可以直接编码**的级别。
当前要冻结的不只是字段名,还包括:
1. 表职责边界
2. `bucket + object_key` 的正式主存储口径
3. 首版字段类型
4. 当前阶段必须先落下的索引
5.`api-server / platform-oss / 后续业务绑定表` 的边界
## 2. 当前已确认前提
目前与 `asset_object` 直接相关的事实已经确认如下:
1. 阿里云 OSS 当前按**私有 bucket** 模式接入。
2. 浏览器上传通过 `POST /api/assets/direct-upload-tickets` 下发的 `PostObject` 票据完成。
3. `platform-oss` 已输出:
- `bucket`
- `objectKey`
- `legacyPublicPath`
- `access`
4. 匿名公开 URL 当前返回 `403`,不能作为正式读取真相。
因此 `asset_object` 必须承担的真实职责是:
1. 记录对象在 OSS 中的唯一定位
2. 记录对象元数据
3. 为后续业务绑定表提供稳定引用
4. 不把 URL 当成正式主键
## 3. 表职责边界
### 3.1 `asset_object` 负责的内容
1. `asset_object_id`:对象主键
2. `bucket`:对象所属 bucket
3. `object_key`bucket 内对象路径
4. `access_policy`:对象访问策略
5. `content_type`
6. `content_length`
7. `content_hash`
8. `version`
9. 来源任务、归属用户、profile、业务实体等轻量关联键
10. `asset_kind`
11. `created_at / updated_at`
### 3.2 `asset_object` 不负责的内容
1. 不负责大对象二进制本体
2. 不负责生成任务完整编排状态
3. 不负责直接暴露公共 URL
4. 不负责角色、动作、场景、精灵表等强业务关系本身
### 3.3 与其他表的边界
1. `asset_job` 负责任务态,不替代对象真相。
2. `asset_manifest` 负责对象集合或发布清单,不替代单对象行。
3. `character_visual_asset / scene_image_asset / sprite_sheet_asset` 这类强业务关系表优先引用 `asset_object_id`
## 4. 表访问级别
`asset_object` 当前固定为 **private table**
原因:
1. bucket 当前是私有读写。
2. 对象读取需要服务端签名 URL 或下载代理。
3. 前端不应直接把 `asset_object` 当公开订阅表使用。
补充口径:
1. 当前阶段先不把 `asset_object` 做成 public。
2. 未来若要给客户端读对象列表,优先通过 view 或 Axum facade 输出脱敏 DTO。
## 5. 首版字段设计
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `asset_object_id` | `String` | 是 | 主键,固定使用 `assetobj_` 前缀的稳定字符串 ID。 |
| `bucket` | `String` | 是 | 正式对象仓名称。 |
| `object_key` | `String` | 是 | bucket 内对象路径,不带前导 `/`。 |
| `access_policy` | `AssetObjectAccessPolicy` | 是 | 当前先冻结为 `private` / `public_read`。 |
| `content_type` | `Option<String>` | 否 | 真实对象 MIME。 |
| `content_length` | `u64` | 是 | 已确认对象大小,单位字节。 |
| `content_hash` | `Option<String>` | 否 | 内容摘要,当前先保留为空间,不预设算法强制值。 |
| `version` | `u32` | 是 | 对象版本号,首版默认 `1`。 |
| `source_job_id` | `Option<String>` | 否 | 来源任务 ID。 |
| `owner_user_id` | `Option<String>` | 否 | 归属用户 ID。 |
| `profile_id` | `Option<String>` | 否 | 归属 profile ID。 |
| `entity_id` | `Option<String>` | 否 | 归属业务实体 ID。 |
| `asset_kind` | `String` | 是 | 资产业务类型,例如 `character_visual``scene_image`。 |
| `created_at` | `Timestamp` | 是 | 创建时间。 |
| `updated_at` | `Timestamp` | 是 | 最近更新时间。 |
补充约束:
1. `bucket``object_key` 是正式对象定位真相。
2. 允许存在 `asset_object_id`,但不允许回退成单列 `storage_path` 作为真相字段。
3. `content_hash` 先允许为空,避免在上传确认链路未落地前把 schema 卡死。
## 6. 索引与查询约束
### 6.1 当前阶段必须先落的索引
1. `bucket + object_key` 组合 B-Tree 索引
作用:按真实对象定位回查对象元数据
2. `asset_kind` 单列索引
作用:后续按业务类型聚合或清理对象
### 6.2 为什么先不加更多索引
当前阶段只先解决:
1. 正式对象定位
2. 业务类型过滤
而以下能力尚未落地:
1. `asset_job`
2. 上传完成确认 reducer
3. 业务绑定 reducer
因此暂不提前为每个可空关联键堆索引,避免在真实访问模式还没固定前把 schema 复杂度拉高。
## 7. 写入约束
### 7.1 当前阶段允许的对象写入时机
后续真正写 `asset_object` 时,必须满足以下前提之一:
1. 浏览器直传成功,服务端确认对象存在
2. 后台 worker 成功上传对象到 OSS
3. 旧对象迁移脚本确认对象存在并完成元数据回填
### 7.2 当前阶段不允许的漂移
1. 只根据 `legacyPublicPath` 就写入对象真相
2. 只存完整 URL不拆 `bucket/object_key`
3.`asset_object` 里重复塞角色、场景、动作等业务专属冗余字段
4. 先落 `bucket/object_key` 之外的单列字符串路径,再计划后续拆列
## 8. 与当前代码的对接关系
当前工程中的直接对接关系固定如下:
1. `platform-oss`
- 负责生成 `bucket + object_key` 对象定位
2. `api-server`
- 负责输出直传票据与私有读签名 URL
3. `module-assets`
- 负责沉淀 `AssetObjectAccessPolicy` 与字段校验 helper
4. `spacetime-module`
- 负责聚合 `asset_object` 首版表骨架
## 9. 当前阶段完成定义
当以下条件满足时,`asset_object` 的首版设计与骨架视为完成:
1. `bucket + object_key` 已在表结构中固定为两列
2. 表访问级别已固定为 private
3. 字段和索引已具体到可直接编码
4. `module-assets``spacetime-module` 已落真实 crate scaffold
## 10. 相关文档
1. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
3. [../../../server-rs/crates/module-assets/README.md](../../../server-rs/crates/module-assets/README.md)
4. [../../../server-rs/crates/spacetime-module/README.md](../../../server-rs/crates/spacetime-module/README.md)

View File

@@ -222,8 +222,9 @@
1. 先按 `provider_unionid` 查;没有再按 `provider_uid`
2. 若 identity 已存在,则更新资料快照并写 `last_login_at`
3. identity 不存在,则创建一条新的 `wechat` identity
4.是首次微信登录,还要同步创建 `pending_bind_phone``user_account`
3.本次是按 `provider_unionid` 命中,但 `provider_uid` 已变化,则必须把新的 `provider_uid` 一并回写为最新主映射
4. identity 不存在,则创建一条新的 `wechat` identity
5. 若是首次微信登录,还要同步创建 `pending_bind_phone``user_account`
### 9.4 `POST /api/auth/wechat/bind-phone`

View File

@@ -626,7 +626,8 @@ workflow-cache/{workflow_type}/{workflow_id}.json
- `/generated-custom-world-scenes/*`
- `/generated-custom-world-covers/*`
- `/generated-qwen-sprites/*`
9. `STS`服务上传 helper、对象确认与业务绑定仍在后续阶段补齐
9. 当前 `POST /api/assets/sts-upload-credentials` 已按“服务上传、Web 只下载”的需求固定为禁用式 contract不向浏览器下发 OSS 写权限
10. 当前 `platform-oss` 已提供服务端 `PutObject` 上传 helper供后续 AI worker 上传生成资源后继续走对象确认链路。
## 11.3 元数据与标签

View File

@@ -0,0 +1,162 @@
# Axum 微信登录接入设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 Rust `api-server` 当前阶段的微信登录落地边界固定到可编码级别,避免在接入 `SpacetimeDB` 前把“第三方 provider 身份”和“系统内登录态 JWT”混成一层。
本次设计固定以下结论:
1. 微信不能作为 `SpacetimeDB` 直连时直接消费的 OIDC provider。
2. 微信返回的 `code -> access_token/openid/unionid` 只能先在 `Axum` 侧完成兑换。
3. `Axum` 在拿到微信身份后,签发本系统自己的 **OIDC 兼容 JWT**
4. 前端、Axum、未来 SpacetimeDB 都统一消费这枚系统 JWT而不是直接消费微信 token。
## 2. 核心边界
### 2.1 为什么不能直接把微信 token 给 SpacetimeDB
原因固定为:
1. 当前微信开放平台登录链路不是标准 OIDC `id_token` 语义。
2. 常见返回物是 `code``access_token``openid``unionid`,不是可直接按 OIDC `iss/sub/aud` 校验的标准 JWT。
3. `SpacetimeDB` 当前直接接受的是 OIDC 兼容 JWT而不是微信的 provider 原始返回。
因此正式口径固定为:
- 微信只负责提供三方身份。
- 系统内部登录态统一由 `Axum` 签发。
- 未来接入 `SpacetimeDB` 时,传给 `.withToken(...)` 的仍然是本系统 JWT。
### 2.2 与 SpacetimeDB 的关系
本阶段虽然前端仍然不直连 `SpacetimeDB`,但 JWT 语义必须从现在就和后续保持一致:
1. `iss` 固定为本系统网关发行者。
2. `sub` 固定为系统内稳定用户 ID不允许使用手机号、`openid``unionid`
3. `provider` 表示**当前会话登录来源**,允许为 `password``phone``wechat`
4. `binding_status` 表示当前账号是否仍处于 `pending_bind_phone`
## 3. 登录主链
### 3.1 `GET /api/auth/wechat/start`
职责固定为:
1. 归一化 `redirectPath`
2.`User-Agent` 判断授权场景:
- `desktop`
- `wechat_in_app`
3. 创建一次性 `state`
4. 返回微信授权地址
关键约束:
1. 普通手机浏览器且非微信内打开时,不创建 state直接报错。
2. 每次点击微信登录都创建新 state不复用旧 state。
### 3.2 `GET /api/auth/wechat/callback`
职责固定为:
1. 先消费一次性 `state`
2. 再用 `code` 或 mock code 换取微信身份
3. 先按 `unionid`,再按 `openid` 查系统内是否已有绑定账号
4. 若没有账号,则创建 `pending_bind_phone` 的微信壳账号
5. 若本次是按 `unionid` 命中,但微信回调带回了新的 `openid`,必须把新的 `openid -> user_id` 映射一并回写
6. 签发系统 access token
7. 创建 refresh session
8. 以 hash 片段回跳前端:
- `auth_provider=wechat`
- `auth_token=...`
- `auth_binding_status=active|pending_bind_phone`
### 3.3 `POST /api/auth/wechat/bind-phone`
职责固定为:
1. 仅允许当前 Bearer 用户为 `pending_bind_phone`
2. 校验手机号验证码
3. 若手机号已对应正式账号:
- 把微信身份归并到该正式账号
- 删除当前微信壳账号
4. 若手机号尚未绑定:
- 激活当前微信账号
- 写入手机号与掩码
5. 再次签发 access token 与 refresh session
补充约束:
1. 若绑定的是当前待绑定微信账号本体,则返回用户快照的 `loginMethod = wechat`
2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`
3. 但 access token 中的 `provider` 仍按**本次登录来源**签发,不依赖账号主登录方式推断,因此微信绑定后的当前会话仍会签发 `provider = wechat`
## 4. 当前最小实现策略
当前阶段为了先打通 Rust 后端闭环,采用以下最小实现:
1. `module-auth` 内使用进程内存仓模拟:
- 用户
- refresh session
- 微信 identity
- 微信 state
- 手机验证码
2. 短信验证码先走 mock
- 固定验证码 `123456`
- TTL `5` 分钟
- 冷却 `60`
3. 微信 provider 同时支持:
- `mock`
- `real`
这意味着:
1. 本轮目的是先把 Rust 认证主链补齐。
2. 后续切到真实 `SpacetimeDB private table` 时,保留接口 contract不改前端页面。
## 5. 当前接口影响面
本次接入会补齐这些 Rust 接口:
1. `GET /api/auth/login-options`
2. `POST /api/auth/phone/send-code`
3. `POST /api/auth/phone/login`
4. `GET /api/auth/wechat/start`
5. `GET /api/auth/wechat/callback`
6. `POST /api/auth/wechat/bind-phone`
## 6. 环境变量
当前 Rust `api-server` 需要新增或明确这些变量:
1. `WECHAT_AUTH_ENABLED`
2. `WECHAT_AUTH_PROVIDER`
3. `WECHAT_APP_ID`
4. `WECHAT_APP_SECRET`
5. `WECHAT_CALLBACK_PATH`
6. `WECHAT_REDIRECT_PATH`
7. `WECHAT_AUTHORIZE_ENDPOINT`
8. `WECHAT_ACCESS_TOKEN_ENDPOINT`
9. `WECHAT_USER_INFO_ENDPOINT`
10. `WECHAT_STATE_TTL_MINUTES`
11. `WECHAT_MOCK_USER_ID`
12. `WECHAT_MOCK_UNION_ID`
13. `WECHAT_MOCK_DISPLAY_NAME`
14. `WECHAT_MOCK_AVATAR_URL`
## 7. 与后续 SpacetimeDB 的衔接要求
未来把认证真相从内存仓切到 `SpacetimeDB` 时,必须继续保持:
1. `wechat_auth_state` 只做一次性 OAuth 状态,不存 provider token。
2. `auth_identity` 负责 `openid/unionid -> user_id` 绑定。
3. `refresh_session` 负责设备会话。
4. `Axum` 负责签 JWT。
5. `SpacetimeDB module` 只信系统 JWT claims不直接解析微信回调结果。
## 8. 一句话结论
微信登录在本项目中的正确接法不是“把微信 token 直接接到 `SpacetimeDB`”,而是:
**微信只提供三方身份Axum 负责完成 provider 兑换并签发系统 OIDC 兼容 JWT再把这枚 JWT 用作前端与未来 SpacetimeDB 的统一登录态。**

View File

@@ -0,0 +1,317 @@
# 微信登录真实联调手册
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把当前仓库里的微信登录,从“代码已具备 mock / real 双模式”推进到“开发、测试、部署都能按步骤联调”的级别。
文档目标不是重复实现设计,而是回答以下落地问题:
1. 本地先怎么用 mock 跑通
2. 什么时候切 `real`
3. 真实微信开放平台需要配什么
4. 回调地址到底怎么拼
5. 前后端各自怎么验证
6. 失败时先查哪一层
## 2. 当前实现结论
当前仓库里的微信登录实现固定为两段式:
1. 微信 OAuth 只负责返回第三方身份:`code -> openid / unionid`
2. Rust `api-server` 负责把该第三方身份换成本系统 JWT
因此当前正式口径固定为:
1. 前端不直接消费微信 token
2. `SpacetimeDB` 未来也不直接消费微信 token
3. 统一由 `api-server` 签发系统 JWT再回给前端和后续服务层
## 3. 模式说明
### 3.1 mock 模式
适用场景:
1. 本地前后端先验证交互链路
2. 还没有微信开放平台配置
3. 还没有可用公网回调域名
当前配置:
```env
WECHAT_AUTH_ENABLED="true"
WECHAT_AUTH_PROVIDER="mock"
WECHAT_CALLBACK_PATH="/api/auth/wechat/callback"
WECHAT_REDIRECT_PATH="/"
WECHAT_MOCK_USER_ID="wx-mock-user"
WECHAT_MOCK_UNION_ID="wx-mock-union"
WECHAT_MOCK_DISPLAY_NAME="微信旅人"
WECHAT_MOCK_AVATAR_URL=""
```
mock 模式行为固定为:
1. `GET /api/auth/wechat/start` 仍会创建一次性 `state`
2. 返回的 `authorizationUrl` 不会跳去微信,而是直接指向本地 callback
3. callback 会走 mock 身份:`mock_code + state`
4. 后端仍会完整创建微信登录态、refresh session 与待绑定账号
### 3.2 real 模式
适用场景:
1. 已拿到微信开放平台应用
2. 已有可访问的公网域名
3. 已在微信开放平台后台配置回调域名
当前配置:
```env
WECHAT_AUTH_ENABLED="true"
WECHAT_AUTH_PROVIDER="real"
WECHAT_APP_ID="你的微信开放平台 AppID"
WECHAT_APP_SECRET="你的微信开放平台 AppSecret"
WECHAT_CALLBACK_PATH="/api/auth/wechat/callback"
WECHAT_REDIRECT_PATH="/"
```
real 模式行为固定为:
1. `GET /api/auth/wechat/start` 返回微信真实授权地址
2. 用户在微信侧完成授权
3. 微信回跳到 `WECHAT_CALLBACK_PATH`
4. 后端用 `code``access_token/openid/unionid`
5. 后端签发本系统 JWT并把结果通过 URL hash 回跳给前端
## 4. 环境变量清单
当前真实联调至少需要这些变量:
| 变量 | 必填 | 说明 |
| --- | --- | --- |
| `WECHAT_AUTH_ENABLED` | 是 | 是否启用微信登录 |
| `WECHAT_AUTH_PROVIDER` | 是 | `mock``real` |
| `WECHAT_APP_ID` | `real` 模式必填 | 微信开放平台应用 ID |
| `WECHAT_APP_SECRET` | `real` 模式必填 | 微信开放平台应用 Secret |
| `WECHAT_CALLBACK_PATH` | 是 | 回调路径,默认 `/api/auth/wechat/callback` |
| `WECHAT_REDIRECT_PATH` | 是 | 登录完成后前端默认落点 |
| `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 |
| `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 |
| `WECHAT_USER_INFO_ENDPOINT` | 否 | 默认用户信息接口 |
| `WECHAT_STATE_TTL_MINUTES` | 否 | state 有效期,默认 `15` 分钟 |
补充说明:
1. `WECHAT_CALLBACK_PATH` 只是路径,不是完整 URL。
2. 当前完整回调地址由后端在运行时按请求头拼接:
- 优先 `x-forwarded-proto`
- 其次 `host` / `x-forwarded-host`
3. 因此部署到反向代理后,代理层必须正确透传 `host``x-forwarded-proto`
## 5. 微信开放平台后台配置
真实联调前,需要先在微信开放平台完成以下配置:
1. 创建网站应用并拿到 `AppID / AppSecret`
2. 配置网站授权回调域名
3. 确保回调域名与实际访问域名一致
当前项目必须特别注意:
1. 微信后台通常配置的是“域名”,不是完整路径
2. 但项目真正收到回调时会落到:
```text
https://你的域名/api/auth/wechat/callback
```
3. 如果代理层把 HTTPS 终止在上游网关,必须把 `x-forwarded-proto=https` 正确传给 `api-server`
4. 否则后端可能生成 `http://.../api/auth/wechat/callback`,导致微信侧回调地址不匹配
## 6. 本地 mock 联调步骤
### 6.1 配置
`.env.local` 中写入:
```env
SMS_AUTH_ENABLED="true"
WECHAT_AUTH_ENABLED="true"
WECHAT_AUTH_PROVIDER="mock"
VITE_AUTH_ALLOW_DEV_GUEST="false"
```
### 6.2 启动
前端和后端分别启动当前项目自己的开发链路。
若只验证 Rust 后端,可直接启动 `server-rs``api-server`
### 6.3 验证顺序
1. 请求 `GET /api/auth/login-options`
2. 期望返回同时包含:
- `phone`
- `wechat`
3. 前端点击“微信登录”
4. 浏览器应先跳到 `/api/auth/wechat/start`
5. 随后直接命中本地 `/api/auth/wechat/callback?...`
6. 前端收到 hash
- `auth_provider=wechat`
- `auth_token=...`
- `auth_binding_status=pending_bind_phone`
7. 页面进入“绑定手机号”界面
8. 发送验证码并绑定手机号
9. 若手机号未使用,当前账号激活
10. 若手机号已对应正式账号,微信身份并入已有正式账号
## 7. 真实微信联调步骤
### 7.1 切换配置
`.env.local` 中切到:
```env
SMS_AUTH_ENABLED="true"
WECHAT_AUTH_ENABLED="true"
WECHAT_AUTH_PROVIDER="real"
WECHAT_APP_ID="你的 AppID"
WECHAT_APP_SECRET="你的 AppSecret"
WECHAT_CALLBACK_PATH="/api/auth/wechat/callback"
WECHAT_REDIRECT_PATH="/"
VITE_AUTH_ALLOW_DEV_GUEST="false"
```
### 7.2 部署要求
需要有一个用户浏览器能够访问的地址,例如:
```text
https://game.example.com
```
并且该地址最终把认证请求转发到当前 Rust `api-server`
### 7.3 代理层要求
反向代理必须至少透传:
1. `Host`
2. `X-Forwarded-Proto`
3. `X-Forwarded-Host`(如果你的代理链路会改写 Host
### 7.4 验证顺序
1. 浏览器访问前端页面
2. 点击“微信登录”
3. 观察 `/api/auth/wechat/start` 返回的 `authorizationUrl`
4. 确认其中 `redirect_uri` 指向真实回调地址
5. 在微信授权完成后,确认请求回到:
```text
/api/auth/wechat/callback?code=...&state=...
```
6. 观察回跳前端地址是否带上:
- `auth_provider=wechat`
- `auth_token=...`
- `auth_binding_status=active|pending_bind_phone`
7. 如果进入待绑定页面,继续完成手机号绑定
8. 绑定后再请求 `GET /api/auth/me`
9. 确认:
- 当前用户存在
- `wechatBound = true`
- `bindingStatus` 已更新为目标状态
## 8. 账号命中规则
当前实现固定按以下顺序命中已有账号:
1. 先按 `unionid`
2. 再按 `openid`
3. 都没有命中时,创建 `pending_bind_phone` 微信壳账号
补充规则:
1. 若按 `unionid` 命中了已有微信身份,但本次微信回调带来了新的 `openid`,后端会把新的 `openid -> user_id` 映射补齐
2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号
## 9. 前端验收点
前端联调时至少检查以下行为:
1. 登录弹窗只在 `login-options` 返回 `wechat` 时显示“微信登录”按钮
2. 点击微信登录后应跳去后端返回的 `authorizationUrl`
3. 回调 hash 被前端消费后,应把 `auth_token` 存入本地登录态
4.`auth_binding_status=pending_bind_phone`,页面必须进入绑定手机号界面
5. 绑定成功后,应切回正常已登录状态
## 10. 后端验收点
当前后端至少应满足以下检查:
1. `GET /api/auth/wechat/start` 能返回授权地址
2. `GET /api/auth/wechat/callback` 能创建系统会话并回跳
3. `POST /api/auth/wechat/bind-phone` 能完成补绑
4. `GET /api/auth/me` 能反映最新 `bindingStatus / wechatBound`
5. access token claims 中的 `provider` 应保持当前会话来源为 `wechat`
## 11. 常见失败点
### 11.1 点微信登录后直接报“微信登录暂未启用”
先检查:
1. `WECHAT_AUTH_ENABLED` 是否为 `true`
2. 运行的是否真是 Rust `api-server`
3. 前端是否还在打旧 Node 服务
### 11.2 微信授权页能打开,但回调报错
先检查:
1. 微信后台配置的回调域名是否正确
2. 代理层是否正确透传 `host``x-forwarded-proto`
3. `WECHAT_CALLBACK_PATH` 是否与当前代码路径一致
### 11.3 按 `unionid` 命中后又出现新壳账号
先检查:
1. 微信开放平台当前应用是否真的能返回稳定 `unionid`
2. 当前账号历史上是否只有 `openid` 没有 `unionid`
3. 是否发生了不同开放平台主体之间的数据割裂
### 11.4 绑定手机号后命中了已有正式账号,但前端看到 `loginMethod=phone`
这是当前实现允许的结果。
原因是:
1. 返回的是目标正式账号快照
2. 目标正式账号本身的主登录方式仍可能是 `phone`
3. 但当前会话签发的 access token `provider` 仍然是 `wechat`
## 12. 当前自动化验证证据
当前仓库里已经有这些自动化验证:
1. `cargo test -p api-server`
覆盖:
- `wechat/start`
- `wechat/callback`
- `wechat/bind-phone`
2. `cargo test -p module-auth`
覆盖:
- `unionid` 优先命中已有微信用户
- 微信待绑定账号并入已有手机号正式账号
3. `npm test -- --run src/services/authService.test.ts`
覆盖:
- 前端微信登录起跳
- callback hash 消费
## 13. 一句话切换原则
本地先用 `mock` 跑通页面和会话闭环,公网域名与微信后台配置就绪后,再切 `real` 做真实 OAuth 联调。