diff --git a/.env.example b/.env.example index 1d858d81..ec4cadc0 100644 --- a/.env.example +++ b/.env.example @@ -76,16 +76,28 @@ SMS_AUTH_BLOCK_IP_DURATION_MINUTES="30" # 仅开发环境:允许本地开发测试自动走游客账号。 # 一旦你已经启用手机号/微信登录,建议改成 `false`,这样会直接进入真实登录界面。 -VITE_AUTH_ALLOW_DEV_GUEST="true" +VITE_AUTH_ALLOW_DEV_GUEST="false" # 微信登录配置。 -# 当前实现已支持微信登录骨架与 mock 联调;正式联调需补齐开放平台 AppID / AppSecret。 +# 当前实现已支持: +# 1. `WECHAT_AUTH_PROVIDER="mock"` 的本地假回调联调 +# 2. `WECHAT_AUTH_PROVIDER="real"` 的真实微信 OAuth 回调 +# 正式联调时除了补齐 AppID / AppSecret,还要确保微信开放平台回调域名与 +# `WECHAT_CALLBACK_PATH` 拼出的完整地址一致。 WECHAT_AUTH_ENABLED="false" -WECHAT_AUTH_PROVIDER="wechat" +WECHAT_AUTH_PROVIDER="mock" WECHAT_APP_ID="" WECHAT_APP_SECRET="" WECHAT_CALLBACK_PATH="/api/auth/wechat/callback" WECHAT_REDIRECT_PATH="/" +WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect" +WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token" +WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo" +WECHAT_STATE_TTL_MINUTES="15" +WECHAT_MOCK_USER_ID="wx-mock-user" +WECHAT_MOCK_UNION_ID="wx-mock-union" +WECHAT_MOCK_DISPLAY_NAME="微信旅人" +WECHAT_MOCK_AVATAR_URL="" # Model name for chat completions. VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715" @@ -97,12 +109,12 @@ DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" # 阿里云 OSS 配置。 # Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量, # 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。 +# 当前正式口径按私有 bucket 处理,后续在 SpacetimeDB 中存 `bucket + object_key` 两列。 ALIYUN_OSS_BUCKET="" ALIYUN_OSS_ENDPOINT="oss-cn-shanghai.aliyuncs.com" ALIYUN_OSS_ACCESS_KEY_ID="" ALIYUN_OSS_ACCESS_KEY_SECRET="" -# 可选:如已接入 CDN,可填 CDN 域名;未填写时将回退为 bucket 直连域名。 -ALIYUN_OSS_PUBLIC_BASE_URL="" +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" diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index c33b796c..94bd29b5 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -181,28 +181,34 @@ ### 手机验证码登录 - [ ] 接入阿里云短信发送 adapter -- [ ] 实现发送验证码接口 -- [ ] 实现验证码校验接口 -- [ ] 实现手机号绑定 +- [x] 实现发送验证码接口 + 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现验证码校验接口 + 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现手机号绑定 + 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs) - [ ] 实现手机号换绑 -- [ ] 实现发送频率限制 -- [ ] 实现验证码失败次数限制 +- [x] 实现发送频率限制 + 交付物:[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现验证码失败次数限制 + 交付物:[../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [ ] 实现 captcha 触发逻辑 - [ ] 实现风控封禁与解除 ### 微信登录 -当前执行策略: - -1. 微信登录链路自 `2026-04-21` 起暂缓执行,不进入当前连续落地顺序。 -2. 相关设计文档继续保留,后续如恢复执行再单独解锁。 - -- [ ] 接入微信 OAuth adapter -- [ ] 实现 `wechat/start` -- [ ] 实现 `wechat/callback` -- [ ] 实现微信身份绑定 -- [ ] 实现微信账号补绑手机号 -- [ ] 实现桌面端 / 微信内打开场景区分 +- [x] 接入微信 OAuth adapter + 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_provider.rs](../server-rs/crates/api-server/src/wechat_provider.rs)、[../server-rs/crates/api-server/src/state.rs](../server-rs/crates/api-server/src/state.rs) +- [x] 实现 `wechat/start` + 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现 `wechat/callback` + 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现微信身份绑定 + 交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) +- [x] 实现微信账号补绑手机号 + 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) +- [x] 实现桌面端 / 微信内打开场景区分 + 交付物:[../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](../docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/session_client.rs](../server-rs/crates/api-server/src/session_client.rs) ### OIDC 与 SpacetimeDB 身份透传 @@ -235,12 +241,17 @@ - [ ] 兼容 `/api/auth/audit-logs` - [ ] 兼容 `/api/auth/risk-blocks` - [ ] 兼容 `/api/auth/risk-blocks/:scopeType/lift` -- [ ] 兼容 `/api/auth/phone/send-code` -- [ ] 兼容 `/api/auth/phone/login` +- [x] 兼容 `/api/auth/phone/send-code` + 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) +- [x] 兼容 `/api/auth/phone/login` + 交付物:[../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md](../docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md)、[../server-rs/crates/api-server/src/phone_auth.rs](../server-rs/crates/api-server/src/phone_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs) - [ ] 兼容 `/api/auth/phone/change` -- [ ] 兼容 `/api/auth/wechat/start` -- [ ] 兼容 `/api/auth/wechat/callback` -- [ ] 兼容 `/api/auth/wechat/bind-phone` +- [x] 兼容 `/api/auth/wechat/start` + 交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts) +- [x] 兼容 `/api/auth/wechat/callback` + 交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts) +- [x] 兼容 `/api/auth/wechat/bind-phone` + 交付物:[../server-rs/crates/api-server/src/wechat_auth.rs](../server-rs/crates/api-server/src/wechat_auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../src/services/authService.ts](../src/services/authService.ts) ### 阶段验收 @@ -248,7 +259,8 @@ 证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。 - [x] refresh cookie 主链可用 证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 refresh 成功轮换、旧 token 失效、缺少 cookie `401` 与失败时清理 cookie。 -- [ ] 手机验证码主链可用 -- [ ] 微信登录主链可用 - 说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。 -- [ ] 所有旧鉴权接口可通过 contract 回归 \ No newline at end of file +- [x] 手机验证码主链可用 + 证据:`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server phone --manifest-path server-rs/Cargo.toml -- --nocapture` 已通过,覆盖发送验证码、同场景冷却 `429`、验证码错误次数耗尽 `429`、重新发送后恢复登录,以及手机号登录建号/复用与 refresh cookie 写回。 +- [x] 微信登录主链可用 + 证据:`cargo test -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth --manifest-path server-rs/Cargo.toml` 已通过,覆盖 `wechat/start`、`wechat/callback`、待绑定会话签发、手机号补绑并入已有账号,以及 `unionid` 命中后新 `openid` 映射回写。 +- [ ] 所有旧鉴权接口可通过 contract 回归 diff --git a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md index c714278e..85ee2698 100644 --- a/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md +++ b/backend-rewrite-tasklist/05_M6_ASSETS_OSS_EDITOR.md @@ -11,17 +11,17 @@ - [x] 设计对象键前缀 - [x] 设计 `object_key -> cdn_url` 解析策略 - [x] 设计 public / private 对象访问策略 -- [ ] 设计签名 URL 输出策略 +- [x] 设计签名 URL 输出策略 - [x] 设计 `x-oss-meta-*` 元数据规范 - [ ] 设计内容 hash / 版本字段规范 ## 2. 上传与对象确认 - [x] 实现浏览器 `PostObject` 直传签名接口 -- [ ] 实现 STS 临时授权接口 -- [ ] 实现服务端上传 helper -- [ ] 实现上传完成后的对象确认接口 -- [ ] 实现对象绑定业务实体 reducer +- [x] 实现 STS 临时授权接口 +- [x] 实现服务端上传 helper +- [x] 实现上传完成后的对象确认接口 +- [x] 实现对象绑定业务实体 reducer 补充说明: @@ -29,20 +29,41 @@ 2. 当前已在 `server-rs/crates/platform-oss` 与 `server-rs/crates/api-server` 落下最小可用链路: - `PostObject` 直传签名能力 - `/api/assets/direct-upload-tickets` + - `/api/assets/objects/confirm` - 兼容旧 `/generated-*` 前缀的对象键规划 - `.env/.env.local` 的 OSS 环境变量加载 -3. 当前仍未进入 `STS`、服务端上传 helper、对象确认与 `SpacetimeDB` 绑定阶段。 + - 服务端 `HEAD Object` 校验 + - `asset_object` 确认真实 SpacetimeDB 持久化 + - `/api/assets/objects/bind` + - `asset_entity_binding` 业务实体槽位绑定 + - `/api/assets/sts-upload-credentials` 禁用式 contract + - 服务端 `PutObject` 上传 helper +3. 当前 bucket 已明确为私有读写;后续正式存储口径改为 `bucket + object_key` 双列,不再把匿名公开 URL 当成真相。 +4. 当前 STS 接口按“服务器上传、Web 只下载”的需求固定为 `403` 禁用式 contract,不向浏览器下发 OSS 写权限。 +5. `2026-04-21` 已通过 live test 验证:真实 OSS 上传后,`/api/assets/objects/confirm` 能把 `xushi-dev + object_key` 写入本地 `genarrative-dev.asset_object`,并可继续通过 `/api/assets/objects/bind` 绑定到业务实体槽位。 ## 3. 资产任务系统 - [ ] 设计 `asset_job` -- [ ] 设计 `asset_object` +- [x] 设计 `asset_object` - [ ] 设计 `asset_manifest` - [ ] 设计 `character_visual_asset` - [ ] 设计 `character_animation_asset` - [ ] 设计 `scene_image_asset` - [ ] 设计 `sprite_sheet_asset` +补充说明: + +1. `asset_object` 当前已冻结核心存储口径为: + - `bucket` + - `object_key` +2. 详细设计见: + - [../docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md) + - [../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) + - [../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md) + - [../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md) +3. 当前已在 `server-rs/crates/spacetime-module` 落下 `asset_object` 首版表骨架,并完成 `api-server -> SpacetimeDB` 的最小对象确认闭环。 + ## 4. 资产生成链路 - [ ] 迁移角色主形象生成 @@ -82,6 +103,8 @@ - [ ] 兼容 `/api/assets/qwen-sprite/save` ## 7. 阶段验收 +- [x] OSS 直传对象可被服务端确认并写入 `asset_object` - [ ] 所有新生成资产都写入 OSS - [ ] 前端仍能通过旧路径习惯访问资源 - [ ] 资产任务状态可查询 +- [x] 已确认对象可绑定到业务实体槽位 diff --git a/docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md b/docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md new file mode 100644 index 00000000..be6c93ae --- /dev/null +++ b/docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md @@ -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` | 否 | 归属用户,当前仅作为服务端传入的记录字段。 | +| `profile_id` | `Option` | 否 | 归属 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 对象绑定到业务实体槽位,强业务资产表等字段稳定后再继续拆分。 diff --git a/docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md b/docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md new file mode 100644 index 00000000..eadb95ca --- /dev/null +++ b/docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md @@ -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` 优先取 OSS,OSS 未返回时再回退请求体 +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) diff --git a/docs/technical/AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md b/docs/technical/AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md new file mode 100644 index 00000000..7630ea32 --- /dev/null +++ b/docs/technical/AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md @@ -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` 并把最终对象记录同步返回。** diff --git a/docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md b/docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md new file mode 100644 index 00000000..6db24370 --- /dev/null +++ b/docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md @@ -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` | 是 | 对象键中间路径片段 | +| `file_name` | `String` | 是 | 文件名,内部会做安全归一化 | +| `content_type` | `Option` | 否 | MIME,写入 `Content-Type` | +| `access` | `OssObjectAccess` | 是 | 当前仅作为 contract 字段,不默认改变 bucket ACL | +| `metadata` | `BTreeMap` | 是 | 自动归一化为 `x-oss-meta-*` | +| `body` | `Vec` | 是 | 要写入 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) diff --git a/docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md b/docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md new file mode 100644 index 00000000..4299cbb6 --- /dev/null +++ b/docs/technical/PHONE_AUTH_AXUM_MINIMAL_FLOW_DESIGN_2026-04-21.md @@ -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. 文档、任务清单与测试已同步更新 diff --git a/docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md b/docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..f687f932 --- /dev/null +++ b/docs/technical/PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md @@ -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 定向测试通过。 diff --git a/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md b/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md new file mode 100644 index 00000000..5cb440db --- /dev/null +++ b/docs/technical/PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md @@ -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 脚本已经包含手机号验证码登录主链。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 9a12d56d..c401f4e2 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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 落地口径,明确当前上传主链为服务器上传 OSS,Web 端只负责签名读下载。 - [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/` 一起看,更容易判断先后顺序。 \ No newline at end of file +- 做阶段排期时,把这一组和 `docs/planning/`、`docs/prd/` 一起看,更容易判断先后顺序。 diff --git a/docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..17defcb1 --- /dev/null +++ b/docs/technical/SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md @@ -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 都只能是运行时派生结果。** diff --git a/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md new file mode 100644 index 00000000..bef49b4c --- /dev/null +++ b/docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md @@ -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` | 否 | 真实对象 MIME。 | +| `content_length` | `u64` | 是 | 已确认对象大小,单位字节。 | +| `content_hash` | `Option` | 否 | 内容摘要,当前先保留为空间,不预设算法强制值。 | +| `version` | `u32` | 是 | 对象版本号,首版默认 `1`。 | +| `source_job_id` | `Option` | 否 | 来源任务 ID。 | +| `owner_user_id` | `Option` | 否 | 归属用户 ID。 | +| `profile_id` | `Option` | 否 | 归属 profile ID。 | +| `entity_id` | `Option` | 否 | 归属业务实体 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) diff --git a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md index e2afa884..e20f334f 100644 --- a/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md +++ b/docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md @@ -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` diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index e9c7a3a3..c4b76f0e 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -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 元数据与标签 diff --git a/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md b/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md new file mode 100644 index 00000000..36fbee52 --- /dev/null +++ b/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md @@ -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 的统一登录态。** diff --git a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md new file mode 100644 index 00000000..053b67e5 --- /dev/null +++ b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md @@ -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 联调。 diff --git a/scripts/smoke-server-node.ts b/scripts/smoke-server-node.ts index 736f3ddb..a870157e 100644 --- a/scripts/smoke-server-node.ts +++ b/scripts/smoke-server-node.ts @@ -162,6 +162,62 @@ async function authEntry(baseUrl: string) { return payload; } +async function sendPhoneCode(baseUrl: string, phone: string) { + const response = await httpRequest(`${baseUrl}/api/auth/phone/send-code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + phone, + scene: 'login', + }), + }); + const payload = (await response.json()) as { + ok: boolean; + cooldownSeconds: number; + expiresInSeconds: number; + }; + + assert.equal(response.status, 200); + assert.equal(payload.ok, true); + assert.equal(payload.cooldownSeconds, 60); + assert.equal(payload.expiresInSeconds, 300); +} + +async function phoneAuthEntry(baseUrl: string) { + const phone = '13800138000'; + + await sendPhoneCode(baseUrl, phone); + + const response = await httpRequest(`${baseUrl}/api/auth/phone/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + phone, + code: '123456', + }), + }); + const payload = (await response.json()) as { + token: string; + user: { + id: string; + username: string; + loginMethod: string; + phoneNumberMasked: string | null; + }; + }; + + assert.equal(response.status, 200); + assert.ok(payload.token); + assert.equal(payload.user.loginMethod, 'phone'); + assert.equal(payload.user.phoneNumberMasked, '138****8000'); + + return payload; +} + async function main() { console.log('[server-node:smoke] booting ephemeral Express server'); @@ -184,27 +240,32 @@ async function main() { console.log('[server-node:smoke] healthz ok'); const entry = await authEntry(baseUrl); - console.log('[server-node:smoke] auth entry ok'); + console.log('[server-node:smoke] password auth entry ok'); + + const phoneEntry = await phoneAuthEntry(baseUrl); + console.log('[server-node:smoke] phone auth entry ok'); const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { - Authorization: `Bearer ${entry.token}`, + Authorization: `Bearer ${phoneEntry.token}`, }, }); const mePayload = (await meResponse.json()) as { user: { id: string; username: string; + loginMethod: string; }; }; assert.equal(meResponse.status, 200); - assert.equal(mePayload.user.username, entry.user.username); + assert.equal(mePayload.user.username, phoneEntry.user.username); + assert.equal(mePayload.user.loginMethod, 'phone'); console.log('[server-node:smoke] auth me ok'); const putSnapshotResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, - withBearer(entry.token, { + withBearer(phoneEntry.token, { method: 'PUT', body: JSON.stringify({ gameState: { @@ -235,7 +296,7 @@ async function main() { `${baseUrl}/api/runtime/save/snapshot`, { headers: { - Authorization: `Bearer ${entry.token}`, + Authorization: `Bearer ${phoneEntry.token}`, }, }, ); @@ -253,36 +314,41 @@ async function main() { const putSettingsResponse = await httpRequest( `${baseUrl}/api/runtime/settings`, - withBearer(entry.token, { + withBearer(phoneEntry.token, { method: 'PUT', body: JSON.stringify({ musicVolume: 0.3, + platformTheme: 'light', }), }), ); const putSettingsPayload = (await putSettingsResponse.json()) as { musicVolume: number; + platformTheme: string; }; assert.equal(putSettingsResponse.status, 200); assert.equal(putSettingsPayload.musicVolume, 0.3); + assert.equal(putSettingsPayload.platformTheme, 'light'); const getSettingsResponse = await httpRequest(`${baseUrl}/api/runtime/settings`, { headers: { - Authorization: `Bearer ${entry.token}`, + Authorization: `Bearer ${phoneEntry.token}`, }, }); const getSettingsPayload = (await getSettingsResponse.json()) as { musicVolume: number; + platformTheme: string; }; assert.equal(getSettingsResponse.status, 200); assert.equal(getSettingsPayload.musicVolume, 0.3); + assert.equal(getSettingsPayload.platformTheme, 'light'); console.log('[server-node:smoke] runtime settings roundtrip ok'); const deleteSnapshotResponse = await httpRequest( `${baseUrl}/api/runtime/save/snapshot`, - withBearer(entry.token, { + withBearer(phoneEntry.token, { method: 'DELETE', }), ); @@ -297,7 +363,7 @@ async function main() { `${baseUrl}/api/runtime/save/snapshot`, { headers: { - Authorization: `Bearer ${entry.token}`, + Authorization: `Bearer ${phoneEntry.token}`, }, }, ); @@ -309,7 +375,7 @@ async function main() { const logoutResponse = await httpRequest( `${baseUrl}/api/auth/logout`, - withBearer(entry.token, { + withBearer(phoneEntry.token, { method: 'POST', }), ); @@ -322,7 +388,7 @@ async function main() { const expiredTokenResponse = await httpRequest(`${baseUrl}/api/auth/me`, { headers: { - Authorization: `Bearer ${entry.token}`, + Authorization: `Bearer ${phoneEntry.token}`, }, }); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 497d1e37..4a50f7d9 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,33 +29,81 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "anymap3" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" + [[package]] name = "api-server" version = "0.1.0" dependencies = [ "axum", + "base64 0.22.1", "dotenvy", + "hmac", "http-body-util", + "httpdate", + "module-assets", "module-auth", "platform-auth", "platform-oss", + "reqwest", "serde", "serde_json", + "sha1", "shared-logging", + "spacetime-client", "time", "tokio", "tower", "tower-http", "tracing", + "url", + "urlencoding", "uuid", ] +[[package]] +name = "approx" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3" +dependencies = [ + "num-traits", +] + [[package]] name = "argon2" version = "0.5.3" @@ -46,10 +112,22 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -114,6 +192,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -141,6 +225,20 @@ dependencies = [ "digest", ] +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -150,18 +248,64 @@ dependencies = [ "generic-array", ] +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", + "serde_core", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.60" @@ -178,6 +322,72 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -187,6 +397,58 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -197,6 +459,56 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "decorum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "deranged" version = "0.5.8" @@ -204,6 +516,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", ] [[package]] @@ -217,30 +543,153 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +dependencies = [ + "serde", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -250,6 +699,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -257,6 +721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -265,6 +730,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -277,8 +776,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -306,6 +810,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -314,11 +832,17 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -328,18 +852,42 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "equivalent", + "rayon", + "serde", + "serde_core", +] + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" version = "0.12.1" @@ -349,6 +897,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -394,6 +951,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "hyper" version = "1.9.0" @@ -412,6 +975,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -420,13 +1000,127 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -435,6 +1129,44 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -447,6 +1179,47 @@ dependencies = [ "serde_core", ] +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "regex", + "serde", + "similar", + "tempfile", + "toml_edit", + "toml_writer", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -459,6 +1232,8 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -469,7 +1244,7 @@ version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64", + "base64 0.22.1", "js-sys", "pem", "ring", @@ -478,12 +1253,32 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lean_string" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a262b6ae1dd9c2d3cf7977a816578b03bf8fb60b61545c395880f95eefc5b24" +dependencies = [ + "castaway", + "itoa", + "ryu", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -496,12 +1291,39 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -529,6 +1351,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -540,6 +1382,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "module-assets" +version = "0.1.0" +dependencies = [ + "platform-oss", + "reqwest", + "serde", + "spacetimedb", +] + [[package]] name = "module-auth" version = "0.1.0" @@ -550,6 +1402,29 @@ dependencies = [ "uuid", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -599,6 +1474,73 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -606,17 +1548,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -626,19 +1574,35 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "platform-auth" version = "0.1.0" dependencies = [ "argon2", "jsonwebtoken", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "time", @@ -651,12 +1615,24 @@ dependencies = [ name = "platform-oss" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "hmac", + "httpdate", + "reqwest", "serde", "serde_json", "sha1", "time", + "tokio", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", ] [[package]] @@ -665,6 +1641,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -684,6 +1669,96 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 2.0.18", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -693,12 +1768,59 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -708,6 +1830,76 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -725,6 +1917,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -739,6 +1971,69 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -751,6 +2046,80 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "second-stack" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -811,6 +2180,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -823,6 +2201,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -830,7 +2239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -841,10 +2250,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -867,6 +2286,28 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -875,7 +2316,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -901,6 +2342,327 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spacetime-client" +version = "0.1.0" +dependencies = [ + "module-assets", + "spacetimedb-sdk", + "tokio", +] + +[[package]] +name = "spacetime-module" +version = "0.1.0" +dependencies = [ + "log", + "module-assets", + "spacetimedb", +] + +[[package]] +name = "spacetimedb" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591f9068644aab6808e7612a869dedde7eeb26df78027a19bc9dc597cc649678" +dependencies = [ + "anyhow", + "bytemuck", + "bytes", + "derive_more", + "getrandom 0.2.17", + "http", + "log", + "rand 0.8.6", + "scoped-tls", + "serde_json", + "spacetimedb-bindings-macro", + "spacetimedb-bindings-sys", + "spacetimedb-lib", + "spacetimedb-primitives", + "spacetimedb-query-builder", +] + +[[package]] +name = "spacetimedb-bindings-macro" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f68bf4810d838be622c13efd4cd64e0a9ce8cd340deaa730f0c92caee845f9" +dependencies = [ + "heck 0.4.1", + "humantime", + "proc-macro2", + "quote", + "spacetimedb-primitives", + "syn", +] + +[[package]] +name = "spacetimedb-bindings-sys" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c2fe9f4124a599c9deae8f8231be3ae5a49bc5b2eef5e04c04b2632cf4cc0b4" +dependencies = [ + "spacetimedb-primitives", +] + +[[package]] +name = "spacetimedb-client-api-messages" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a18c2e145f61ad498f8094a2231e09f8d39a3dde09defa716075dbcb8c7e85" +dependencies = [ + "bytes", + "bytestring", + "chrono", + "derive_more", + "enum-as-inner", + "serde", + "serde_json", + "serde_with", + "smallvec", + "spacetimedb-lib", + "spacetimedb-primitives", + "spacetimedb-sats", + "strum", + "thiserror 1.0.69", +] + +[[package]] +name = "spacetimedb-data-structures" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4035c17ddbfc8c49a659bd6fb265b0a2a11115d1b4ad1963bccfad75cdfb4b" +dependencies = [ + "ahash", + "crossbeam-queue", + "either", + "hashbrown 0.16.1", + "nohash-hasher", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "spacetimedb-lib" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672c0dd16feced67155a0dee7bd38d30f7725321c8177cb871a21c3d8749ae97" +dependencies = [ + "anyhow", + "bitflags", + "blake3", + "chrono", + "derive_more", + "enum-as-inner", + "enum-map", + "hex", + "itertools", + "log", + "serde", + "spacetimedb-bindings-macro", + "spacetimedb-metrics", + "spacetimedb-primitives", + "spacetimedb-sats", + "thiserror 1.0.69", +] + +[[package]] +name = "spacetimedb-memory-usage" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c00614eb981354ee6b31661ec47002d3fc274f9d4543279dd6ee8692cdd8266" +dependencies = [ + "decorum", + "ethnum", +] + +[[package]] +name = "spacetimedb-metrics" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6f7f6b24932505a696b75b7e5e60646ab1d76eeb8d2f95f04948562c965b5e" +dependencies = [ + "arrayvec", + "itertools", + "paste", + "prometheus", +] + +[[package]] +name = "spacetimedb-primitives" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba5d7497d54aa8d4254f78a0bef12606bb05e62f8dea8b69abc9b241508e8b7" +dependencies = [ + "bitflags", + "either", + "enum-as-inner", + "itertools", + "nohash-hasher", + "spacetimedb-memory-usage", +] + +[[package]] +name = "spacetimedb-query-builder" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d04c6e41e05273f14405ac6f429477626677d46528b561a509b7b78b45128f30" +dependencies = [ + "spacetimedb-lib", +] + +[[package]] +name = "spacetimedb-sats" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfde33ec86d80881da8b00c42096bf0382bef8e1bc35e9b6faaa42d77cbf503c" +dependencies = [ + "anyhow", + "arrayvec", + "bitflags", + "bytemuck", + "bytes", + "bytestring", + "chrono", + "decorum", + "derive_more", + "enum-as-inner", + "ethnum", + "hex", + "itertools", + "lean_string", + "rand 0.9.4", + "second-stack", + "serde", + "sha3", + "smallvec", + "spacetimedb-bindings-macro", + "spacetimedb-memory-usage", + "spacetimedb-metrics", + "spacetimedb-primitives", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "spacetimedb-schema" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b03b34a38bd39f3f60a0687efafb942355bd9f6026b88a38c7c9ec904e944f1" +dependencies = [ + "anyhow", + "convert_case 0.6.0", + "derive_more", + "enum-as-inner", + "enum-map", + "indexmap 2.14.0", + "insta", + "itertools", + "lazy_static", + "lean_string", + "petgraph", + "serde_json", + "smallvec", + "spacetimedb-data-structures", + "spacetimedb-lib", + "spacetimedb-memory-usage", + "spacetimedb-primitives", + "spacetimedb-sats", + "spacetimedb-sql-parser", + "termcolor", + "thiserror 1.0.69", + "unicode-ident", + "unicode-normalization", +] + +[[package]] +name = "spacetimedb-sdk" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7302851fec72929ffef976125f51971b0ec76be2730e27705a8e544f2ce159" +dependencies = [ + "anymap3", + "base64 0.21.7", + "brotli", + "bytes", + "flate2", + "futures", + "futures-channel", + "home", + "http", + "log", + "native-tls", + "once_cell", + "prometheus", + "rand 0.9.4", + "spacetimedb-client-api-messages", + "spacetimedb-data-structures", + "spacetimedb-lib", + "spacetimedb-metrics", + "spacetimedb-query-builder", + "spacetimedb-sats", + "spacetimedb-schema", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "spacetimedb-sql-parser" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cbb9a837ac5f1ddb0cfb745159dea276dcf456244452f5a90684e5184f1f31" +dependencies = [ + "derive_more", + "spacetimedb-lib", + "sqlparser", + "thiserror 1.0.69", +] + +[[package]] +name = "sqlparser" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0272b7bb0a225320170c99901b4b5fb3a4384e255a7f2cc228f61e2ba3893e75" +dependencies = [ + "log", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -923,6 +2685,51 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] [[package]] name = "thiserror" @@ -930,7 +2737,18 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -984,15 +2802,43 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ + "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1009,6 +2855,79 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -1033,9 +2952,12 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", + "futures-util", "http", "http-body", + "iri-string", "pin-project-lite", + "tower", "tower-layer", "tower-service", "tracing", @@ -1115,18 +3037,64 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1139,12 +3107,36 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.1" @@ -1162,12 +3154,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1205,6 +3212,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -1254,7 +3271,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -1267,16 +3284,107 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1359,6 +3467,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1381,7 +3498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -1392,8 +3509,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap", + "heck 0.5.0", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -1424,7 +3541,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -1443,7 +3560,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -1453,6 +3570,115 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index c85784fc..b9efbb85 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -5,13 +5,20 @@ resolver = "2" members = [ "crates/api-server", + "crates/module-assets", "crates/module-auth", "crates/platform-oss", "crates/platform-auth", "crates/shared-logging", + "crates/spacetime-client", + "crates/spacetime-module", ] [workspace.package] edition = "2024" version = "0.1.0" license = "UNLICENSED" + +[workspace.dependencies] +log = "0.4" +spacetimedb = "2.1.0" diff --git a/server-rs/README.md b/server-rs/README.md index acb92adc..c67d619e 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -51,6 +51,7 @@ 33. 创建 `scripts/smoke.sh`,固定 Unix-like 本地冒烟验证入口。 34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。 35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。 +36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。 后续任务会继续在本目录内按顺序补齐: @@ -58,6 +59,16 @@ 2. `module-auth` 的身份表、JWT 与 refresh cookie 主链 3. `platform-oss` 的浏览器直传签名、旧 `/generated-*` 前缀映射与对象 URL 解析能力 +当前本地脚本补充说明: + +1. `scripts/smoke.ps1` 用于验证 `api-server` 的本地 `/healthz` 基础 contract。 +2. `scripts/oss-smoke.ps1` 用于验证真实阿里云 OSS: + - 读取仓库根目录 `.env` / `.env.local` + - 启动临时 `api-server` + - 请求 `/api/assets/direct-upload-tickets` + - 实际执行 `PostObject` 上传 + - 校验对象存在并默认自动删除 + ## 3. 已冻结边界 本目录后续落地时必须继续遵守 `M0` 已冻结的边界: diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index ff4d3021..3473903f 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -7,18 +7,28 @@ license.workspace = true [dependencies] axum = "0.8" dotenvy = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +module-assets = { path = "../module-assets" } module-auth = { path = "../module-auth" } platform-auth = { path = "../platform-auth" } platform-oss = { path = "../platform-oss" } serde = { version = "1", features = ["derive"] } serde_json = "1" shared-logging = { path = "../shared-logging" } +spacetime-client = { path = "../spacetime-client" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } time = { version = "0.3", features = ["formatting"] } tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" +url = "2" +urlencoding = "2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] +base64 = "0.22" +hmac = "0.12" +httpdate = "1" http-body-util = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } +sha1 = "0.10" tower = { version = "0.5", features = ["util"] } diff --git a/server-rs/crates/api-server/README.md b/server-rs/crates/api-server/README.md index 9a290480..553fbb3f 100644 --- a/server-rs/crates/api-server/README.md +++ b/server-rs/crates/api-server/README.md @@ -33,6 +33,15 @@ 11. 接入 `GET /api/auth/me` 当前用户查询链路 12. 接入 `POST /api/auth/refresh` refresh token 轮换链路 13. 接入 `POST /api/auth/logout` 当前设备退出链路 +14. 接入 `POST /api/assets/objects/confirm` 上传完成确认链路 +15. 接入 `GET /api/auth/login-options` 登录方式探测链路 +16. 接入 `POST /api/auth/phone/send-code` 手机验证码发送链路 +17. 接入 `POST /api/auth/phone/login` 手机验证码登录链路 +18. 接入 `GET /api/auth/wechat/start` 微信授权起跳链路 +19. 接入 `GET /api/auth/wechat/callback` 微信回调换取系统登录态链路 +20. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路 +21. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路 +22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract 后续与本 crate 直接相关的任务包括: @@ -46,6 +55,15 @@ 8. [x] 接入 `/api/auth/me` 9. [x] 接入 `/api/auth/refresh` 10. [x] 接入 `/api/auth/logout` +11. [x] 接入 `/api/assets/objects/confirm` +12. [x] 接入 `/api/auth/login-options` +13. [x] 接入 `/api/auth/phone/send-code` +14. [x] 接入 `/api/auth/phone/login` +15. [x] 接入 `/api/auth/wechat/start` +16. [x] 接入 `/api/auth/wechat/callback` +17. [x] 接入 `/api/auth/wechat/bind-phone` +18. [x] 接入 `/api/assets/objects/bind` +19. [x] 接入 `/api/assets/sts-upload-credentials` 当前 tracing 约定: @@ -108,3 +126,8 @@ 6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。 7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。 8. 当前 `/api/auth/logout` 复用 `module-auth` 的当前会话吊销与用户版本递增能力,`api-server` 负责 Bearer JWT、refresh cookie 读取与清理 cookie 回写。 +9. 当前 `/api/assets/objects/confirm` 先由 `platform-oss` 完成私有 `HEAD Object` 校验,再通过 `spacetime-client` 调用 `spacetime-module` 的对象确认持久化入口。 +10. 当前 `/api/assets/objects/bind` 只绑定已确认对象到业务实体槽位,不访问 OSS,不创建悬空 `asset_object_id`。 +11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。 +12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。 +13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index e07120c1..c7f93f6d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -10,7 +10,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T use tracing::{Level, info_span}; use crate::{ - assets::{create_direct_upload_ticket, get_asset_read_url}, + assets::{ + bind_asset_object_to_entity, confirm_asset_object, create_direct_upload_ticket, + create_sts_upload_credentials, get_asset_read_url, + }, auth::{ attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie, require_bearer_auth, @@ -23,10 +26,12 @@ use crate::{ logout::logout, logout_all::logout_all, password_entry::password_entry, + phone_auth::{phone_login, send_phone_code}, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, state::AppState, + wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, }; // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 @@ -52,10 +57,7 @@ pub fn build_router(state: AppState) -> Router { attach_refresh_session_token, )), ) - .route( - "/api/auth/login-options", - get(auth_login_options), - ) + .route("/api/auth/login-options", get(auth_login_options)) .route( "/api/auth/me", get(auth_me).route_layer(middleware::from_fn_with_state( @@ -82,6 +84,17 @@ pub fn build_router(state: AppState) -> Router { attach_refresh_session_token, )), ) + .route("/api/auth/phone/send-code", post(send_phone_code)) + .route("/api/auth/phone/login", post(phone_login)) + .route("/api/auth/wechat/start", get(start_wechat_login)) + .route("/api/auth/wechat/callback", get(handle_wechat_callback)) + .route( + "/api/auth/wechat/bind-phone", + post(bind_wechat_phone).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/auth/logout", post(logout) @@ -105,6 +118,15 @@ pub fn build_router(state: AppState) -> Router { "/api/assets/direct-upload-tickets", post(create_direct_upload_ticket), ) + .route( + "/api/assets/sts-upload-credentials", + post(create_sts_upload_credentials), + ) + .route("/api/assets/objects/confirm", post(confirm_asset_object)) + .route( + "/api/assets/objects/bind", + post(bind_asset_object_to_entity), + ) .route("/api/assets/read-url", get(get_asset_read_url)) .route("/api/auth/entry", post(password_entry)) // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 @@ -479,6 +501,858 @@ mod tests { ); } + #[tokio::test] + async fn send_phone_code_returns_mock_cooldown_and_expire_seconds() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "scene": "login" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!( + payload["cooldownSeconds"], + Value::Number(serde_json::Number::from(60)) + ); + assert_eq!( + payload["expiresInSeconds"], + Value::Number(serde_json::Number::from(300)) + ); + assert_eq!(payload["providerRequestId"], Value::Null); + } + + #[tokio::test] + async fn send_phone_code_rejects_same_scene_during_cooldown() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let first_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "scene": "login" + }) + .to_string(), + )) + .expect("first request should build"), + ) + .await + .expect("first request should succeed"); + assert_eq!(first_response.status(), StatusCode::OK); + + let cooldown_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "scene": "login" + }) + .to_string(), + )) + .expect("cooldown request should build"), + ) + .await + .expect("cooldown request should succeed"); + + assert_eq!(cooldown_response.status(), StatusCode::TOO_MANY_REQUESTS); + assert!( + cooldown_response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.parse::().is_ok_and(|seconds| seconds > 0)) + ); + + let body = cooldown_response + .into_body() + .collect() + .await + .expect("cooldown body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("cooldown body should be valid json"); + + assert_eq!( + payload["error"]["code"], + Value::String("TOO_MANY_REQUESTS".to_string()) + ); + assert_eq!( + payload["error"]["message"], + Value::String("验证码发送过于频繁,请稍后再试".to_string()) + ); + assert!( + payload["error"]["details"]["retryAfterSeconds"] + .as_u64() + .is_some() + ); + } + + #[tokio::test] + async fn phone_login_creates_user_and_sets_refresh_cookie() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + let login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "code": "123456" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + assert!( + login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("genarrative_refresh_session=")) + ); + + let body = login_response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert!(payload["token"].as_str().is_some()); + assert_eq!( + payload["user"]["loginMethod"], + Value::String("phone".to_string()) + ); + assert_eq!( + payload["user"]["bindingStatus"], + Value::String("active".to_string()) + ); + assert_eq!( + payload["user"]["phoneNumberMasked"], + Value::String("138****8000".to_string()) + ); + } + + #[tokio::test] + async fn phone_login_reuses_existing_user_for_same_phone_number() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13900139000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + let first_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13900139000", + "code": "123456" + }) + .to_string(), + )) + .expect("first login request should build"), + ) + .await + .expect("first login request should succeed"); + assert_eq!(first_login_response.status(), StatusCode::OK); + let first_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let first_payload: Value = + serde_json::from_slice(&first_body).expect("first login payload should be json"); + + let send_code_again_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13900139000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_again_response.status(), StatusCode::OK); + + let second_login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13900139000", + "code": "123456" + }) + .to_string(), + )) + .expect("second login request should build"), + ) + .await + .expect("second login request should succeed"); + assert_eq!(second_login_response.status(), StatusCode::OK); + let second_body = second_login_response + .into_body() + .collect() + .await + .expect("second login body should collect") + .to_bytes(); + let second_payload: Value = + serde_json::from_slice(&second_body).expect("second login payload should be json"); + + assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); + } + + #[tokio::test] + async fn phone_login_exhausts_code_after_too_many_wrong_attempts() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13700137000", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + for _ in 0..4 { + let wrong_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13700137000", + "code": "000000" + }) + .to_string(), + )) + .expect("wrong login request should build"), + ) + .await + .expect("wrong login request should succeed"); + assert_eq!(wrong_response.status(), StatusCode::BAD_REQUEST); + } + + let exhausted_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13700137000", + "code": "000000" + }) + .to_string(), + )) + .expect("exhausted login request should build"), + ) + .await + .expect("exhausted login request should succeed"); + assert_eq!(exhausted_response.status(), StatusCode::TOO_MANY_REQUESTS); + + let exhausted_body = exhausted_response + .into_body() + .collect() + .await + .expect("exhausted body should collect") + .to_bytes(); + let exhausted_payload: Value = + serde_json::from_slice(&exhausted_body).expect("exhausted payload should be json"); + assert_eq!( + exhausted_payload["error"]["message"], + Value::String("验证码错误次数过多,请重新获取验证码".to_string()) + ); + + let stale_right_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13700137000", + "code": "123456" + }) + .to_string(), + )) + .expect("stale login request should build"), + ) + .await + .expect("stale login request should succeed"); + assert_eq!(stale_right_code_response.status(), StatusCode::BAD_REQUEST); + + let resend_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13700137000", + "scene": "login" + }) + .to_string(), + )) + .expect("resend request should build"), + ) + .await + .expect("resend request should succeed"); + assert_eq!(resend_response.status(), StatusCode::OK); + + let login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13700137000", + "code": "123456" + }) + .to_string(), + )) + .expect("login request should build"), + ) + .await + .expect("login request should succeed"); + assert_eq!(login_response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn wechat_start_returns_mock_callback_url_with_state() { + let config = AppConfig { + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/wechat/start?redirectPath=%2Fplay") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("host", "localhost:3000") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + let authorization_url = payload["authorizationUrl"] + .as_str() + .expect("authorization url should exist"); + + assert!(authorization_url.contains("/api/auth/wechat/callback")); + assert!(authorization_url.contains("mock_code=wx-mock-code")); + assert!(authorization_url.contains("state=")); + } + + #[tokio::test] + async fn wechat_callback_creates_pending_bind_phone_session_with_wechat_provider() { + let config = AppConfig { + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config.clone()).expect("state should build")); + + let start_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/wechat/start?redirectPath=%2Fplay") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("host", "localhost:3000") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("wechat start should succeed"); + let start_body = start_response + .into_body() + .collect() + .await + .expect("wechat start body should collect") + .to_bytes(); + let start_payload: Value = + serde_json::from_slice(&start_body).expect("wechat start payload should be json"); + let authorization_url = start_payload["authorizationUrl"] + .as_str() + .expect("authorization url should exist"); + + let callback_url = + url::Url::parse(authorization_url).expect("authorization url should be valid"); + let state = callback_url + .query_pairs() + .find(|(key, _)| key == "state") + .map(|(_, value)| value.into_owned()) + .expect("state query should exist"); + + let callback_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/api/auth/wechat/callback?state={state}&mock_code=wx-mock-code" + )) + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("host", "localhost:3000") + .body(Body::empty()) + .expect("callback request should build"), + ) + .await + .expect("callback request should succeed"); + + assert_eq!(callback_response.status(), StatusCode::SEE_OTHER); + let location = callback_response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .expect("redirect location should exist"); + let refresh_cookie = callback_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("refresh cookie should exist"); + + assert!(location.starts_with("/play#")); + assert!(location.contains("auth_provider=wechat")); + assert!(location.contains("auth_binding_status=pending_bind_phone")); + assert!(location.contains("auth_token=")); + assert!(refresh_cookie.contains("genarrative_refresh_session=")); + + let auth_hash = location + .split('#') + .nth(1) + .expect("hash fragment should exist"); + let auth_params = url::form_urlencoded::parse(auth_hash.as_bytes()) + .into_owned() + .collect::>(); + let token = auth_params + .get("auth_token") + .expect("auth token should exist in hash"); + + let me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .expect("auth me request should build"), + ) + .await + .expect("auth me request should succeed"); + + assert_eq!(me_response.status(), StatusCode::OK); + let me_body = me_response + .into_body() + .collect() + .await + .expect("auth me body should collect") + .to_bytes(); + let me_payload: Value = + serde_json::from_slice(&me_body).expect("auth me payload should be json"); + + assert_eq!( + me_payload["user"]["loginMethod"], + Value::String("wechat".to_string()) + ); + assert_eq!( + me_payload["user"]["bindingStatus"], + Value::String("pending_bind_phone".to_string()) + ); + + let claims_response = app + .oneshot( + Request::builder() + .uri("/_internal/auth/claims") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .expect("claims request should build"), + ) + .await + .expect("claims request should succeed"); + let claims_body = claims_response + .into_body() + .collect() + .await + .expect("claims body should collect") + .to_bytes(); + let claims_payload: Value = + serde_json::from_slice(&claims_body).expect("claims payload should be json"); + + assert_eq!( + claims_payload["claims"]["provider"], + Value::String("wechat".to_string()) + ); + assert_eq!( + claims_payload["claims"]["binding_status"], + Value::String("pending_bind_phone".to_string()) + ); + assert_eq!( + claims_payload["claims"]["phone_verified"], + Value::Bool(false) + ); + } + + #[tokio::test] + async fn wechat_bind_phone_merges_into_existing_phone_user() { + let config = AppConfig { + sms_auth_enabled: true, + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let phone_send_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "scene": "login" + }) + .to_string(), + )) + .expect("phone send request should build"), + ) + .await + .expect("phone send request should succeed"); + assert_eq!(phone_send_response.status(), StatusCode::OK); + + let phone_login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "code": "123456" + }) + .to_string(), + )) + .expect("phone login request should build"), + ) + .await + .expect("phone login request should succeed"); + let phone_login_body = phone_login_response + .into_body() + .collect() + .await + .expect("phone login body should collect") + .to_bytes(); + let phone_login_payload: Value = + serde_json::from_slice(&phone_login_body).expect("phone login payload should be json"); + let phone_user_id = phone_login_payload["user"]["id"].clone(); + + let wechat_start_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/wechat/start?redirectPath=%2Fplay") + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("host", "localhost:3000") + .body(Body::empty()) + .expect("wechat start request should build"), + ) + .await + .expect("wechat start request should succeed"); + let wechat_start_body = wechat_start_response + .into_body() + .collect() + .await + .expect("wechat start body should collect") + .to_bytes(); + let wechat_start_payload: Value = serde_json::from_slice(&wechat_start_body) + .expect("wechat start payload should be json"); + let authorization_url = wechat_start_payload["authorizationUrl"] + .as_str() + .expect("wechat authorization url should exist"); + let callback_state = url::Url::parse(authorization_url) + .expect("authorization url should be valid") + .query_pairs() + .find(|(key, _)| key == "state") + .map(|(_, value)| value.into_owned()) + .expect("state should exist"); + + let wechat_callback_response = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/api/auth/wechat/callback?state={callback_state}&mock_code=wx-mock-code" + )) + .header( + "user-agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0 Safari/537.36", + ) + .header("host", "localhost:3000") + .body(Body::empty()) + .expect("wechat callback request should build"), + ) + .await + .expect("wechat callback request should succeed"); + let wechat_location = wechat_callback_response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .expect("wechat callback location should exist"); + let wechat_hash = wechat_location + .split('#') + .nth(1) + .expect("wechat callback hash should exist"); + let wechat_auth_params = url::form_urlencoded::parse(wechat_hash.as_bytes()) + .into_owned() + .collect::>(); + let wechat_token = wechat_auth_params + .get("auth_token") + .expect("wechat auth token should exist"); + + let bind_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "scene": "bind_phone" + }) + .to_string(), + )) + .expect("bind code request should build"), + ) + .await + .expect("bind code request should succeed"); + assert_eq!(bind_code_response.status(), StatusCode::OK); + + let bind_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/wechat/bind-phone") + .header("authorization", format!("Bearer {wechat_token}")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "phone": "13800138000", + "code": "123456" + }) + .to_string(), + )) + .expect("bind request should build"), + ) + .await + .expect("bind request should succeed"); + + assert_eq!(bind_response.status(), StatusCode::OK); + assert!( + bind_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("genarrative_refresh_session=")) + ); + let bind_body = bind_response + .into_body() + .collect() + .await + .expect("bind body should collect") + .to_bytes(); + let bind_payload: Value = + serde_json::from_slice(&bind_body).expect("bind payload should be json"); + + assert_eq!(bind_payload["user"]["id"], phone_user_id); + assert_eq!( + bind_payload["user"]["bindingStatus"], + Value::String("active".to_string()) + ); + assert_eq!( + bind_payload["user"]["loginMethod"], + Value::String("phone".to_string()) + ); + assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true)); + assert_eq!( + bind_payload["user"]["phoneNumberMasked"], + Value::String("138****8000".to_string()) + ); + } + #[tokio::test] async fn auth_sessions_returns_multi_device_session_fields() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -1178,8 +2052,8 @@ mod tests { .await .expect("logout-all body should collect") .to_bytes(); - let logout_all_payload: Value = serde_json::from_slice(&logout_all_body) - .expect("logout-all payload should be json"); + let logout_all_payload: Value = + serde_json::from_slice(&logout_all_body).expect("logout-all payload should be json"); assert_eq!(logout_all_payload["ok"], Value::Bool(true)); let me_response = app @@ -1279,4 +2153,4 @@ mod tests { .is_some_and(|value| value.contains("Max-Age=0")) ); } -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index e53b8943..278d1356 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -2,12 +2,21 @@ use std::collections::BTreeMap; use axum::{ Json, - extract::{Extension, State}, + extract::{Extension, Query, State}, http::StatusCode, }; -use platform_oss::{LegacyAssetPrefix, OssObjectAccess, OssPostObjectRequest}; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION, + build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, + generate_asset_object_id, normalize_optional_value, validate_asset_object_fields, +}; +use platform_oss::{ + LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPostObjectRequest, + OssSignedGetObjectUrlRequest, +}; use serde::Deserialize; use serde_json::{Value, json}; +use spacetime_client::SpacetimeClientError; use crate::{ api_response::json_success_body, http_error::AppError, request_context::RequestContext, @@ -35,6 +44,63 @@ pub struct CreateDirectUploadTicketRequest { pub success_action_status: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetReadUrlQuery { + #[serde(default)] + pub object_key: Option, + #[serde(default)] + pub legacy_public_path: Option, + #[serde(default)] + pub expire_seconds: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmAssetObjectRequest { + #[serde(default)] + pub bucket: Option, + pub object_key: String, + #[serde(default)] + pub content_type: Option, + #[serde(default)] + pub content_length: Option, + #[serde(default)] + pub content_hash: Option, + pub asset_kind: String, + #[serde(default)] + pub access_policy: Option, + #[serde(default)] + pub source_job_id: Option, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub entity_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BindAssetObjectRequest { + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + #[serde(default)] + pub owner_user_id: Option, + #[serde(default)] + pub profile_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfirmAssetObjectAccessPolicy { + Private, + PublicRead, +} + pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, @@ -60,7 +126,7 @@ pub async fn create_direct_upload_ticket( path_segments: payload.path_segments, file_name: payload.file_name, content_type: payload.content_type, - access: payload.access.unwrap_or(OssObjectAccess::Public), + access: payload.access.unwrap_or(OssObjectAccess::Private), metadata: payload.metadata, max_size_bytes: payload.max_size_bytes, expire_seconds: payload.expire_seconds, @@ -81,18 +147,335 @@ pub async fn create_direct_upload_ticket( )) } +pub async fn get_asset_read_url( + State(state): State, + Extension(request_context): Extension, + Query(query): Query, +) -> Result, AppError> { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + + let object_key = resolve_object_key_from_query(&query).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "objectKey", + "reason": "必须提供 objectKey 或 legacyPublicPath", + })) + })?; + + let signed = oss_client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key, + expire_seconds: query.expire_seconds, + }) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })) + })?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "read": signed, + }), + )) +} + +pub async fn create_sts_upload_credentials( + Extension(_request_context): Extension, +) -> Result, AppError> { + Err( + AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ + "provider": "aliyun-sts", + "enabled": false, + "reason": "当前上传主链为服务器上传 OSS,Web 端只负责读取,不开放浏览器 STS 写权限", + "fallback": "/api/assets/direct-upload-tickets", + })), + ) +} + +pub async fn confirm_asset_object( + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result, AppError> { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + + let result = state + .spacetime_client() + .confirm_asset_object( + build_confirm_asset_object_upsert_input(oss_client, payload) + .await + .map_err(map_confirm_asset_object_prepare_error)?, + ) + .await + .map_err(map_confirm_asset_object_error)?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "assetObject": { + "assetObjectId": result.asset_object_id, + "bucket": result.bucket, + "objectKey": result.object_key, + "accessPolicy": result.access_policy.as_str(), + "contentType": result.content_type, + "contentLength": result.content_length, + "contentHash": result.content_hash, + "version": result.version, + "sourceJobId": result.source_job_id, + "ownerUserId": result.owner_user_id, + "profileId": result.profile_id, + "entityId": result.entity_id, + "assetKind": result.asset_kind, + "createdAt": result.created_at, + "updatedAt": result.updated_at, + } + }), + )) +} + +pub async fn bind_asset_object_to_entity( + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result, AppError> { + let now_micros = current_utc_micros(); + let input = build_asset_entity_binding_input( + generate_asset_binding_id(now_micros), + payload.asset_object_id, + payload.entity_kind, + payload.entity_id, + payload.slot, + payload.asset_kind, + payload.owner_user_id, + payload.profile_id, + now_micros, + ) + .map_err(map_asset_entity_binding_prepare_error)?; + + let result = state + .spacetime_client() + .bind_asset_object_to_entity(input) + .await + .map_err(map_confirm_asset_object_error)?; + + Ok(json_success_body( + Some(&request_context), + json!({ + "assetBinding": { + "bindingId": result.binding_id, + "assetObjectId": result.asset_object_id, + "entityKind": result.entity_kind, + "entityId": result.entity_id, + "slot": result.slot, + "assetKind": result.asset_kind, + "ownerUserId": result.owner_user_id, + "profileId": result.profile_id, + "createdAt": result.created_at, + "updatedAt": result.updated_at, + } + }), + )) +} + +fn resolve_object_key_from_query(query: &GetReadUrlQuery) -> Option { + if let Some(object_key) = query + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(object_key.trim_start_matches('/').to_string()); + } + + query + .legacy_public_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) +} + +async fn build_confirm_asset_object_upsert_input( + oss_client: &platform_oss::OssClient, + payload: ConfirmAssetObjectRequest, +) -> Result { + let configured_bucket = oss_client.config_bucket().to_string(); + let resolved_bucket = payload + .bucket + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(configured_bucket.as_str()) + .to_string(); + + if resolved_bucket != configured_bucket { + return Err(ConfirmAssetObjectPrepareError::BucketMismatch); + } + + validate_asset_object_fields( + &resolved_bucket, + &payload.object_key, + &payload.asset_kind, + INITIAL_ASSET_OBJECT_VERSION, + ) + .map_err(ConfirmAssetObjectPrepareError::Field)?; + + let head = oss_client + .head_object( + &reqwest::Client::new(), + OssHeadObjectRequest { + object_key: payload.object_key, + }, + ) + .await + .map_err(ConfirmAssetObjectPrepareError::Oss)?; + + if let Some(expected_length) = payload.content_length + && expected_length != head.content_length + { + return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch); + } + + let now_micros = current_utc_micros(); + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + resolved_bucket, + head.object_key, + payload + .access_policy + .map(Into::into) + .unwrap_or(AssetObjectAccessPolicy::Private), + head.content_type + .or_else(|| normalize_optional_value(payload.content_type)), + head.content_length, + normalize_optional_value(payload.content_hash), + payload.asset_kind, + payload.source_job_id, + payload.owner_user_id, + payload.profile_id, + payload.entity_id, + now_micros, + ) + .map_err(ConfirmAssetObjectPrepareError::Field) +} + +fn map_confirm_asset_object_prepare_error(error: ConfirmAssetObjectPrepareError) -> AppError { + match error { + ConfirmAssetObjectPrepareError::BucketMismatch + | ConfirmAssetObjectPrepareError::ContentLengthMismatch + | ConfirmAssetObjectPrepareError::Field(_) => { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) + } + ConfirmAssetObjectPrepareError::Oss(platform_oss::OssError::ObjectNotFound(_)) => { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })) + } + ConfirmAssetObjectPrepareError::Oss(_) => AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })), + } +} + +fn map_asset_entity_binding_prepare_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-entity-binding", + "message": error.to_string(), + })) +} + +fn map_confirm_asset_object_error(error: SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[derive(Debug)] +enum ConfirmAssetObjectPrepareError { + BucketMismatch, + ContentLengthMismatch, + Field(AssetObjectFieldError), + Oss(platform_oss::OssError), +} + +impl std::fmt::Display for ConfirmAssetObjectPrepareError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BucketMismatch => f.write_str("bucket 与当前服务端 OSS bucket 不一致"), + Self::ContentLengthMismatch => { + f.write_str("客户端声明的 contentLength 与 OSS 实际对象大小不一致") + } + Self::Field(error) => write!(f, "{error}"), + Self::Oss(error) => write!(f, "{error}"), + } + } +} + +impl From for AssetObjectAccessPolicy { + fn from(value: ConfirmAssetObjectAccessPolicy) -> Self { + match value { + ConfirmAssetObjectAccessPolicy::Private => Self::Private, + ConfirmAssetObjectAccessPolicy::PublicRead => Self::PublicRead, + } + } +} + #[cfg(test)] mod tests { + use std::{ + collections::BTreeMap, + error::Error, + fs, + path::{Path, PathBuf}, + time::SystemTime, + }; + use axum::{ body::Body, http::{Request, StatusCode}, }; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; + use hmac::{Hmac, Mac}; use http_body_util::BodyExt; + use httpdate::fmt_http_date; + use reqwest::{Method, multipart}; use serde_json::{Value, json}; + use sha1::Sha1; use tower::ServiceExt; + use uuid::Uuid; use crate::{app::build_router, config::AppConfig, state::AppState}; + type HmacSha1 = Hmac; + #[tokio::test] async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -144,7 +527,6 @@ mod tests { oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), oss_access_key_id: Some("test-access-key-id".to_string()), oss_access_key_secret: Some("test-access-key-secret".to_string()), - oss_public_base_url: Some("https://cdn.genarrative.local".to_string()), ..AppConfig::default() }; @@ -187,20 +569,809 @@ mod tests { serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!( + payload["data"]["upload"]["bucket"], + Value::String("genarrative-assets".to_string()) + ); assert_eq!( payload["data"]["upload"]["objectKey"], Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string()) ); assert_eq!( - payload["data"]["upload"]["publicUrl"], - Value::String( - "https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png" - .to_string() - ) + payload["data"]["upload"]["access"], + Value::String("private".to_string()) ); assert_eq!( payload["data"]["upload"]["formFields"]["OSSAccessKeyId"], Value::String("test-access-key-id".to_string()) ); + assert!(payload["data"]["upload"].get("publicUrl").is_none()); + } + + #[tokio::test] + async fn read_url_returns_signed_private_object_url_when_oss_configured() { + let config = AppConfig { + oss_bucket: Some("genarrative-assets".to_string()), + oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), + oss_access_key_id: Some("test-access-key-id".to_string()), + oss_access_key_secret: Some("test-access-key-secret".to_string()), + ..AppConfig::default() + }; + + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/assets/read-url?objectKey=generated-characters/hero_001/visual/asset_01/master.png") + .header("x-genarrative-response-envelope", "1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!(payload["ok"], Value::Bool(true)); + assert_eq!( + payload["data"]["read"]["objectKey"], + Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string()) + ); + assert!( + payload["data"]["read"]["signedUrl"] + .as_str() + .is_some_and(|value| value.contains("OSSAccessKeyId=test-access-key-id")) + ); + } + + #[tokio::test] + async fn read_url_accepts_legacy_public_path_for_transition() { + let config = AppConfig { + oss_bucket: Some("genarrative-assets".to_string()), + oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), + oss_access_key_id: Some("test-access-key-id".to_string()), + oss_access_key_secret: Some("test-access-key-secret".to_string()), + ..AppConfig::default() + }; + + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/assets/read-url?legacyPublicPath=%2Fgenerated-custom-world-scenes%2Fprofile_01%2Flandmark_01%2Fscene.png") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!( + payload["read"]["objectKey"], + Value::String( + "generated-custom-world-scenes/profile_01/landmark_01/scene.png".to_string() + ) + ); + } + + #[tokio::test] + async fn read_url_rejects_missing_identifier() { + let config = AppConfig { + oss_bucket: Some("genarrative-assets".to_string()), + oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), + oss_access_key_id: Some("test-access-key-id".to_string()), + oss_access_key_secret: Some("test-access-key-secret".to_string()), + ..AppConfig::default() + }; + + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/assets/read-url") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn sts_upload_credentials_are_disabled_for_browser_writes() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/sts-upload-credentials") + .header("x-genarrative-response-envelope", "1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("aliyun-sts".to_string()) + ); + assert_eq!(payload["error"]["details"]["enabled"], Value::Bool(false)); + assert!(payload["error"]["details"].get("credentials").is_none()); + } + + #[tokio::test] + async fn confirm_asset_object_returns_service_unavailable_when_oss_missing() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/objects/confirm") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "objectKey": "generated-characters/hero_001/visual/asset_404/master.png", + "assetKind": "character_visual" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn confirm_asset_object_rejects_bucket_mismatch_before_calling_oss() { + let config = AppConfig { + oss_bucket: Some("xushi-dev".to_string()), + oss_endpoint: Some("oss-cn-beijing.aliyuncs.com".to_string()), + oss_access_key_id: Some("test-access-key-id".to_string()), + oss_access_key_secret: Some("test-access-key-secret".to_string()), + ..AppConfig::default() + }; + + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/objects/confirm") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "bucket": "another-bucket", + "objectKey": "generated-characters/hero_001/visual/asset_404/master.png", + "assetKind": "character_visual" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "bucket 不一致应在发 OSS 请求前直接被拒绝" + ); + } + + #[tokio::test] + async fn bind_asset_object_rejects_missing_slot_before_calling_spacetime() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/objects/bind") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "assetObjectId": "assetobj_001", + "entityKind": "character", + "entityId": "hero_001", + "slot": " ", + "assetKind": "character_visual" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + assert_eq!( + payload["error"]["details"]["provider"], + Value::String("asset-entity-binding".to_string()) + ); + } + + #[tokio::test] + #[ignore = "需要本地 SpacetimeDB genarrative-dev 已启动并发布当前模块"] + async fn bind_asset_object_rejects_missing_asset_object_in_spacetime() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/objects/bind") + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1") + .body(Body::from( + json!({ + "assetObjectId": "assetobj_missing_for_binding_test", + "entityKind": "character", + "entityId": "hero_001", + "slot": "primary_visual", + "assetKind": "character_visual" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + } + + #[tokio::test] + #[ignore = "需要仓库根目录 .env / .env.local 中的真实 OSS 配置"] + async fn oss_live_roundtrip_works_with_private_bucket() { + let config = load_live_oss_config().expect("live OSS config should load"); + let client = reqwest::Client::new(); + let mut uploaded_object_key: Option = None; + + let test_result = async { + let bucket_head = send_signed_oss_request(&client, &config, Method::HEAD, None).await?; + ensure_success_status(bucket_head.status().as_u16(), "bucket HEAD 应成功")?; + + let app = build_router(AppState::new(config.clone()).expect("state should build")); + let run_id = Uuid::new_v4().simple().to_string(); + let file_name = format!("oss-live-{run_id}.txt"); + let file_content = format!("Genarrative OSS Rust live test {run_id}"); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/direct-upload-tickets") + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1") + .body(Body::from( + json!({ + "legacyPrefix": "/generated-character-drafts/*", + "pathSegments": ["rust-live-test", run_id], + "fileName": file_name, + "contentType": "text/plain", + "metadata": { + "origin": "cargo-test", + "asset-kind": "manual-test" + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + if response.status() != StatusCode::OK { + return Err(std::io::Error::other(format!( + "直传票据接口返回了非预期状态码:{}", + response.status() + )) + .into()); + } + + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("response body should be valid json"); + + let upload = payload["data"]["upload"].clone(); + let upload_host = upload["host"] + .as_str() + .ok_or_else(|| std::io::Error::other("upload.host 缺失"))? + .to_string(); + let object_key = upload["objectKey"] + .as_str() + .ok_or_else(|| std::io::Error::other("upload.objectKey 缺失"))? + .to_string(); + uploaded_object_key = Some(object_key.clone()); + + let mut form = multipart::Form::new(); + for (key, value) in read_form_fields(&upload)? { + form = form.text(key, value); + } + form = form.part( + "file", + multipart::Part::text(file_content.clone()) + .file_name("oss-live-test.txt") + .mime_str("text/plain")?, + ); + + let upload_response = client.post(upload_host).multipart(form).send().await?; + ensure_success_status(upload_response.status().as_u16(), "PostObject 上传应成功")?; + + let public_response = client + .head(build_object_url(&config, &object_key)?) + .send() + .await?; + if public_response.status().as_u16() != 403 { + return Err(std::io::Error::other(format!( + "私有对象匿名读取应返回 403,实际为 {}", + public_response.status() + )) + .into()); + } + + let read_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/assets/read-url?objectKey={object_key}")) + .header("x-genarrative-response-envelope", "1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + if read_response.status() != StatusCode::OK { + return Err(std::io::Error::other(format!( + "私有读签名接口返回了非预期状态码:{}", + read_response.status() + )) + .into()); + } + + let read_body = read_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let read_payload: Value = + serde_json::from_slice(&read_body).expect("response body should be valid json"); + let signed_url = read_payload["data"]["read"]["signedUrl"] + .as_str() + .ok_or_else(|| std::io::Error::other("read.signedUrl 缺失"))?; + + let signed_read = client.get(signed_url).send().await?; + ensure_success_status(signed_read.status().as_u16(), "签名读应成功")?; + let signed_content = signed_read.text().await?; + if signed_content != file_content { + return Err(std::io::Error::other("签名读回来的对象内容与上传内容不一致").into()); + } + + Ok::<(), Box>(()) + } + .await; + + if let Some(object_key) = uploaded_object_key.as_deref() { + let delete_result = + send_signed_oss_request(&client, &config, Method::DELETE, Some(object_key)).await; + if let Ok(response) = delete_result { + ensure_success_status(response.status().as_u16(), "测试对象删除应成功") + .expect("cleanup should succeed"); + } + } + + test_result.expect("live OSS roundtrip should succeed"); + } + + #[tokio::test] + #[ignore = "需要仓库根目录 .env / .env.local 中的真实 OSS 配置"] + async fn confirm_asset_object_live_roundtrip_persists_confirmed_record() { + let config = load_live_oss_config().expect("live OSS config should load"); + let client = reqwest::Client::new(); + let mut uploaded_object_key: Option = None; + + let test_result = async { + let app = build_router(AppState::new(config.clone()).expect("state should build")); + let run_id = Uuid::new_v4().simple().to_string(); + let file_content = format!("Genarrative confirm asset object live test {run_id}"); + + let ticket_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/direct-upload-tickets") + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1") + .body(Body::from( + json!({ + "legacyPrefix": "/generated-characters/*", + "pathSegments": ["confirm-live-test", run_id], + "fileName": "master.txt", + "contentType": "text/plain", + "metadata": { + "origin": "cargo-test", + "asset-kind": "character-visual" + } + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + let ticket_body = ticket_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let ticket_payload: Value = + serde_json::from_slice(&ticket_body).expect("response body should be valid json"); + let upload = ticket_payload["data"]["upload"].clone(); + let object_key = upload["objectKey"] + .as_str() + .ok_or_else(|| std::io::Error::other("upload.objectKey 缺失"))? + .to_string(); + let upload_host = upload["host"] + .as_str() + .ok_or_else(|| std::io::Error::other("upload.host 缺失"))? + .to_string(); + uploaded_object_key = Some(object_key.clone()); + + let mut form = multipart::Form::new(); + for (key, value) in read_form_fields(&upload)? { + form = form.text(key, value); + } + form = form.part( + "file", + multipart::Part::text(file_content) + .file_name("master.txt") + .mime_str("text/plain")?, + ); + + let upload_response = client.post(upload_host).multipart(form).send().await?; + ensure_success_status(upload_response.status().as_u16(), "PostObject 上传应成功")?; + + let confirm_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/objects/confirm") + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1") + .body(Body::from( + json!({ + "objectKey": object_key, + "assetKind": "character_visual", + "accessPolicy": "private" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + if confirm_response.status() != StatusCode::OK { + return Err(std::io::Error::other(format!( + "对象确认接口返回了非预期状态码:{}", + confirm_response.status() + )) + .into()); + } + + let confirm_body = confirm_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let confirm_payload: Value = + serde_json::from_slice(&confirm_body).expect("response body should be valid json"); + + assert!( + confirm_payload["data"]["assetObject"]["assetObjectId"] + .as_str() + .is_some_and(|value| value.starts_with("assetobj_")) + ); + assert_eq!( + confirm_payload["data"]["assetObject"]["bucket"], + Value::String( + config + .oss_bucket + .clone() + .expect("live config should have bucket") + ) + ); + assert_eq!( + confirm_payload["data"]["assetObject"]["accessPolicy"], + Value::String("private".to_string()) + ); + + let asset_object_id = confirm_payload["data"]["assetObject"]["assetObjectId"] + .as_str() + .ok_or_else(|| std::io::Error::other("assetObjectId 缺失"))? + .to_string(); + let bind_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/assets/objects/bind") + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1") + .body(Body::from( + json!({ + "assetObjectId": asset_object_id, + "entityKind": "character", + "entityId": format!("hero_{run_id}"), + "slot": "primary_visual", + "assetKind": "character_visual" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + if bind_response.status() != StatusCode::OK { + return Err(std::io::Error::other(format!( + "对象绑定接口返回了非预期状态码:{}", + bind_response.status() + )) + .into()); + } + + let bind_body = bind_response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let bind_payload: Value = + serde_json::from_slice(&bind_body).expect("response body should be valid json"); + + assert!( + bind_payload["data"]["assetBinding"]["bindingId"] + .as_str() + .is_some_and(|value| value.starts_with("assetbind_")) + ); + assert_eq!( + bind_payload["data"]["assetBinding"]["assetObjectId"], + Value::String(asset_object_id) + ); + assert_eq!( + bind_payload["data"]["assetBinding"]["slot"], + Value::String("primary_visual".to_string()) + ); + + Ok::<(), Box>(()) + } + .await; + + if let Some(object_key) = uploaded_object_key.as_deref() { + let delete_result = + send_signed_oss_request(&client, &config, Method::DELETE, Some(object_key)).await; + if let Ok(response) = delete_result { + ensure_success_status(response.status().as_u16(), "测试对象删除应成功") + .expect("cleanup should succeed"); + } + } + + test_result.expect("live asset confirm roundtrip should succeed"); + } + + fn load_live_oss_config() -> Result> { + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("..") + .canonicalize()?; + let mut env_map = BTreeMap::new(); + + read_env_file(&repo_root.join(".env"), &mut env_map)?; + read_env_file(&repo_root.join(".env.local"), &mut env_map)?; + + Ok(AppConfig { + oss_bucket: Some(read_required_env(&env_map, "ALIYUN_OSS_BUCKET")?), + oss_endpoint: Some(read_required_env(&env_map, "ALIYUN_OSS_ENDPOINT")?), + oss_access_key_id: Some(read_required_env(&env_map, "ALIYUN_OSS_ACCESS_KEY_ID")?), + oss_access_key_secret: Some(read_required_env( + &env_map, + "ALIYUN_OSS_ACCESS_KEY_SECRET", + )?), + ..AppConfig::default() + }) + } + + fn read_env_file( + path: &Path, + target: &mut BTreeMap, + ) -> Result<(), Box> { + if !path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(path)?; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let Some((key, value)) = trimmed.split_once('=') else { + continue; + }; + + let value = value.trim().trim_matches('"').to_string(); + target.insert(key.trim().to_string(), value); + } + + Ok(()) + } + + fn read_required_env( + env_map: &BTreeMap, + key: &str, + ) -> Result> { + env_map + .get(key) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| std::io::Error::other(format!("缺少 {key}")).into()) + } + + fn read_form_fields(upload: &Value) -> Result, Box> { + let form_fields = upload["formFields"] + .as_object() + .ok_or_else(|| std::io::Error::other("upload.formFields 缺失"))?; + + let mut fields = Vec::with_capacity(form_fields.len()); + for (key, value) in form_fields { + let value = value + .as_str() + .ok_or_else(|| std::io::Error::other(format!("formFields.{key} 不是字符串")))?; + fields.push((key.clone(), value.to_string())); + } + + Ok(fields) + } + + fn build_object_url( + config: &AppConfig, + object_key: &str, + ) -> Result> { + let bucket = config + .oss_bucket + .as_deref() + .ok_or_else(|| std::io::Error::other("缺少 oss bucket"))?; + let endpoint = config + .oss_endpoint + .as_deref() + .ok_or_else(|| std::io::Error::other("缺少 oss endpoint"))?; + let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?; + url = url.join(object_key.trim_start_matches('/'))?; + Ok(url) + } + + async fn send_signed_oss_request( + client: &reqwest::Client, + config: &AppConfig, + method: Method, + object_key: Option<&str>, + ) -> Result> { + let bucket = config + .oss_bucket + .as_deref() + .ok_or_else(|| std::io::Error::other("缺少 oss bucket"))?; + let endpoint = config + .oss_endpoint + .as_deref() + .ok_or_else(|| std::io::Error::other("缺少 oss endpoint"))?; + let access_key_id = config + .oss_access_key_id + .as_deref() + .ok_or_else(|| std::io::Error::other("缺少 oss access key id"))?; + let access_key_secret = config + .oss_access_key_secret + .as_deref() + .ok_or_else(|| std::io::Error::other("缺少 oss access key secret"))?; + let date = fmt_http_date(SystemTime::now()); + let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) { + Some(object_key) => format!("/{bucket}/{}", object_key.trim_start_matches('/')), + None => format!("/{bucket}/"), + }; + let string_to_sign = format!("{}\n\n\n{}\n{}", method.as_str(), date, canonical_resource); + let signature = sign_oss_string(access_key_secret, &string_to_sign)?; + let target_url = match object_key.map(str::trim).filter(|value| !value.is_empty()) { + Some(object_key) => build_object_url(config, object_key)?, + None => reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/"))?, + }; + + let response = client + .request(method, target_url) + .header("Date", date) + .header("Authorization", format!("OSS {access_key_id}:{signature}")) + .send() + .await?; + + Ok(response) + } + + fn sign_oss_string(secret: &str, content: &str) -> Result> { + let mut signer = HmacSha1::new_from_slice(secret.as_bytes())?; + signer.update(content.as_bytes()); + Ok(BASE64_STANDARD.encode(signer.finalize().into_bytes())) + } + + fn ensure_success_status(status: u16, message: &str) -> Result<(), Box> { + if (200..300).contains(&status) { + return Ok(()); + } + + Err(std::io::Error::other(format!("{message},实际状态码为 {status}")).into()) } } diff --git a/server-rs/crates/api-server/src/auth_session.rs b/server-rs/crates/api-server/src/auth_session.rs index 247dff82..e27ff631 100644 --- a/server-rs/crates/api-server/src/auth_session.rs +++ b/server-rs/crates/api-server/src/auth_session.rs @@ -9,8 +9,8 @@ use platform_auth::{ }; use time::OffsetDateTime; +use crate::session_client::SessionClientContext; use crate::{http_error::AppError, state::AppState}; -use crate::{session_client::SessionClientContext}; #[derive(Debug, Clone)] pub struct SignedAuthSession { @@ -22,6 +22,15 @@ pub fn create_password_auth_session( state: &AppState, user: &AuthUser, session_client: &SessionClientContext, +) -> Result { + create_auth_session(state, user, session_client, AuthLoginMethod::Password) +} + +pub fn create_auth_session( + state: &AppState, + user: &AuthUser, + session_client: &SessionClientContext, + session_provider: AuthLoginMethod, ) -> Result { let refresh_token = create_refresh_session_token(); let refresh_token_hash = hash_refresh_session_token(&refresh_token); @@ -31,13 +40,18 @@ pub fn create_password_auth_session( CreateRefreshSessionInput { user_id: user.id.clone(), refresh_token_hash, - issued_by_provider: AuthLoginMethod::Password, + issued_by_provider: session_provider.clone(), client_info: session_client.to_refresh_session_client_info(), }, OffsetDateTime::now_utc(), ) .map_err(map_refresh_session_error)?; - let access_token = sign_access_token_for_user(state, user, &session.session.session_id)?; + let access_token = sign_access_token_for_user( + state, + user, + &session.session.session_id, + Some(&session_provider), + )?; Ok(SignedAuthSession { access_token, @@ -49,12 +63,13 @@ pub fn sign_access_token_for_user( state: &AppState, user: &AuthUser, session_id: &str, + session_provider_override: Option<&AuthLoginMethod>, ) -> Result { let access_claims = AccessTokenClaims::from_input( AccessTokenClaimsInput { user_id: user.id.clone(), session_id: session_id.to_string(), - provider: map_auth_provider(&user.login_method), + provider: map_auth_provider(session_provider_override.unwrap_or(&user.login_method)), roles: vec!["user".to_string()], token_version: user.token_version, phone_verified: user.phone_number_masked.is_some(), diff --git a/server-rs/crates/api-server/src/auth_sessions.rs b/server-rs/crates/api-server/src/auth_sessions.rs index a7a94c50..96dbbf3d 100644 --- a/server-rs/crates/api-server/src/auth_sessions.rs +++ b/server-rs/crates/api-server/src/auth_sessions.rs @@ -70,9 +70,9 @@ pub async fn auth_sessions( .sessions .into_iter() .map(|session| { - let is_current = current_refresh_token_hash.as_ref().is_some_and(|hash| { - session.refresh_token_hash == *hash - }); + let is_current = current_refresh_token_hash + .as_ref() + .is_some_and(|hash| session.refresh_token_hash == *hash); let client_label = session.client_info.device_display_name.clone(); AuthSessionSummaryPayload { @@ -99,8 +99,10 @@ pub async fn auth_sessions( fn map_refresh_session_list_error(error: module_auth::RefreshSessionError) -> AppError { match error { - module_auth::RefreshSessionError::UserNotFound => AppError::from_status(StatusCode::UNAUTHORIZED) - .with_message("当前登录态已失效,请重新登录"), + module_auth::RefreshSessionError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("当前登录态已失效,请重新登录") + } module_auth::RefreshSessionError::MissingToken | module_auth::RefreshSessionError::SessionNotFound | module_auth::RefreshSessionError::SessionExpired => { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 12c90df4..845df0a7 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -16,14 +16,30 @@ pub struct AppConfig { pub refresh_session_ttl_days: u32, pub sms_auth_enabled: bool, pub wechat_auth_enabled: bool, + pub wechat_auth_provider: String, + pub wechat_app_id: Option, + pub wechat_app_secret: Option, + pub wechat_callback_path: String, + pub wechat_redirect_path: String, + pub wechat_authorize_endpoint: String, + pub wechat_access_token_endpoint: String, + pub wechat_user_info_endpoint: String, + pub wechat_state_ttl_minutes: u32, + pub wechat_mock_user_id: String, + pub wechat_mock_union_id: Option, + pub wechat_mock_display_name: String, + pub wechat_mock_avatar_url: Option, pub oss_bucket: Option, pub oss_endpoint: Option, pub oss_access_key_id: Option, pub oss_access_key_secret: Option, - pub oss_public_base_url: Option, + pub oss_read_expire_seconds: u64, pub oss_post_expire_seconds: u64, pub oss_post_max_size_bytes: u64, pub oss_success_action_status: u16, + pub spacetime_server_url: String, + pub spacetime_database: String, + pub spacetime_token: Option, } impl Default for AppConfig { @@ -42,14 +58,31 @@ impl Default for AppConfig { refresh_session_ttl_days: 30, sms_auth_enabled: false, wechat_auth_enabled: false, + wechat_auth_provider: "mock".to_string(), + wechat_app_id: None, + wechat_app_secret: None, + wechat_callback_path: "/api/auth/wechat/callback".to_string(), + wechat_redirect_path: "/".to_string(), + wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(), + wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token" + .to_string(), + wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(), + wechat_state_ttl_minutes: 15, + wechat_mock_user_id: "wx-mock-user".to_string(), + wechat_mock_union_id: Some("wx-mock-union".to_string()), + wechat_mock_display_name: "微信旅人".to_string(), + wechat_mock_avatar_url: None, oss_bucket: None, oss_endpoint: None, oss_access_key_id: None, oss_access_key_secret: None, - oss_public_base_url: None, + oss_read_expire_seconds: 10 * 60, oss_post_expire_seconds: 10 * 60, oss_post_max_size_bytes: 20 * 1024 * 1024, oss_success_action_status: 200, + spacetime_server_url: "http://127.0.0.1:3000".to_string(), + spacetime_database: "genarrative-dev".to_string(), + spacetime_token: None, } } } @@ -126,12 +159,58 @@ impl AppConfig { if let Some(wechat_auth_enabled) = read_first_bool_env(&["WECHAT_AUTH_ENABLED"]) { config.wechat_auth_enabled = wechat_auth_enabled; } + if let Some(wechat_auth_provider) = read_first_non_empty_env(&["WECHAT_AUTH_PROVIDER"]) { + config.wechat_auth_provider = wechat_auth_provider; + } + config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]); + config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]); + if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) { + config.wechat_callback_path = wechat_callback_path; + } + if let Some(wechat_redirect_path) = read_first_non_empty_env(&["WECHAT_REDIRECT_PATH"]) { + config.wechat_redirect_path = wechat_redirect_path; + } + if let Some(wechat_authorize_endpoint) = + read_first_non_empty_env(&["WECHAT_AUTHORIZE_ENDPOINT"]) + { + config.wechat_authorize_endpoint = wechat_authorize_endpoint; + } + if let Some(wechat_access_token_endpoint) = + read_first_non_empty_env(&["WECHAT_ACCESS_TOKEN_ENDPOINT"]) + { + config.wechat_access_token_endpoint = wechat_access_token_endpoint; + } + if let Some(wechat_user_info_endpoint) = + read_first_non_empty_env(&["WECHAT_USER_INFO_ENDPOINT"]) + { + config.wechat_user_info_endpoint = wechat_user_info_endpoint; + } + if let Some(wechat_state_ttl_minutes) = + read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"]) + { + config.wechat_state_ttl_minutes = wechat_state_ttl_minutes; + } + if let Some(wechat_mock_user_id) = read_first_non_empty_env(&["WECHAT_MOCK_USER_ID"]) { + config.wechat_mock_user_id = wechat_mock_user_id; + } + config.wechat_mock_union_id = read_first_non_empty_env(&["WECHAT_MOCK_UNION_ID"]); + if let Some(wechat_mock_display_name) = + read_first_non_empty_env(&["WECHAT_MOCK_DISPLAY_NAME"]) + { + config.wechat_mock_display_name = wechat_mock_display_name; + } + config.wechat_mock_avatar_url = read_first_non_empty_env(&["WECHAT_MOCK_AVATAR_URL"]); config.oss_bucket = read_first_non_empty_env(&["ALIYUN_OSS_BUCKET"]); config.oss_endpoint = read_first_non_empty_env(&["ALIYUN_OSS_ENDPOINT"]); config.oss_access_key_id = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_ID"]); config.oss_access_key_secret = read_first_non_empty_env(&["ALIYUN_OSS_ACCESS_KEY_SECRET"]); - config.oss_public_base_url = read_first_non_empty_env(&["ALIYUN_OSS_PUBLIC_BASE_URL"]); + + if let Some(oss_read_expire_seconds) = + read_first_duration_seconds_env(&["ALIYUN_OSS_READ_EXPIRE_SECONDS"]) + { + config.oss_read_expire_seconds = oss_read_expire_seconds; + } if let Some(oss_post_expire_seconds) = read_first_duration_seconds_env(&["ALIYUN_OSS_POST_EXPIRE_SECONDS"]) @@ -151,6 +230,20 @@ impl AppConfig { config.oss_success_action_status = oss_success_action_status; } + if let Some(spacetime_server_url) = + read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"]) + { + config.spacetime_server_url = spacetime_server_url; + } + + if let Some(spacetime_database) = + read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"]) + { + config.spacetime_database = spacetime_database; + } + + config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]); + config } diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 88853a9b..b22ba953 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -1,6 +1,6 @@ use axum::{ - http::{HeaderMap, HeaderValue}, http::StatusCode, + http::{HeaderMap, HeaderValue}, response::{IntoResponse, Response}, }; use serde::Serialize; @@ -42,6 +42,10 @@ impl AppError { self.code } + pub fn message(&self) -> &str { + &self.message + } + pub fn with_message(mut self, message: impl Into) -> Self { self.message = message.into(); self @@ -60,7 +64,8 @@ impl AppError { pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response { let status_code = self.status_code; let payload = self.to_payload(); - let mut response = (status_code, json_error_body(request_context, &payload)).into_response(); + let mut response = + (status_code, json_error_body(request_context, &payload)).into_response(); response.headers_mut().extend(self.headers); response } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index f204e3e0..91e04cc5 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -3,8 +3,8 @@ mod app; mod assets; mod auth; mod auth_me; -mod auth_sessions; mod auth_session; +mod auth_sessions; mod config; mod error_middleware; mod health; @@ -13,11 +13,14 @@ mod login_options; mod logout; mod logout_all; mod password_entry; +mod phone_auth; mod refresh_session; mod request_context; mod response_headers; mod session_client; mod state; +mod wechat_auth; +mod wechat_provider; use shared_logging::init_tracing; use tokio::net::TcpListener; @@ -45,4 +48,4 @@ async fn main() -> Result<(), std::io::Error> { info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听"); axum::serve(listener, router).await -} \ No newline at end of file +} diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs new file mode 100644 index 00000000..e2789bab --- /dev/null +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -0,0 +1,188 @@ +use axum::{ + Json, + extract::{Extension, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, +}; +use module_auth::{ + AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use time::OffsetDateTime; + +use crate::{ + api_response::json_success_body, + auth_session::{ + attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, + }, + http_error::AppError, + password_entry::PasswordEntryUserPayload, + request_context::RequestContext, + session_client::resolve_session_client_context, + state::AppState, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PhoneSendCodeRequest { + pub phone: String, + pub scene: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PhoneSendCodeResponse { + pub ok: bool, + pub cooldown_seconds: u64, + pub expires_in_seconds: u64, + pub provider_request_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginRequest { + pub phone: String, + pub code: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginResponse { + pub token: String, + pub user: PasswordEntryUserPayload, +} + +pub async fn send_phone_code( + State(state): State, + Extension(request_context): Extension, + Json(payload): Json, +) -> Result, AppError> { + // 短信登录开关由服务端配置统一控制,避免前端误调用未开放能力。 + if !state.config.sms_auth_enabled { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") + ); + } + let scene = map_phone_auth_scene(payload.scene.as_deref())?; + let result = state + .phone_auth_service() + .send_code( + SendPhoneCodeInput { + phone_number: payload.phone, + scene, + }, + OffsetDateTime::now_utc(), + ) + .map_err(map_phone_auth_error)?; + + Ok(json_success_body( + Some(&request_context), + PhoneSendCodeResponse { + ok: true, + cooldown_seconds: result.cooldown_seconds, + expires_in_seconds: result.expires_in_seconds, + provider_request_id: result.provider_request_id, + }, + )) +} + +pub async fn phone_login( + State(state): State, + Extension(request_context): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + // 手机号验证码校验通过后,沿用统一会话签发逻辑,确保 refresh cookie 与 JWT 行为一致。 + if !state.config.sms_auth_enabled { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") + ); + } + let result = state + .phone_auth_service() + .login( + PhoneLoginInput { + phone_number: payload.phone, + verify_code: payload.code, + }, + OffsetDateTime::now_utc(), + ) + .await + .map_err(map_phone_auth_error)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Phone, + )?; + + let mut headers = HeaderMap::new(); + attach_set_cookie_header( + &mut headers, + build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, + ); + + Ok(( + headers, + json_success_body( + Some(&request_context), + PhoneLoginResponse { + token: signed_session.access_token, + user: PasswordEntryUserPayload { + id: result.user.id, + username: result.user.username, + display_name: result.user.display_name, + phone_number_masked: result.user.phone_number_masked, + login_method: result.user.login_method.as_str(), + binding_status: result.user.binding_status.as_str(), + wechat_bound: result.user.wechat_bound, + }, + }, + ), + )) +} + +fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result { + match raw_scene.unwrap_or("login").trim() { + "login" => Ok(PhoneAuthScene::Login), + "bind_phone" => Ok(PhoneAuthScene::BindPhone), + "change_phone" => Ok(PhoneAuthScene::ChangePhone), + _ => Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("短信验证码场景不合法") + .with_details(json!({ "field": "scene" }))), + } +} + +fn map_phone_auth_error(error: PhoneAuthError) -> AppError { + match error { + PhoneAuthError::InvalidPhoneNumber + | PhoneAuthError::InvalidVerifyCode + | PhoneAuthError::VerifyCodeNotFound + | PhoneAuthError::VerifyCodeExpired + | PhoneAuthError::UserStateMismatch => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + PhoneAuthError::SendCoolingDown { + retry_after_seconds, + } => { + let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_message(error.to_string()) + .with_details(json!({ "retryAfterSeconds": retry_after_seconds })); + match HeaderValue::from_str(&retry_after_seconds.to_string()) { + Ok(value) => app_error.with_header("retry-after", value), + Err(_) => app_error, + } + } + PhoneAuthError::VerifyAttemptsExceeded => { + AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string()) + } + PhoneAuthError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) + } + PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + } +} diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index cb88fb81..7494f14a 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -54,8 +54,12 @@ pub async fn refresh_session( OffsetDateTime::now_utc(), ) .map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?; - let access_token = - sign_access_token_for_user(&state, &rotated.user, &rotated.session.session_id)?; + let access_token = sign_access_token_for_user( + &state, + &rotated.user, + &rotated.session.session_id, + Some(&rotated.session.issued_by_provider), + )?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/session_client.rs b/server-rs/crates/api-server/src/session_client.rs index 0eeecba1..c6a7ae9b 100644 --- a/server-rs/crates/api-server/src/session_client.rs +++ b/server-rs/crates/api-server/src/session_client.rs @@ -56,24 +56,30 @@ pub fn resolve_session_client_context(headers: &HeaderMap) -> SessionClientConte normalize_runtime(header_value(headers, X_CLIENT_RUNTIME_HEADER), &ua_lower); let explicit_client_platform = normalize_platform(header_value(headers, X_CLIENT_PLATFORM_HEADER), &ua_lower); - let client_instance_id = normalize_optional_string(header_value( - headers, - X_CLIENT_INSTANCE_ID_HEADER, - )); + let client_instance_id = + normalize_optional_string(header_value(headers, X_CLIENT_INSTANCE_ID_HEADER)); let mini_program_app_id = normalize_optional_string(header_value(headers, X_MINI_PROGRAM_APP_ID_HEADER)); let mini_program_env = normalize_optional_string(header_value(headers, X_MINI_PROGRAM_ENV_HEADER)); let inferred_client_type = infer_client_type(explicit_client_type.as_deref(), &ua_lower); - let inferred_runtime = - infer_client_runtime(explicit_client_runtime.as_deref(), &inferred_client_type, &ua_lower); + let inferred_runtime = infer_client_runtime( + explicit_client_runtime.as_deref(), + &inferred_client_type, + &ua_lower, + ); let inferred_platform = infer_client_platform(explicit_client_platform.as_deref(), &ua_lower); let ip = resolve_ip(headers); let device_display_name = build_device_display_name(&inferred_client_type, &inferred_runtime, &inferred_platform); - let device_fingerprint = - build_device_fingerprint(&inferred_client_type, &inferred_runtime, &inferred_platform, client_instance_id.as_deref(), user_agent.as_deref()); + let device_fingerprint = build_device_fingerprint( + &inferred_client_type, + &inferred_runtime, + &inferred_platform, + client_instance_id.as_deref(), + user_agent.as_deref(), + ); SessionClientContext { client_type: inferred_client_type, @@ -160,7 +166,11 @@ fn infer_client_type(explicit_type: Option<&str>, ua_lower: &str) -> String { "web_browser".to_string() } -fn infer_client_runtime(explicit_runtime: Option<&str>, client_type: &str, ua_lower: &str) -> String { +fn infer_client_runtime( + explicit_runtime: Option<&str>, + client_type: &str, + ua_lower: &str, +) -> String { if client_type == "mini_program" { if let Some(runtime) = explicit_runtime { return runtime.to_string(); @@ -201,7 +211,8 @@ fn infer_runtime_from_user_agent(ua_lower: &str) -> Option { if ua_lower.contains("chrome/") || ua_lower.contains("crios/") { return Some("chrome".to_string()); } - if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") { + if ua_lower.contains("safari/") && !ua_lower.contains("chrome/") && !ua_lower.contains("crios/") + { return Some("safari".to_string()); } @@ -228,7 +239,11 @@ fn infer_platform_from_user_agent(ua_lower: &str) -> Option { None } -fn build_device_display_name(client_type: &str, client_runtime: &str, client_platform: &str) -> String { +fn build_device_display_name( + client_type: &str, + client_runtime: &str, + client_platform: &str, +) -> String { // 展示名固定由后端派生,避免前端上传自由文本导致同类设备标签漂移。 if client_type == "mini_program" { return format!( @@ -329,7 +344,10 @@ pub fn mask_ip(ip: Option<&str>) -> Option { } if ip.contains(':') { - let parts = ip.split(':').filter(|part| !part.is_empty()).collect::>(); + let parts = ip + .split(':') + .filter(|part| !part.is_empty()) + .collect::>(); if parts.len() <= 2 { return Some(ip.to_string()); } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index b161e2f6..21fd3e1f 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,12 +1,17 @@ use std::{error::Error, fmt}; -use module_auth::{AuthUserService, InMemoryAuthStore, PasswordEntryService, RefreshSessionService}; +use module_auth::{ + AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, + RefreshSessionService, WechatAuthService, WechatAuthStateService, +}; use platform_auth::{ JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, }; use platform_oss::{OssClient, OssConfig, OssError}; +use spacetime_client::{SpacetimeClient, SpacetimeClientConfig}; use crate::config::AppConfig; +use crate::wechat_provider::{WechatProvider, build_wechat_provider}; // 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。 #[derive(Clone, Debug)] @@ -20,6 +25,11 @@ pub struct AppState { password_entry_service: PasswordEntryService, refresh_session_service: RefreshSessionService, auth_user_service: AuthUserService, + phone_auth_service: PhoneAuthService, + wechat_auth_state_service: WechatAuthStateService, + wechat_auth_service: WechatAuthService, + wechat_provider: WechatProvider, + spacetime_client: SpacetimeClient, } #[derive(Debug)] @@ -51,8 +61,18 @@ impl AppState { let auth_store = InMemoryAuthStore::default(); let password_entry_service = PasswordEntryService::new(auth_store.clone()); let auth_user_service = AuthUserService::new(auth_store.clone()); + let phone_auth_service = PhoneAuthService::new(auth_store.clone()); + let wechat_auth_state_service = + WechatAuthStateService::new(auth_store.clone(), config.wechat_state_ttl_minutes); + let wechat_auth_service = WechatAuthService::new(auth_store.clone()); + let wechat_provider = build_wechat_provider(&config); let refresh_session_service = RefreshSessionService::new(auth_store, config.refresh_session_ttl_days); + let spacetime_client = SpacetimeClient::new(SpacetimeClientConfig { + server_url: config.spacetime_server_url.clone(), + database: config.spacetime_database.clone(), + token: config.spacetime_token.clone(), + }); Ok(Self { config, @@ -62,6 +82,11 @@ impl AppState { password_entry_service, refresh_session_service, auth_user_service, + phone_auth_service, + wechat_auth_state_service, + wechat_auth_service, + wechat_provider, + spacetime_client, }) } @@ -88,6 +113,26 @@ impl AppState { pub fn auth_user_service(&self) -> &AuthUserService { &self.auth_user_service } + + pub fn phone_auth_service(&self) -> &PhoneAuthService { + &self.phone_auth_service + } + + pub fn wechat_auth_state_service(&self) -> &WechatAuthStateService { + &self.wechat_auth_state_service + } + + pub fn wechat_auth_service(&self) -> &WechatAuthService { + &self.wechat_auth_service + } + + pub fn wechat_provider(&self) -> &WechatProvider { + &self.wechat_provider + } + + pub fn spacetime_client(&self) -> &SpacetimeClient { + &self.spacetime_client + } } impl fmt::Display for AppStateInitError { diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs new file mode 100644 index 00000000..f43e80b8 --- /dev/null +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -0,0 +1,382 @@ +use axum::{ + Json, + extract::{Extension, Query, State}, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Redirect, Response}, +}; +use module_auth::{ + AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError, + WechatAuthScene, +}; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use url::Url; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + auth_session::{ + attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session, + }, + http_error::AppError, + password_entry::PasswordEntryUserPayload, + request_context::RequestContext, + session_client::resolve_session_client_context, + state::AppState, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WechatStartQuery { + pub redirect_path: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WechatStartResponse { + pub authorization_url: String, +} + +#[derive(Debug, Deserialize)] +pub struct WechatCallbackQuery { + pub state: Option, + pub code: Option, + pub mock_code: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WechatBindPhoneRequest { + pub phone: String, + pub code: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WechatBindPhoneResponse { + pub token: String, + pub user: PasswordEntryUserPayload, +} + +pub async fn start_wechat_login( + State(state): State, + Extension(request_context): Extension, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + if !state.config.wechat_auth_enabled { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); + } + let user_agent = headers + .get("user-agent") + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + let scene = resolve_wechat_scene(user_agent.as_deref())?; + let state_record = state + .wechat_auth_state_service() + .create_state( + CreateWechatAuthStateInput { + redirect_path: normalize_redirect_path( + query.redirect_path.as_deref(), + &state.config.wechat_redirect_path, + ), + scene: scene.clone(), + request_user_agent: user_agent.clone(), + }, + OffsetDateTime::now_utc(), + ) + .map_err(map_wechat_auth_error)?; + let authorization_url = state.wechat_provider().build_authorization_url( + &resolve_wechat_callback_url(&state, &headers)?, + &state_record.state.state_token, + &scene, + )?; + + Ok(json_success_body( + Some(&request_context), + WechatStartResponse { authorization_url }, + )) +} + +pub async fn handle_wechat_callback( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + if !state.config.wechat_auth_enabled { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); + } + let fallback_redirect = state.config.wechat_redirect_path.clone(); + let state_token = query + .state + .as_deref() + .unwrap_or_default() + .trim() + .to_string(); + if state_token.is_empty() { + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录状态已失效,请重新发起登录。"), + ], + )) + .into_response()); + } + + let consumed = match state + .wechat_auth_state_service() + .consume_state(&state_token, OffsetDateTime::now_utc()) + { + Ok(value) => value, + Err(_) => { + return Ok(Redirect::to(&build_auth_result_redirect_url( + &fallback_redirect, + &[ + ("auth_provider", "wechat"), + ("auth_error", "微信登录状态已失效,请重新发起登录。"), + ], + )) + .into_response()); + } + }; + + let redirect_path = consumed.state.redirect_path.clone(); + let session_client = resolve_session_client_context(&headers); + + let result = match state + .wechat_provider() + .resolve_callback_profile(query.code.as_deref(), query.mock_code.as_deref()) + .await + { + Ok(profile) => state + .wechat_auth_service() + .resolve_login(module_auth::ResolveWechatLoginInput { profile }) + .await + .map_err(map_wechat_auth_error), + Err(error) => Err(error), + }; + + match result { + Ok(result) => { + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Wechat, + )?; + let mut response = Redirect::to(&build_auth_result_redirect_url( + &redirect_path, + &[ + ("auth_provider", "wechat"), + ("auth_token", signed_session.access_token.as_str()), + ("auth_binding_status", result.user.binding_status.as_str()), + ], + )) + .into_response(); + attach_set_cookie_header( + response.headers_mut(), + build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, + ); + Ok(response) + } + Err(error) => Ok(Redirect::to(&build_auth_result_redirect_url( + &redirect_path, + &[("auth_provider", "wechat"), ("auth_error", error.message())], + )) + .into_response()), + } +} + +pub async fn bind_wechat_phone( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + if !state.config.wechat_auth_enabled { + return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); + } + let result = state + .phone_auth_service() + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: authenticated.claims().user_id().to_string(), + phone_number: payload.phone, + verify_code: payload.code, + }, + OffsetDateTime::now_utc(), + ) + .await + .map_err(map_wechat_bind_phone_error)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Wechat, + )?; + + let mut response_headers = HeaderMap::new(); + attach_set_cookie_header( + &mut response_headers, + build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, + ); + + Ok(( + response_headers, + json_success_body( + Some(&request_context), + WechatBindPhoneResponse { + token: signed_session.access_token, + user: PasswordEntryUserPayload { + id: result.user.id, + username: result.user.username, + display_name: result.user.display_name, + phone_number_masked: result.user.phone_number_masked, + login_method: result.user.login_method.as_str(), + binding_status: result.user.binding_status.as_str(), + wechat_bound: result.user.wechat_bound, + }, + }, + ), + )) +} + +fn resolve_wechat_scene(user_agent: Option<&str>) -> Result { + let user_agent = user_agent.unwrap_or_default(); + let is_wechat = user_agent.contains("MicroMessenger"); + let is_mobile = user_agent.contains("Android") + || user_agent.contains("iPhone") + || user_agent.contains("iPad") + || user_agent.contains("Mobile"); + + if is_wechat { + return Ok(WechatAuthScene::WechatInApp); + } + if is_mobile { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前浏览器请使用手机号登录,或在微信内打开后再使用微信登录")); + } + + Ok(WechatAuthScene::Desktop) +} + +fn normalize_redirect_path(raw_value: Option<&str>, fallback: &str) -> String { + let Some(raw_value) = raw_value.map(str::trim).filter(|value| !value.is_empty()) else { + return fallback.to_string(); + }; + if raw_value.starts_with('/') { + return raw_value.to_string(); + } + Url::parse(raw_value) + .map(|url| { + format!( + "{}{}{}", + url.path(), + url.query().map(|v| format!("?{v}")).unwrap_or_default(), + url.fragment().map(|v| format!("#{v}")).unwrap_or_default() + ) + }) + .unwrap_or_else(|_| fallback.to_string()) +} + +fn resolve_wechat_callback_url(state: &AppState, headers: &HeaderMap) -> Result { + let proto = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("http"); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("127.0.0.1:3000"); + Ok(format!( + "{proto}://{host}{}", + state.config.wechat_callback_path + )) +} + +fn build_auth_result_redirect_url(redirect_path: &str, params: &[(&str, &str)]) -> String { + let hash = params + .iter() + .map(|(key, value)| { + format!( + "{}={}", + urlencoding::encode(key), + urlencoding::encode(value) + ) + }) + .collect::>() + .join("&"); + let path_without_hash = redirect_path.split('#').next().unwrap_or("/"); + format!( + "{}#{}", + if path_without_hash.is_empty() { + "/" + } else { + path_without_hash + }, + hash + ) +} + +#[allow(dead_code)] +fn _assert_response_type(_: Response) {} + +fn map_wechat_auth_error(error: WechatAuthError) -> AppError { + match error { + WechatAuthError::MissingProfile + | WechatAuthError::StateNotFound + | WechatAuthError::StateExpired + | WechatAuthError::StateConsumed + | WechatAuthError::MissingWechatIdentity => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + WechatAuthError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) + } + WechatAuthError::Store(_) | WechatAuthError::PasswordHash(_) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + } +} + +fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError { + match error { + module_auth::PhoneAuthError::InvalidPhoneNumber + | module_auth::PhoneAuthError::InvalidVerifyCode + | module_auth::PhoneAuthError::VerifyCodeNotFound + | module_auth::PhoneAuthError::VerifyCodeExpired + | module_auth::PhoneAuthError::UserStateMismatch => { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + } + module_auth::PhoneAuthError::SendCoolingDown { + retry_after_seconds, + } => { + let app_error = AppError::from_status(StatusCode::TOO_MANY_REQUESTS) + .with_message(error.to_string()) + .with_details(serde_json::json!({ "retryAfterSeconds": retry_after_seconds })); + match HeaderValue::from_str(&retry_after_seconds.to_string()) { + Ok(value) => app_error.with_header("retry-after", value), + Err(_) => app_error, + } + } + module_auth::PhoneAuthError::VerifyAttemptsExceeded => { + AppError::from_status(StatusCode::TOO_MANY_REQUESTS).with_message(error.to_string()) + } + module_auth::PhoneAuthError::UserNotFound => { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) + } + module_auth::PhoneAuthError::Store(_) | module_auth::PhoneAuthError::PasswordHash(_) => { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) + } + } +} diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat_provider.rs new file mode 100644 index 00000000..f6ff9da7 --- /dev/null +++ b/server-rs/crates/api-server/src/wechat_provider.rs @@ -0,0 +1,280 @@ +use module_auth::{WechatAuthScene, WechatIdentityProfile}; +use reqwest::Client; +use serde::Deserialize; +use tracing::warn; +use url::Url; + +use crate::{config::AppConfig, http_error::AppError}; +use axum::http::StatusCode; + +#[derive(Clone, Debug)] +pub enum WechatProvider { + Disabled, + Mock(MockWechatProvider), + Real(RealWechatProvider), +} + +#[derive(Clone, Debug)] +pub struct MockWechatProvider { + mock_user_id: String, + mock_union_id: Option, + mock_display_name: String, + mock_avatar_url: Option, +} + +#[derive(Clone, Debug)] +pub struct RealWechatProvider { + client: Client, + app_id: String, + app_secret: String, + authorize_endpoint: String, + access_token_endpoint: String, + user_info_endpoint: String, +} + +#[derive(Debug, Deserialize)] +struct WechatAccessTokenResponse { + access_token: Option, + openid: Option, + unionid: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatUserInfoResponse { + openid: Option, + unionid: Option, + nickname: Option, + headimgurl: Option, + errmsg: Option, +} + +pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider { + if !config.wechat_auth_enabled { + return WechatProvider::Disabled; + } + + if config + .wechat_auth_provider + .trim() + .eq_ignore_ascii_case("mock") + { + return WechatProvider::Mock(MockWechatProvider { + mock_user_id: config.wechat_mock_user_id.clone(), + mock_union_id: config.wechat_mock_union_id.clone(), + mock_display_name: config.wechat_mock_display_name.clone(), + mock_avatar_url: config.wechat_mock_avatar_url.clone(), + }); + } + + let Some(app_id) = config.wechat_app_id.clone() else { + return WechatProvider::Disabled; + }; + let Some(app_secret) = config.wechat_app_secret.clone() else { + return WechatProvider::Disabled; + }; + + WechatProvider::Real(RealWechatProvider { + client: Client::new(), + app_id, + app_secret, + authorize_endpoint: config.wechat_authorize_endpoint.clone(), + access_token_endpoint: config.wechat_access_token_endpoint.clone(), + user_info_endpoint: config.wechat_user_info_endpoint.clone(), + }) +} + +impl WechatProvider { + pub fn build_authorization_url( + &self, + callback_url: &str, + state: &str, + scene: &WechatAuthScene, + ) -> Result { + match self { + Self::Disabled => { + Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")) + } + Self::Mock(_) => { + let mut callback = Url::parse(callback_url).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("微信回调地址非法:{error}")) + })?; + callback + .query_pairs_mut() + .append_pair("mock_code", "wx-mock-code") + .append_pair("state", state); + Ok(callback.to_string()) + } + Self::Real(provider) => provider.build_authorization_url(callback_url, state, scene), + } + } + + pub async fn resolve_callback_profile( + &self, + code: Option<&str>, + mock_code: Option<&str>, + ) -> Result { + match self { + Self::Disabled => { + Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")) + } + Self::Mock(provider) => Ok(provider.resolve_callback_profile(mock_code)), + Self::Real(provider) => provider.resolve_callback_profile(code).await, + } + } +} + +impl MockWechatProvider { + fn resolve_callback_profile(&self, mock_code: Option<&str>) -> WechatIdentityProfile { + let provider_uid = mock_code + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(self.mock_user_id.as_str()) + .to_string(); + WechatIdentityProfile { + provider_uid, + provider_union_id: self.mock_union_id.clone(), + display_name: Some(self.mock_display_name.clone()), + avatar_url: self.mock_avatar_url.clone(), + } + } +} + +impl RealWechatProvider { + fn build_authorization_url( + &self, + callback_url: &str, + state: &str, + scene: &WechatAuthScene, + ) -> Result { + let mut url = Url::parse(match scene { + WechatAuthScene::Desktop => &self.authorize_endpoint, + WechatAuthScene::WechatInApp => "https://open.weixin.qq.com/connect/oauth2/authorize", + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("微信授权地址非法:{error}")) + })?; + url.query_pairs_mut() + .append_pair("appid", &self.app_id) + .append_pair("redirect_uri", callback_url) + .append_pair("response_type", "code") + .append_pair( + "scope", + match scene { + WechatAuthScene::Desktop => "snsapi_login", + WechatAuthScene::WechatInApp => "snsapi_userinfo", + }, + ) + .append_pair("state", state); + Ok(format!("{url}#wechat_redirect")) + } + + async fn resolve_callback_profile( + &self, + code: Option<&str>, + ) -> Result { + let code = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code") + })?; + + let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("微信 access_token 地址非法:{error}")) + })?; + access_token_url + .query_pairs_mut() + .append_pair("appid", &self.app_id) + .append_pair("secret", &self.app_secret) + .append_pair("code", code) + .append_pair("grant_type", "authorization_code"); + + let access_token_payload = self + .client + .get(access_token_url.as_str()) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信 access_token 请求失败"); + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信登录失败:access_token 请求失败") + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信 access_token 响应解析失败"); + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信登录失败:access_token 响应非法") + })?; + + let access_token = access_token_payload + .access_token + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!( + "微信登录失败:{}", + access_token_payload + .errmsg + .unwrap_or_else(|| "缺少 access_token".to_string()) + )) + })?; + let openid = access_token_payload + .openid + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信登录失败:缺少 openid") + })?; + + let mut user_info_url = Url::parse(&self.user_info_endpoint).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("微信用户信息地址非法:{error}")) + })?; + user_info_url + .query_pairs_mut() + .append_pair("access_token", &access_token) + .append_pair("openid", &openid) + .append_pair("lang", "zh_CN"); + + let user_info_payload = self + .client + .get(user_info_url.as_str()) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信用户信息请求失败"); + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信登录失败:用户信息请求失败") + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信用户信息响应解析失败"); + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信登录失败:用户信息响应非法") + })?; + + let provider_uid = user_info_payload + .openid + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(format!( + "微信登录失败:{}", + user_info_payload + .errmsg + .unwrap_or_else(|| "缺少 openid".to_string()) + )) + })?; + + Ok(WechatIdentityProfile { + provider_uid, + provider_union_id: user_info_payload.unionid.or(access_token_payload.unionid), + display_name: user_info_payload.nickname, + avatar_url: user_info_payload.headimgurl, + }) + } +} diff --git a/server-rs/crates/module-assets/Cargo.toml b/server-rs/crates/module-assets/Cargo.toml new file mode 100644 index 00000000..387edf0a --- /dev/null +++ b/server-rs/crates/module-assets/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "module-assets" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = ["server-service"] +server-service = ["dep:platform-oss", "dep:reqwest"] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true } +spacetimedb = { workspace = true, optional = true } +platform-oss = { path = "../platform-oss", optional = true } diff --git a/server-rs/crates/module-assets/README.md b/server-rs/crates/module-assets/README.md index 6696de87..b930404f 100644 --- a/server-rs/crates/module-assets/README.md +++ b/server-rs/crates/module-assets/README.md @@ -14,10 +14,29 @@ ## 2. 当前阶段说明 -当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施: +当前提交尚未进入完整资产状态建模,但已完成与本模块直接相关的前置基础设施与首版 schema 骨架: 1. `api-server` 已具备 `POST /api/assets/direct-upload-tickets` 2. `platform-oss` 已具备旧 `/generated-*` 前缀兼容的 `PostObject` 签名能力 +3. 资产对象引用口径已冻结为 `bucket + object_key` 双列 +4. `module-assets` 已落地: + - `AssetObjectAccessPolicy` + - `asset_object` 字段校验 helper + - `assetobj_` ID 前缀与初始版本常量 + - `asset_entity_binding` 输入、快照、返回记录与字段校验 helper + - `assetbind_` ID 前缀 + +当前 `asset_object` 表的字段、索引与可编码约束见: + +1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) +2. [../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md) +3. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md) + +当前还已补齐: + +1. `AssetObjectService` +2. 私有 bucket `HEAD Object` 后的对象确认写入 +3. 当前阶段的进程内 `asset_object` 去重存储 后续与本 package 直接相关的任务包括: @@ -31,3 +50,4 @@ 1. `module-assets` 负责资产任务状态、对象引用关系与模块级编排,不把二进制对象本身放回本地持久化目录真相中。 2. OSS 上传、签名、对象读写等副作用通过平台适配完成,状态最终回写到 `apps/spacetime-module` 聚合的状态模型中。 3. 前端兼容接口由 `apps/api-server` 暴露,但资产任务状态与对象绑定关系不能再次散落到本地文件判断逻辑里。 +4. 后续 `SpacetimeDB` 中的对象引用统一按 `bucket + object_key` 两列建模,不存完整 URL 作为真相字段。 diff --git a/server-rs/crates/module-assets/src/asset_object_core.rs b/server-rs/crates/module-assets/src/asset_object_core.rs new file mode 100644 index 00000000..26b77421 --- /dev/null +++ b/server-rs/crates/module-assets/src/asset_object_core.rs @@ -0,0 +1,515 @@ +use std::{error::Error, fmt}; + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const ASSET_OBJECT_ID_PREFIX: &str = "assetobj_"; +pub const ASSET_BINDING_ID_PREFIX: &str = "assetbind_"; +pub const INITIAL_ASSET_OBJECT_VERSION: u32 = 1; + +// 资产对象访问策略先冻结为枚举,避免后续在 reducer、HTTP DTO 和脚本里散落字符串字面量。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum AssetObjectAccessPolicy { + Private, + PublicRead, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AssetObjectFieldError { + MissingBucket, + MissingObjectKey, + MissingAssetKind, + MissingAssetObjectId, + MissingBindingId, + MissingEntityKind, + MissingEntityId, + MissingSlot, + InvalidVersion, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfirmAssetObjectInput { + pub bucket: Option, + pub object_key: String, + pub content_type: Option, + pub content_length: Option, + pub content_hash: Option, + pub asset_kind: String, + pub access_policy: Option, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetEntityBindingProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectUpsertInput { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: AssetObjectAccessPolicy, + pub content_type: Option, + pub content_length: u64, + pub content_hash: Option, + pub version: u32, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, + pub asset_kind: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectUpsertSnapshot { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: AssetObjectAccessPolicy, + pub content_type: Option, + pub content_length: u64, + pub content_hash: Option, + pub version: u32, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, + pub asset_kind: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetEntityBindingInput { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetEntityBindingSnapshot { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetObjectRecord { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: AssetObjectAccessPolicy, + pub content_type: Option, + pub content_length: u64, + pub content_hash: Option, + pub version: u32, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, + pub asset_kind: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfirmAssetObjectResult { + pub record: AssetObjectRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetEntityBindingRecord { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub created_at: String, + pub updated_at: String, +} + +impl AssetObjectAccessPolicy { + pub fn as_str(&self) -> &'static str { + match self { + Self::Private => "private", + Self::PublicRead => "public_read", + } + } +} + +// bucket 与 object_key 是正式真相字段,因此这里只做字段校验,不回退成单字符串路径字段。 +pub fn validate_asset_object_fields( + bucket: &str, + object_key: &str, + asset_kind: &str, + version: u32, +) -> Result<(), AssetObjectFieldError> { + if bucket.trim().is_empty() { + return Err(AssetObjectFieldError::MissingBucket); + } + + if object_key.trim().trim_start_matches('/').is_empty() { + return Err(AssetObjectFieldError::MissingObjectKey); + } + + if asset_kind.trim().is_empty() { + return Err(AssetObjectFieldError::MissingAssetKind); + } + + if version == 0 { + return Err(AssetObjectFieldError::InvalidVersion); + } + + Ok(()) +} + +// 业务绑定首版只校验稳定定位字段;授权关系后续由 SpacetimeDB 身份透传接入后再收紧。 +pub fn validate_asset_entity_binding_fields( + binding_id: &str, + asset_object_id: &str, + entity_kind: &str, + entity_id: &str, + slot: &str, + asset_kind: &str, +) -> Result<(), AssetObjectFieldError> { + if binding_id.trim().is_empty() { + return Err(AssetObjectFieldError::MissingBindingId); + } + + if asset_object_id.trim().is_empty() { + return Err(AssetObjectFieldError::MissingAssetObjectId); + } + + if entity_kind.trim().is_empty() { + return Err(AssetObjectFieldError::MissingEntityKind); + } + + if entity_id.trim().is_empty() { + return Err(AssetObjectFieldError::MissingEntityId); + } + + if slot.trim().is_empty() { + return Err(AssetObjectFieldError::MissingSlot); + } + + if asset_kind.trim().is_empty() { + return Err(AssetObjectFieldError::MissingAssetKind); + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_asset_object_upsert_input( + asset_object_id: String, + bucket: String, + object_key: String, + access_policy: AssetObjectAccessPolicy, + content_type: Option, + content_length: u64, + content_hash: Option, + asset_kind: String, + source_job_id: Option, + owner_user_id: Option, + profile_id: Option, + entity_id: Option, + updated_at_micros: i64, +) -> Result { + if asset_object_id.trim().is_empty() { + return Err(AssetObjectFieldError::MissingAssetObjectId); + } + + validate_asset_object_fields( + &bucket, + &object_key, + &asset_kind, + INITIAL_ASSET_OBJECT_VERSION, + )?; + + Ok(AssetObjectUpsertInput { + asset_object_id: asset_object_id.trim().to_string(), + bucket: bucket.trim().to_string(), + object_key: object_key.trim().trim_start_matches('/').to_string(), + access_policy, + content_type: normalize_optional_value(content_type), + content_length, + content_hash: normalize_optional_value(content_hash), + version: INITIAL_ASSET_OBJECT_VERSION, + source_job_id: normalize_optional_value(source_job_id), + owner_user_id: normalize_optional_value(owner_user_id), + profile_id: normalize_optional_value(profile_id), + entity_id: normalize_optional_value(entity_id), + asset_kind: asset_kind.trim().to_string(), + updated_at_micros, + }) +} + +pub fn build_asset_object_record(snapshot: AssetObjectUpsertSnapshot) -> AssetObjectRecord { + AssetObjectRecord { + asset_object_id: snapshot.asset_object_id, + bucket: snapshot.bucket, + object_key: snapshot.object_key, + access_policy: snapshot.access_policy, + content_type: snapshot.content_type, + content_length: snapshot.content_length, + content_hash: snapshot.content_hash, + version: snapshot.version, + source_job_id: snapshot.source_job_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + entity_id: snapshot.entity_id, + asset_kind: snapshot.asset_kind, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +#[allow(clippy::too_many_arguments)] +pub fn build_asset_entity_binding_input( + binding_id: String, + asset_object_id: String, + entity_kind: String, + entity_id: String, + slot: String, + asset_kind: String, + owner_user_id: Option, + profile_id: Option, + updated_at_micros: i64, +) -> Result { + validate_asset_entity_binding_fields( + &binding_id, + &asset_object_id, + &entity_kind, + &entity_id, + &slot, + &asset_kind, + )?; + + Ok(AssetEntityBindingInput { + binding_id: binding_id.trim().to_string(), + asset_object_id: asset_object_id.trim().to_string(), + entity_kind: entity_kind.trim().to_string(), + entity_id: entity_id.trim().to_string(), + slot: slot.trim().to_string(), + asset_kind: asset_kind.trim().to_string(), + owner_user_id: normalize_optional_value(owner_user_id), + profile_id: normalize_optional_value(profile_id), + updated_at_micros, + }) +} + +pub fn build_asset_entity_binding_record( + snapshot: AssetEntityBindingSnapshot, +) -> AssetEntityBindingRecord { + AssetEntityBindingRecord { + binding_id: snapshot.binding_id, + asset_object_id: snapshot.asset_object_id, + entity_kind: snapshot.entity_kind, + entity_id: snapshot.entity_id, + slot: snapshot.slot, + asset_kind: snapshot.asset_kind, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub fn generate_asset_object_id(seed_micros: i64) -> String { + format!("{}{:x}", ASSET_OBJECT_ID_PREFIX, seed_micros) +} + +pub fn generate_asset_binding_id(seed_micros: i64) -> String { + format!("{}{:x}", ASSET_BINDING_ID_PREFIX, seed_micros) +} + +pub fn normalize_optional_value(value: Option) -> Option { + value.and_then(|value| { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } + }) +} + +fn format_timestamp_micros(micros: i64) -> String { + let seconds = micros.div_euclid(1_000_000); + let subsec_micros = micros.rem_euclid(1_000_000); + format!("{seconds}.{subsec_micros:06}Z") +} + +impl fmt::Display for AssetObjectFieldError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingBucket => f.write_str("asset_object.bucket 不能为空"), + Self::MissingObjectKey => f.write_str("asset_object.object_key 不能为空"), + Self::MissingAssetKind => f.write_str("asset_object.asset_kind 不能为空"), + Self::MissingAssetObjectId => f.write_str("asset_object.asset_object_id 不能为空"), + Self::MissingBindingId => f.write_str("asset_entity_binding.binding_id 不能为空"), + Self::MissingEntityKind => f.write_str("asset_entity_binding.entity_kind 不能为空"), + Self::MissingEntityId => f.write_str("asset_entity_binding.entity_id 不能为空"), + Self::MissingSlot => f.write_str("asset_entity_binding.slot 不能为空"), + Self::InvalidVersion => f.write_str("asset_object.version 必须大于 0"), + } + } +} + +impl Error for AssetObjectFieldError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_asset_object_fields_accepts_minimal_private_object_contract() { + let result = validate_asset_object_fields( + "xushi-dev", + "generated-characters/hero_001/visual/master.png", + "character_visual", + INITIAL_ASSET_OBJECT_VERSION, + ); + + assert!(result.is_ok()); + } + + #[test] + fn validate_asset_object_fields_rejects_missing_storage_truth() { + let error = validate_asset_object_fields("", " ", "character_visual", 0) + .expect_err("missing bucket/object_key/version should fail"); + + assert_eq!(error, AssetObjectFieldError::MissingBucket); + } + + #[test] + fn access_policy_string_matches_private_bucket_first_contract() { + assert_eq!(AssetObjectAccessPolicy::Private.as_str(), "private"); + assert_eq!(AssetObjectAccessPolicy::PublicRead.as_str(), "public_read"); + } + + #[test] + fn build_asset_object_upsert_input_normalizes_optional_fields() { + let input = build_asset_object_upsert_input( + "assetobj_001".to_string(), + "xushi-dev".to_string(), + "/generated-characters/hero/master.png".to_string(), + AssetObjectAccessPolicy::Private, + Some(" image/png ".to_string()), + 128, + Some(" ".to_string()), + " character_visual ".to_string(), + Some(" job-001 ".to_string()), + None, + Some(" profile_001 ".to_string()), + None, + 1_713_686_400_000_000, + ) + .expect("input should build"); + + assert_eq!(input.object_key, "generated-characters/hero/master.png"); + assert_eq!(input.content_type.as_deref(), Some("image/png")); + assert_eq!(input.content_hash, None); + assert_eq!(input.asset_kind, "character_visual"); + assert_eq!(input.source_job_id.as_deref(), Some("job-001")); + assert_eq!(input.profile_id.as_deref(), Some("profile_001")); + } + + #[test] + fn build_asset_object_record_formats_timestamp_micros_stably() { + let record = build_asset_object_record(AssetObjectUpsertSnapshot { + asset_object_id: "assetobj_001".to_string(), + bucket: "xushi-dev".to_string(), + object_key: "generated-characters/hero/master.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 128, + content_hash: None, + version: INITIAL_ASSET_OBJECT_VERSION, + source_job_id: None, + owner_user_id: None, + profile_id: None, + entity_id: None, + asset_kind: "character_visual".to_string(), + created_at_micros: 1_713_686_400_000_000, + updated_at_micros: 1_713_686_401_234_567, + }); + + assert_eq!(record.created_at, "1713686400.000000Z"); + assert_eq!(record.updated_at, "1713686401.234567Z"); + } + + #[test] + fn build_asset_entity_binding_input_normalizes_binding_fields() { + let input = build_asset_entity_binding_input( + " assetbind_001 ".to_string(), + " assetobj_001 ".to_string(), + " character ".to_string(), + " hero_001 ".to_string(), + " primary_visual ".to_string(), + " character_visual ".to_string(), + Some(" user_001 ".to_string()), + Some(" ".to_string()), + 1_713_686_400_000_000, + ) + .expect("binding input should build"); + + assert_eq!(input.binding_id, "assetbind_001"); + assert_eq!(input.asset_object_id, "assetobj_001"); + assert_eq!(input.entity_kind, "character"); + assert_eq!(input.entity_id, "hero_001"); + assert_eq!(input.slot, "primary_visual"); + assert_eq!(input.asset_kind, "character_visual"); + assert_eq!(input.owner_user_id.as_deref(), Some("user_001")); + assert_eq!(input.profile_id, None); + } + + #[test] + fn validate_asset_entity_binding_fields_rejects_missing_slot() { + let error = validate_asset_entity_binding_fields( + "assetbind_001", + "assetobj_001", + "character", + "hero_001", + " ", + "character_visual", + ) + .expect_err("missing slot should fail"); + + assert_eq!(error, AssetObjectFieldError::MissingSlot); + } +} diff --git a/server-rs/crates/module-assets/src/asset_object_service.rs b/server-rs/crates/module-assets/src/asset_object_service.rs new file mode 100644 index 00000000..d89a633c --- /dev/null +++ b/server-rs/crates/module-assets/src/asset_object_service.rs @@ -0,0 +1,254 @@ +use std::{ + collections::HashMap, + error::Error, + fmt, + sync::{Arc, Mutex}, +}; + +use platform_oss::{OssClient, OssError, OssHeadObjectRequest}; +use reqwest::Client; + +use crate::{ + AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectRecord, AssetObjectUpsertSnapshot, + ConfirmAssetObjectInput, ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION, + build_asset_object_record, build_asset_object_upsert_input, generate_asset_object_id, + normalize_optional_value, validate_asset_object_fields, +}; + +#[derive(Clone, Debug)] +pub struct InMemoryAssetObjectStore { + inner: Arc>>, +} + +#[derive(Clone, Debug)] +pub struct AssetObjectService { + store: InMemoryAssetObjectStore, + http_client: Client, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ConfirmAssetObjectError { + BucketMismatch, + ContentLengthMismatch, + Field(AssetObjectFieldError), + Oss(OssError), + Store(String), +} + +impl Default for InMemoryAssetObjectStore { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl InMemoryAssetObjectStore { + fn upsert_by_location( + &self, + record: AssetObjectUpsertSnapshot, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| ConfirmAssetObjectError::Store("资产对象仓储锁已中毒".to_string()))?; + + let key = (record.bucket.clone(), record.object_key.clone()); + let next_record = match state.get(&key) { + Some(existing) => AssetObjectUpsertSnapshot { + asset_object_id: existing.asset_object_id.clone(), + created_at_micros: existing.created_at_micros, + ..record + }, + None => record, + }; + state.insert(key, next_record.clone()); + + Ok(build_asset_object_record(next_record)) + } +} + +impl AssetObjectService { + pub fn new(store: InMemoryAssetObjectStore) -> Self { + Self { + store, + http_client: Client::new(), + } + } + + pub async fn confirm_object( + &self, + oss_client: &OssClient, + input: ConfirmAssetObjectInput, + ) -> Result { + let configured_bucket = oss_client.config_bucket().to_string(); + let resolved_bucket = input + .bucket + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(configured_bucket.as_str()) + .to_string(); + + if resolved_bucket != configured_bucket { + return Err(ConfirmAssetObjectError::BucketMismatch); + } + + validate_asset_object_fields( + &resolved_bucket, + &input.object_key, + &input.asset_kind, + INITIAL_ASSET_OBJECT_VERSION, + ) + .map_err(ConfirmAssetObjectError::Field)?; + + let head = oss_client + .head_object( + &self.http_client, + OssHeadObjectRequest { + object_key: input.object_key.clone(), + }, + ) + .await + .map_err(ConfirmAssetObjectError::Oss)?; + + if let Some(expected_length) = input.content_length + && expected_length != head.content_length + { + return Err(ConfirmAssetObjectError::ContentLengthMismatch); + } + + // 进程内 store 仅保留给无 SpacetimeDB 配置场景的最小 fallback,因此这里继续使用稳定微秒值表达时间。 + let now_micros = chrono_like_utc_now_micros(); + let upsert_input = build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + resolved_bucket, + head.object_key, + input + .access_policy + .unwrap_or(AssetObjectAccessPolicy::Private), + head.content_type + .or_else(|| normalize_optional_value(input.content_type)), + head.content_length, + normalize_optional_value(input.content_hash), + input.asset_kind, + input.source_job_id, + input.owner_user_id, + input.profile_id, + input.entity_id, + now_micros, + ) + .map_err(ConfirmAssetObjectError::Field)?; + + let record = self.store.upsert_by_location(AssetObjectUpsertSnapshot { + asset_object_id: upsert_input.asset_object_id, + bucket: upsert_input.bucket, + object_key: upsert_input.object_key, + access_policy: upsert_input.access_policy, + content_type: upsert_input.content_type, + content_length: upsert_input.content_length, + content_hash: upsert_input.content_hash, + version: upsert_input.version, + source_job_id: upsert_input.source_job_id, + owner_user_id: upsert_input.owner_user_id, + profile_id: upsert_input.profile_id, + entity_id: upsert_input.entity_id, + asset_kind: upsert_input.asset_kind, + created_at_micros: now_micros, + updated_at_micros: now_micros, + })?; + + Ok(ConfirmAssetObjectResult { record }) + } +} + +impl fmt::Display for ConfirmAssetObjectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BucketMismatch => f.write_str("bucket 与当前服务端 OSS bucket 不一致"), + Self::ContentLengthMismatch => { + f.write_str("客户端声明的 contentLength 与 OSS 实际对象大小不一致") + } + Self::Field(error) => write!(f, "{error}"), + Self::Oss(error) => write!(f, "{error}"), + Self::Store(message) => f.write_str(message), + } + } +} + +impl Error for ConfirmAssetObjectError {} + +impl From for ConfirmAssetObjectError { + fn from(value: AssetObjectFieldError) -> Self { + Self::Field(value) + } +} + +impl From for ConfirmAssetObjectError { + fn from(value: OssError) -> Self { + Self::Oss(value) + } +} + +fn chrono_like_utc_now_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn in_memory_store_upsert_keeps_same_primary_id_for_same_bucket_and_object_key() { + let store = InMemoryAssetObjectStore::default(); + let first = store + .upsert_by_location(AssetObjectUpsertSnapshot { + asset_object_id: "assetobj_first".to_string(), + bucket: "xushi-dev".to_string(), + object_key: "generated-characters/hero/master.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 100, + content_hash: None, + version: INITIAL_ASSET_OBJECT_VERSION, + source_job_id: None, + owner_user_id: None, + profile_id: None, + entity_id: None, + asset_kind: "character_visual".to_string(), + created_at_micros: 1_000_000, + updated_at_micros: 1_000_000, + }) + .expect("first upsert should succeed"); + + let second = store + .upsert_by_location(AssetObjectUpsertSnapshot { + asset_object_id: "assetobj_second".to_string(), + bucket: "xushi-dev".to_string(), + object_key: "generated-characters/hero/master.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 100, + content_hash: None, + version: INITIAL_ASSET_OBJECT_VERSION, + source_job_id: None, + owner_user_id: None, + profile_id: None, + entity_id: None, + asset_kind: "character_visual".to_string(), + created_at_micros: 2_000_000, + updated_at_micros: 2_000_000, + }) + .expect("second upsert should succeed"); + + assert_eq!(first.asset_object_id, "assetobj_first"); + assert_eq!(second.asset_object_id, "assetobj_first"); + assert_eq!(second.created_at, "1.000000Z"); + assert_eq!(second.updated_at, "2.000000Z"); + } +} diff --git a/server-rs/crates/module-assets/src/lib.rs b/server-rs/crates/module-assets/src/lib.rs new file mode 100644 index 00000000..0dcae760 --- /dev/null +++ b/server-rs/crates/module-assets/src/lib.rs @@ -0,0 +1,18 @@ +mod asset_object_core; +#[cfg(feature = "server-service")] +mod asset_object_service; + +pub use asset_object_core::{ + ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput, + AssetEntityBindingProcedureResult, AssetEntityBindingRecord, AssetEntityBindingSnapshot, + AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectProcedureResult, AssetObjectRecord, + AssetObjectUpsertInput, AssetObjectUpsertSnapshot, ConfirmAssetObjectInput, + ConfirmAssetObjectResult, INITIAL_ASSET_OBJECT_VERSION, build_asset_entity_binding_input, + build_asset_entity_binding_record, build_asset_object_record, build_asset_object_upsert_input, + generate_asset_binding_id, generate_asset_object_id, normalize_optional_value, + validate_asset_entity_binding_fields, validate_asset_object_fields, +}; +#[cfg(feature = "server-service")] +pub use asset_object_service::{ + AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore, +}; diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 90fd24ee..ae36cb7d 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -13,6 +13,11 @@ const USERNAME_MIN_LENGTH: usize = 3; const USERNAME_MAX_LENGTH: usize = 24; const PASSWORD_MIN_LENGTH: usize = 6; const PASSWORD_MAX_LENGTH: usize = 128; +const SMS_CODE_LENGTH: usize = 6; +const SMS_MOCK_VERIFY_CODE: &str = "123456"; +const SMS_CODE_TTL_MINUTES: i64 = 5; +const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; +const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; #[derive(Clone, Debug, PartialEq, Eq)] pub enum AuthLoginMethod { @@ -56,6 +61,111 @@ pub struct PasswordEntryResult { pub created: bool, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PhoneAuthScene { + Login, + BindPhone, + ChangePhone, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhoneNumberSnapshot { + pub e164: String, + pub masked_national_number: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SendPhoneCodeInput { + pub phone_number: String, + pub scene: PhoneAuthScene, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SendPhoneCodeResult { + pub cooldown_seconds: u64, + pub expires_in_seconds: u64, + pub provider_request_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhoneLoginInput { + pub phone_number: String, + pub verify_code: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhoneLoginResult { + pub user: AuthUser, + pub created: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatIdentityProfile { + pub provider_uid: String, + pub provider_union_id: Option, + pub display_name: Option, + pub avatar_url: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveWechatLoginInput { + pub profile: WechatIdentityProfile, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveWechatLoginResult { + pub user: AuthUser, + pub created: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WechatAuthScene { + Desktop, + WechatInApp, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateWechatAuthStateInput { + pub redirect_path: String, + pub scene: WechatAuthScene, + pub request_user_agent: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatAuthStateRecord { + pub wechat_state_id: String, + pub state_token: String, + pub redirect_path: String, + pub scene: WechatAuthScene, + pub request_user_agent: Option, + pub expires_at: String, + pub consumed_at: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateWechatAuthStateResult { + pub state: WechatAuthStateRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConsumeWechatAuthStateResult { + pub state: WechatAuthStateRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindWechatPhoneInput { + pub user_id: String, + pub phone_number: String, + pub verify_code: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindWechatPhoneResult { + pub user: AuthUser, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateRefreshSessionInput { pub user_id: String, @@ -124,6 +234,7 @@ pub struct LogoutCurrentSessionInput { pub struct LogoutCurrentSessionResult { pub user: AuthUser, } + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutAllSessionsInput { pub user_id: String, @@ -143,6 +254,32 @@ pub enum PasswordEntryError { PasswordHash(String), } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PhoneAuthError { + InvalidPhoneNumber, + InvalidVerifyCode, + VerifyCodeNotFound, + VerifyCodeExpired, + SendCoolingDown { retry_after_seconds: u64 }, + VerifyAttemptsExceeded, + UserNotFound, + UserStateMismatch, + Store(String), + PasswordHash(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WechatAuthError { + MissingProfile, + StateNotFound, + StateExpired, + StateConsumed, + UserNotFound, + MissingWechatIdentity, + Store(String), + PasswordHash(String), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum RefreshSessionError { MissingToken, @@ -167,14 +304,20 @@ pub struct InMemoryAuthStore { struct InMemoryAuthStoreState { next_user_id: u64, users_by_username: HashMap, + phone_to_user_id: HashMap, sessions_by_id: HashMap, session_id_by_refresh_token_hash: HashMap, + phone_codes_by_key: HashMap, + wechat_states_by_token: HashMap, + wechat_identity_by_provider_uid: HashMap, + user_id_by_provider_union_id: HashMap, } #[derive(Clone, Debug)] struct StoredPasswordUser { user: AuthUser, password_hash: String, + phone_number: Option, } #[derive(Clone, Debug)] @@ -182,6 +325,30 @@ struct StoredRefreshSession { session: RefreshSessionRecord, } +#[derive(Clone, Debug)] +struct StoredPhoneCode { + phone_number: String, + scene: PhoneAuthScene, + verify_code: String, + expires_at: String, + last_sent_at: String, + failed_attempts: u32, +} + +#[derive(Clone, Debug)] +struct StoredWechatAuthState { + state: WechatAuthStateRecord, +} + +#[derive(Clone, Debug)] +struct StoredWechatIdentity { + user_id: String, + provider_uid: String, + provider_union_id: Option, + display_name: Option, + avatar_url: Option, +} + #[derive(Clone, Debug)] pub struct PasswordEntryService { store: InMemoryAuthStore, @@ -198,6 +365,22 @@ pub struct AuthUserService { store: InMemoryAuthStore, } +#[derive(Clone, Debug)] +pub struct PhoneAuthService { + store: InMemoryAuthStore, +} + +#[derive(Clone, Debug)] +pub struct WechatAuthStateService { + store: InMemoryAuthStore, + state_ttl_minutes: u32, +} + +#[derive(Clone, Debug)] +pub struct WechatAuthService { + store: InMemoryAuthStore, +} + impl PasswordEntryService { pub fn new(store: InMemoryAuthStore) -> Self { Self { store } @@ -396,6 +579,217 @@ impl RefreshSessionService { } } +impl PhoneAuthService { + pub fn new(store: InMemoryAuthStore) -> Self { + Self { store } + } + + pub fn send_code( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result { + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + let expires_at = now + .checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES)) + .ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?; + let expires_at = format_rfc3339(expires_at).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码过期时间格式化失败:{message}")) + })?; + + // 当前阶段先冻结 mock 短信行为,只记录验证码快照,不接真实短信供应商。 + self.store.upsert_phone_code( + StoredPhoneCode { + phone_number: normalized_phone.e164.clone(), + scene: input.scene, + verify_code: SMS_MOCK_VERIFY_CODE.to_string(), + expires_at, + last_sent_at: format_rfc3339(now).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) + })?, + failed_attempts: 0, + }, + now, + )?; + + Ok(SendPhoneCodeResult { + cooldown_seconds: SMS_CODE_COOLDOWN_SECONDS, + expires_in_seconds: (SMS_CODE_TTL_MINUTES * 60) as u64, + provider_request_id: None, + }) + } + + pub async fn login( + &self, + input: PhoneLoginInput, + now: OffsetDateTime, + ) -> Result { + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + verify_sms_code_format(&input.verify_code)?; + self.store.consume_phone_code( + &normalized_phone.e164, + &PhoneAuthScene::Login, + input.verify_code.trim(), + now, + )?; + + if let Some(user) = self + .store + .find_by_phone_number(&normalized_phone.e164)? + .map(|stored| stored.user) + { + return Ok(PhoneLoginResult { + user: AuthUser { + login_method: AuthLoginMethod::Phone, + ..user + }, + created: false, + }); + } + + let password_hash = hash_password(&build_random_password_seed()) + .await + .map_err(|error| PhoneAuthError::PasswordHash(error.to_string()))?; + let created_user = self.store.create_phone_user( + normalized_phone.clone(), + normalized_phone.masked_national_number.clone(), + password_hash, + )?; + + Ok(PhoneLoginResult { + user: created_user, + created: true, + }) + } + + pub async fn bind_wechat_phone( + &self, + input: BindWechatPhoneInput, + now: OffsetDateTime, + ) -> Result { + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + verify_sms_code_format(&input.verify_code)?; + self.store.consume_phone_code( + &normalized_phone.e164, + &PhoneAuthScene::BindPhone, + input.verify_code.trim(), + now, + )?; + + let current_user = self + .store + .find_by_user_id(&input.user_id) + .map_err(map_password_error_to_phone_error)? + .ok_or(PhoneAuthError::UserNotFound)?; + if current_user.user.binding_status != AuthBindingStatus::PendingBindPhone { + return Err(PhoneAuthError::UserStateMismatch); + } + if !current_user.user.wechat_bound { + return Err(PhoneAuthError::UserStateMismatch); + } + + let merged_user = self + .store + .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; + + Ok(BindWechatPhoneResult { user: merged_user }) + } +} + +impl WechatAuthStateService { + pub fn new(store: InMemoryAuthStore, state_ttl_minutes: u32) -> Self { + Self { + store, + state_ttl_minutes, + } + } + + pub fn create_state( + &self, + input: CreateWechatAuthStateInput, + now: OffsetDateTime, + ) -> Result { + let created_at = format_rfc3339(now).map_err(|message| { + WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}")) + })?; + let expires_at = now + .checked_add(Duration::minutes(i64::from(self.state_ttl_minutes))) + .ok_or_else(|| WechatAuthError::Store("微信 state 过期时间计算溢出".to_string()))?; + let expires_at = format_rfc3339(expires_at).map_err(|message| { + WechatAuthError::Store(format!("微信 state 过期时间格式化失败:{message}")) + })?; + let state = WechatAuthStateRecord { + wechat_state_id: format!("wxstate_{}", Uuid::new_v4().simple()), + state_token: create_wechat_state_token(), + redirect_path: input.redirect_path.trim().to_string(), + scene: input.scene, + request_user_agent: normalize_optional_string(input.request_user_agent), + expires_at, + consumed_at: None, + created_at: created_at.clone(), + updated_at: created_at, + }; + self.store.insert_wechat_state(state.clone())?; + Ok(CreateWechatAuthStateResult { state }) + } + + pub fn consume_state( + &self, + state_token: &str, + now: OffsetDateTime, + ) -> Result { + let consumed = self.store.consume_wechat_state(state_token, now)?; + Ok(ConsumeWechatAuthStateResult { + state: consumed.state, + }) + } +} + +impl WechatAuthService { + pub fn new(store: InMemoryAuthStore) -> Self { + Self { store } + } + + pub async fn resolve_login( + &self, + input: ResolveWechatLoginInput, + ) -> Result { + if input.profile.provider_uid.trim().is_empty() + && input + .profile + .provider_union_id + .as_ref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(WechatAuthError::MissingProfile); + } + + if let Some(user) = self.store.find_by_wechat_identity( + input.profile.provider_uid.trim(), + input.profile.provider_union_id.as_deref(), + )? { + let refreshed_user = self + .store + .refresh_wechat_identity_profile(&user.id, input.profile)?; + return Ok(ResolveWechatLoginResult { + user: refreshed_user, + created: false, + }); + } + + let password_hash = hash_password(&build_random_password_seed()) + .await + .map_err(|error| WechatAuthError::PasswordHash(error.to_string()))?; + let created_user = self + .store + .create_pending_wechat_user(input.profile, password_hash)?; + Ok(ResolveWechatLoginResult { + user: created_user, + created: true, + }) + } +} + impl AuthUserService { pub fn new(store: InMemoryAuthStore) -> Self { Self { store } @@ -432,6 +826,7 @@ impl AuthUserService { Ok(LogoutCurrentSessionResult { user }) } + // 全端登出需要先吊销该用户全部 refresh session,再统一提升 token_version, // 让所有旧 access token 在下一次鉴权时立即失效。 pub fn logout_all_sessions( @@ -459,8 +854,13 @@ impl Default for InMemoryAuthStore { inner: Arc::new(Mutex::new(InMemoryAuthStoreState { next_user_id: 1, users_by_username: HashMap::new(), + phone_to_user_id: HashMap::new(), sessions_by_id: HashMap::new(), session_id_by_refresh_token_hash: HashMap::new(), + phone_codes_by_key: HashMap::new(), + wechat_states_by_token: HashMap::new(), + wechat_identity_by_provider_uid: HashMap::new(), + user_id_by_provider_union_id: HashMap::new(), })), } } @@ -494,6 +894,24 @@ impl InMemoryAuthStore { .cloned()) } + fn find_by_phone_number( + &self, + phone_number: &str, + ) -> Result, PhoneAuthError> { + let state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + let Some(user_id) = state.phone_to_user_id.get(phone_number) else { + return Ok(None); + }; + Ok(state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == *user_id) + .cloned()) + } + fn create_user( &self, username: String, @@ -526,12 +944,209 @@ impl InMemoryAuthStore { StoredPasswordUser { user: user.clone(), password_hash, + phone_number: None, }, ); Ok(user) } + fn create_phone_user( + &self, + phone_number: PhoneNumberSnapshot, + display_name: String, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + if state.phone_to_user_id.contains_key(&phone_number.e164) { + return Err(PhoneAuthError::Store( + "手机号已存在,无法重复创建账号".to_string(), + )); + } + + let user_id = format!("user_{:08}", state.next_user_id); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + username: username.clone(), + display_name, + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Phone, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + token_version: 1, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + phone_number: Some(phone_number.e164), + }, + ); + + Ok(user) + } + + fn create_pending_wechat_user( + &self, + profile: WechatIdentityProfile, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; + + let user_id = format!("user_{:08}", state.next_user_id); + state.next_user_id += 1; + let username = build_system_username("wechat", state.next_user_id); + let display_name = profile + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("微信旅人") + .to_string(); + let user = AuthUser { + id: user_id.clone(), + username: username.clone(), + display_name, + phone_number_masked: None, + login_method: AuthLoginMethod::Wechat, + binding_status: AuthBindingStatus::PendingBindPhone, + wechat_bound: true, + token_version: 1, + }; + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + phone_number: None, + }, + ); + let identity = StoredWechatIdentity { + user_id: user_id.clone(), + provider_uid: profile.provider_uid.trim().to_string(), + provider_union_id: normalize_optional_string(profile.provider_union_id), + display_name: normalize_optional_string(profile.display_name), + avatar_url: normalize_optional_string(profile.avatar_url), + }; + if let Some(provider_union_id) = identity.provider_union_id.clone() { + state + .user_id_by_provider_union_id + .insert(provider_union_id, user_id.clone()); + } + state + .wechat_identity_by_provider_uid + .insert(identity.provider_uid.clone(), identity); + + Ok(user) + } + + fn find_by_wechat_identity( + &self, + provider_uid: &str, + provider_union_id: Option<&str>, + ) -> Result, WechatAuthError> { + let state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; + + if let Some(provider_union_id) = provider_union_id + .map(str::trim) + .filter(|value| !value.is_empty()) + && let Some(user_id) = state.user_id_by_provider_union_id.get(provider_union_id) + && let Some(stored) = state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == *user_id) + { + return Ok(Some(stored.user.clone())); + } + + let Some(identity) = state + .wechat_identity_by_provider_uid + .get(provider_uid.trim()) + else { + return Ok(None); + }; + Ok(state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == identity.user_id) + .map(|stored| stored.user.clone())) + } + + fn refresh_wechat_identity_profile( + &self, + user_id: &str, + profile: WechatIdentityProfile, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; + + let next_display_name = normalize_optional_string(profile.display_name); + let next_avatar_url = normalize_optional_string(profile.avatar_url); + let next_provider_union_id = normalize_optional_string(profile.provider_union_id); + let next_provider_uid = profile.provider_uid.trim().to_string(); + { + let identity = state + .wechat_identity_by_provider_uid + .remove(profile.provider_uid.trim()) + .or_else(|| { + state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == user_id) + .cloned() + }) + .ok_or(WechatAuthError::MissingWechatIdentity)?; + let mut identity = identity; + // 微信同一 unionid 在不同应用或不同阶段可能回传新的 openid,这里要把最新 provider_uid 回写, + // 否则下一次只能按 unionid 命中,随后刷新资料时会因为旧 openid 不存在而丢失 identity。 + identity.provider_uid = next_provider_uid.clone(); + identity.display_name = next_display_name.clone(); + identity.avatar_url = next_avatar_url; + identity.provider_union_id = next_provider_union_id.clone(); + state + .wechat_identity_by_provider_uid + .insert(next_provider_uid.clone(), identity); + } + if let Some(provider_union_id) = next_provider_union_id { + state + .user_id_by_provider_union_id + .insert(provider_union_id, user_id.to_string()); + } + + let stored_user = state + .users_by_username + .values_mut() + .find(|stored_user| stored_user.user.id == user_id) + .ok_or(WechatAuthError::UserNotFound)?; + if let Some(display_name) = next_display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + && stored_user.user.binding_status == AuthBindingStatus::PendingBindPhone + { + stored_user.user.display_name = display_name.to_string(); + } + + Ok(stored_user.user.clone()) + } + fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> { let mut state = self .inner @@ -558,6 +1173,207 @@ impl InMemoryAuthStore { Ok(()) } + fn upsert_phone_code( + &self, + code: StoredPhoneCode, + now: OffsetDateTime, + ) -> Result<(), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + // 手机号和业务场景共同决定同一份验证码快照,重复发送时直接覆盖旧值。 + let key = build_phone_code_key(&code.phone_number, &code.scene); + if let Some(stored) = state.phone_codes_by_key.get(&key).cloned() { + let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?; + if expires_at > now { + let last_sent_at = parse_phone_code_time(&stored.last_sent_at, "发送时间")?; + let cooling_until = last_sent_at + .checked_add(Duration::seconds(SMS_CODE_COOLDOWN_SECONDS as i64)) + .ok_or_else(|| { + PhoneAuthError::Store("短信验证码冷却时间计算溢出".to_string()) + })?; + if cooling_until > now { + return Err(PhoneAuthError::SendCoolingDown { + retry_after_seconds: seconds_until(now, cooling_until), + }); + } + } + } + state.phone_codes_by_key.insert(key, code); + Ok(()) + } + + fn consume_phone_code( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, + now: OffsetDateTime, + ) -> Result<(), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let key = build_phone_code_key(phone_number, scene); + let stored = state + .phone_codes_by_key + .get(&key) + .cloned() + .ok_or(PhoneAuthError::VerifyCodeNotFound)?; + let expires_at = OffsetDateTime::parse( + &stored.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|error| PhoneAuthError::Store(format!("短信验证码过期时间解析失败:{error}")))?; + if expires_at <= now { + state.phone_codes_by_key.remove(&key); + return Err(PhoneAuthError::VerifyCodeExpired); + } + if stored.verify_code != verify_code.trim() { + let next_failed_attempts = stored.failed_attempts.saturating_add(1); + if next_failed_attempts >= SMS_CODE_MAX_FAILED_ATTEMPTS { + state.phone_codes_by_key.remove(&key); + return Err(PhoneAuthError::VerifyAttemptsExceeded); + } + if let Some(current) = state.phone_codes_by_key.get_mut(&key) { + current.failed_attempts = next_failed_attempts; + } + return Err(PhoneAuthError::InvalidVerifyCode); + } + state.phone_codes_by_key.remove(&key); + Ok(()) + } + + fn insert_wechat_state( + &self, + state_record: WechatAuthStateRecord, + ) -> Result<(), WechatAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + if state + .wechat_states_by_token + .contains_key(&state_record.state_token) + { + return Err(WechatAuthError::Store("微信 state 已存在".to_string())); + } + state.wechat_states_by_token.insert( + state_record.state_token.clone(), + StoredWechatAuthState { + state: state_record, + }, + ); + Ok(()) + } + + fn consume_wechat_state( + &self, + state_token: &str, + now: OffsetDateTime, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + let stored = state + .wechat_states_by_token + .get(state_token.trim()) + .cloned() + .ok_or(WechatAuthError::StateNotFound)?; + if stored.state.consumed_at.is_some() { + return Err(WechatAuthError::StateConsumed); + } + let expires_at = OffsetDateTime::parse( + &stored.state.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|error| WechatAuthError::Store(format!("微信 state 过期时间解析失败:{error}")))?; + if expires_at <= now { + return Err(WechatAuthError::StateExpired); + } + let now_iso = format_rfc3339(now).map_err(|message| { + WechatAuthError::Store(format!("微信 state 时间格式化失败:{message}")) + })?; + let current = state + .wechat_states_by_token + .get_mut(state_token.trim()) + .ok_or(WechatAuthError::StateNotFound)?; + current.state.consumed_at = Some(now_iso.clone()); + current.state.updated_at = now_iso; + Ok(current.clone()) + } + + fn bind_wechat_phone_to_user( + &self, + pending_user_id: &str, + phone_number: PhoneNumberSnapshot, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + + let existing_phone_user_id = state.phone_to_user_id.get(&phone_number.e164).cloned(); + if let Some(target_user_id) = existing_phone_user_id + && target_user_id != pending_user_id + { + let pending_wechat_identity = state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == pending_user_id) + .cloned() + .ok_or(PhoneAuthError::UserStateMismatch)?; + + let pending_username = state + .users_by_username + .values() + .find(|stored| stored.user.id == pending_user_id) + .map(|stored| stored.user.username.clone()) + .ok_or(PhoneAuthError::UserNotFound)?; + state.users_by_username.remove(&pending_username); + + state.wechat_identity_by_provider_uid.insert( + pending_wechat_identity.provider_uid.clone(), + StoredWechatIdentity { + user_id: target_user_id.clone(), + ..pending_wechat_identity.clone() + }, + ); + if let Some(provider_union_id) = pending_wechat_identity.provider_union_id { + state + .user_id_by_provider_union_id + .insert(provider_union_id, target_user_id.clone()); + } + + let target_user = state + .users_by_username + .values_mut() + .find(|stored| stored.user.id == target_user_id) + .ok_or(PhoneAuthError::UserNotFound)?; + target_user.user.wechat_bound = true; + + return Ok(target_user.user.clone()); + } + + state + .phone_to_user_id + .insert(phone_number.e164.clone(), pending_user_id.to_string()); + + let stored_user = state + .users_by_username + .values_mut() + .find(|stored| stored.user.id == pending_user_id) + .ok_or(PhoneAuthError::UserNotFound)?; + stored_user.user.phone_number_masked = Some(phone_number.masked_national_number.clone()); + stored_user.user.binding_status = AuthBindingStatus::Active; + stored_user.user.wechat_bound = true; + stored_user.phone_number = Some(phone_number.e164); + + Ok(stored_user.user.clone()) + } + fn find_session_by_refresh_token_hash( &self, refresh_token_hash: &str, @@ -706,6 +1522,7 @@ impl InMemoryAuthStore { Ok(()) } + fn revoke_all_sessions_by_user_id( &self, user_id: &str, @@ -796,6 +1613,40 @@ impl fmt::Display for PasswordEntryError { impl Error for PasswordEntryError {} +impl fmt::Display for PhoneAuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), + Self::InvalidVerifyCode => f.write_str("验证码错误"), + Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"), + Self::VerifyCodeExpired => f.write_str("验证码已过期"), + Self::SendCoolingDown { .. } => f.write_str("验证码发送过于频繁,请稍后再试"), + Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"), + Self::UserNotFound => f.write_str("用户不存在"), + Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"), + Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), + } + } +} + +impl Error for PhoneAuthError {} + +impl fmt::Display for WechatAuthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingProfile => f.write_str("缺少微信身份信息"), + Self::StateNotFound => f.write_str("微信登录状态已失效,请重新发起登录"), + Self::StateExpired => f.write_str("微信登录状态已过期,请重新发起登录"), + Self::StateConsumed => f.write_str("微信登录状态已被消费,请重新发起登录"), + Self::UserNotFound => f.write_str("用户不存在"), + Self::MissingWechatIdentity => f.write_str("当前账号缺少微信身份"), + Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), + } + } +} + +impl Error for WechatAuthError {} + impl fmt::Display for RefreshSessionError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -833,6 +1684,18 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError { } } +fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthError { + match error { + PasswordEntryError::Store(message) => PhoneAuthError::Store(message), + PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), + PasswordEntryError::InvalidUsername + | PasswordEntryError::InvalidPasswordLength + | PasswordEntryError::InvalidCredentials => { + PhoneAuthError::Store("用户仓储读取失败".to_string()) + } + } +} + fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError { match error { PasswordEntryError::Store(message) => LogoutError::Store(message), @@ -877,6 +1740,106 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> { Ok(()) } +fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> { + let verify_code = verify_code.trim(); + if verify_code.len() != SMS_CODE_LENGTH + || !verify_code + .chars() + .all(|character| character.is_ascii_digit()) + { + return Err(PhoneAuthError::InvalidVerifyCode); + } + + Ok(()) +} + +fn normalize_mainland_china_phone_number( + raw_phone_number: &str, +) -> Result { + let digits = raw_phone_number + .trim() + .chars() + .filter(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 11 || !digits.starts_with('1') { + return Err(PhoneAuthError::InvalidPhoneNumber); + } + + Ok(PhoneNumberSnapshot { + e164: format!("+86{digits}"), + masked_national_number: mask_phone_number(&digits), + }) +} + +fn mask_phone_number(phone_number: &str) -> String { + format!("{}****{}", &phone_number[..3], &phone_number[7..11]) +} + +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|field| { + let trimmed = field.trim().to_string(); + if trimmed.is_empty() { + return None; + } + Some(trimmed) + }) +} + +fn build_random_password_seed() -> String { + format!( + "seed_{}_{}", + Uuid::new_v4().simple(), + Uuid::new_v4().simple() + ) +} + +fn build_system_username(prefix: &str, sequence: u64) -> String { + format!("{prefix}_{sequence:08}") +} + +fn format_rfc3339(value: OffsetDateTime) -> Result { + value + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| error.to_string()) +} + +fn parse_phone_code_time(value: &str, field_label: &str) -> Result { + OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}"))) +} + +fn seconds_until(now: OffsetDateTime, target: OffsetDateTime) -> u64 { + let seconds = target.unix_timestamp().saturating_sub(now.unix_timestamp()); + u64::try_from(seconds.max(1)).unwrap_or(1) +} + +fn build_phone_code_key(phone_number: &str, scene: &PhoneAuthScene) -> String { + format!("{}:{}", phone_number.trim(), scene.as_str()) +} + +fn create_wechat_state_token() -> String { + Uuid::new_v4().simple().to_string() +} + +impl PhoneAuthScene { + pub fn as_str(&self) -> &'static str { + match self { + Self::Login => "login", + Self::BindPhone => "bind_phone", + Self::ChangePhone => "change_phone", + } + } +} + +impl WechatAuthScene { + pub fn as_str(&self) -> &'static str { + match self { + Self::Desktop => "desktop", + Self::WechatInApp => "wechat_in_app", + } + } +} + #[cfg(test)] mod tests { use platform_auth::hash_refresh_session_token; @@ -891,6 +1854,10 @@ mod tests { PasswordEntryService::new(store) } + fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService { + PhoneAuthService::new(store) + } + fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService { RefreshSessionService::new(store, 30) } @@ -996,6 +1963,141 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidUsername); } + #[tokio::test] + async fn phone_send_code_rejects_same_scene_during_cooldown() { + let service = build_phone_service(build_store()); + let now = OffsetDateTime::now_utc(); + + service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .expect("first phone code should send"); + + let error = service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now + Duration::seconds(10), + ) + .expect_err("same scene send should be cooled down"); + + match error { + PhoneAuthError::SendCoolingDown { + retry_after_seconds, + } => assert!((1..=SMS_CODE_COOLDOWN_SECONDS).contains(&retry_after_seconds)), + other => panic!("unexpected phone auth error: {other:?}"), + } + } + + #[tokio::test] + async fn phone_send_code_keeps_different_scenes_isolated() { + let service = build_phone_service(build_store()); + let now = OffsetDateTime::now_utc(); + + service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .expect("login scene code should send"); + let bind_result = service.send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now + Duration::seconds(1), + ); + + assert!(bind_result.is_ok()); + } + + #[tokio::test] + async fn phone_login_expires_code_after_too_many_wrong_attempts() { + let service = build_phone_service(build_store()); + let now = OffsetDateTime::now_utc(); + + service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .expect("phone code should send"); + + for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS { + let error = service + .login( + PhoneLoginInput { + phone_number: "13800138000".to_string(), + verify_code: "000000".to_string(), + }, + now + Duration::seconds(i64::from(attempt)), + ) + .await + .expect_err("wrong code should fail before limit"); + assert_eq!(error, PhoneAuthError::InvalidVerifyCode); + } + + let exhausted_error = service + .login( + PhoneLoginInput { + phone_number: "13800138000".to_string(), + verify_code: "000000".to_string(), + }, + now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS)), + ) + .await + .expect_err("fifth wrong code should exhaust the snapshot"); + assert_eq!(exhausted_error, PhoneAuthError::VerifyAttemptsExceeded); + + let missing_error = service + .login( + PhoneLoginInput { + phone_number: "13800138000".to_string(), + verify_code: SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)), + ) + .await + .expect_err("exhausted snapshot should be deleted"); + assert_eq!(missing_error, PhoneAuthError::VerifyCodeNotFound); + + service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)), + ) + .expect("deleted snapshot should allow a new code"); + let login = service + .login( + PhoneLoginInput { + phone_number: "13800138000".to_string(), + verify_code: SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)), + ) + .await + .expect("new code should login"); + + assert!(login.created); + assert_eq!(login.user.login_method, AuthLoginMethod::Phone); + } + #[tokio::test] async fn refresh_session_creation_and_rotation_keep_same_session_id() { let store = build_store(); @@ -1109,6 +2211,7 @@ mod tests { .expect_err("revoked session should fail"); assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); } + #[tokio::test] async fn logout_all_sessions_revokes_all_sessions_and_increments_token_version_once() { let store = build_store(); @@ -1266,11 +2369,167 @@ mod tests { .expect("sessions should list"); assert_eq!(listed.sessions.len(), 1); - assert_eq!(listed.sessions[0].session_id, active_session.session.session_id); + assert_eq!( + listed.sessions[0].session_id, + active_session.session.session_id + ); assert_eq!(listed.sessions[0].client_info.client_runtime, "chrome"); assert_eq!( listed.sessions[0].client_info.device_display_name, "Windows / Chrome" ); } + + #[tokio::test] + async fn wechat_login_hits_existing_user_by_union_id_before_openid() { + let store = build_store(); + let phone_service = PhoneAuthService::new(store.clone()); + let wechat_service = WechatAuthService::new(store); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .expect("phone code should send"); + let phone_user = phone_service + .login( + PhoneLoginInput { + phone_number: "13800138000".to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("phone login should succeed") + .user; + + let first_wechat = wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-first".to_string(), + provider_union_id: Some("wx-union-shared".to_string()), + display_name: Some("微信旅人甲".to_string()), + avatar_url: None, + }, + }) + .await + .expect("first wechat login should succeed"); + + assert!(first_wechat.created); + assert_eq!( + first_wechat.user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + + let second_wechat = wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-second".to_string(), + provider_union_id: Some("wx-union-shared".to_string()), + display_name: Some("微信旅人乙".to_string()), + avatar_url: None, + }, + }) + .await + .expect("second wechat login should succeed"); + + assert!(!second_wechat.created); + assert_eq!(second_wechat.user.id, first_wechat.user.id); + assert_ne!(second_wechat.user.id, phone_user.id); + assert_eq!(second_wechat.user.login_method, AuthLoginMethod::Wechat); + } + + #[tokio::test] + async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() { + let store = build_store(); + let phone_service = PhoneAuthService::new(store.clone()); + let wechat_service = WechatAuthService::new(store.clone()); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .expect("phone login code should send"); + let phone_user = phone_service + .login( + PhoneLoginInput { + phone_number: "13800138000".to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("phone login should succeed") + .user; + + let wechat_user = wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-bind".to_string(), + provider_union_id: Some("wx-union-bind".to_string()), + display_name: Some("待绑定微信用户".to_string()), + avatar_url: None, + }, + }) + .await + .expect("wechat login should succeed") + .user; + + assert_eq!( + wechat_user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + assert_ne!(wechat_user.id, phone_user.id); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now + Duration::seconds(2), + ) + .expect("bind phone code should send"); + let merged = phone_service + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: wechat_user.id.clone(), + phone_number: "13800138000".to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(3), + ) + .await + .expect("bind phone should succeed"); + + assert_eq!(merged.user.id, phone_user.id); + assert_eq!(merged.user.binding_status, AuthBindingStatus::Active); + assert!(merged.user.wechat_bound); + + let reused_wechat_user = wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-bind".to_string(), + provider_union_id: Some("wx-union-bind".to_string()), + display_name: Some("已归并微信用户".to_string()), + avatar_url: None, + }, + }) + .await + .expect("wechat login should reuse merged user"); + + assert!(!reused_wechat_user.created); + assert_eq!(reused_wechat_user.user.id, phone_user.id); + assert!(reused_wechat_user.user.wechat_bound); + } } diff --git a/server-rs/crates/platform-oss/Cargo.toml b/server-rs/crates/platform-oss/Cargo.toml index 72dc64e2..a8b56162 100644 --- a/server-rs/crates/platform-oss/Cargo.toml +++ b/server-rs/crates/platform-oss/Cargo.toml @@ -7,7 +7,12 @@ license.workspace = true [dependencies] base64 = "0.22" hmac = "0.12" +httpdate = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha1 = "0.10" time = { version = "0.3", features = ["formatting"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt"] } diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index 7656b6c5..5e98938d 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -17,16 +17,23 @@ 1. `PostObject` 浏览器直传签名 2. 旧 `/generated-*` 公开前缀到 OSS `object_key` 的兼容映射 -3. `object_key -> publicUrl` 解析 -4. `x-oss-meta-*` 元数据归一化与大小限制校验 -5. `content-type`、`content-length-range`、`success_action_status` policy 条件生成 +3. 私有对象短期签名读 URL +4. 私有对象 `HEAD Object` 探测 +5. 服务端 `PutObject` 上传 helper +6. `x-oss-meta-*` 元数据归一化与大小限制校验 +7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成 当前仍未落地的内容: -1. `STS` 临时授权 -2. 服务端上传 helper -3. 私有对象签名 URL -4. 对象确认与业务绑定 +1. `STS` 真实临时授权下发 +2. multipart 分片上传 +3. 内容 hash 自动计算与标签写入 + +补充说明: + +1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。 +2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。 +3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。 ## 3. 边界约束 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 47bb730a..a9ac3f53 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -1,7 +1,9 @@ -use std::{collections::BTreeMap, error::Error, fmt}; +use std::{collections::BTreeMap, error::Error, fmt, time::SystemTime}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use hmac::{Hmac, Mac}; +use httpdate::fmt_http_date; +use reqwest::Method; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha1::Sha1; @@ -10,6 +12,7 @@ use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339}; type HmacSha1 = Hmac; pub const DEFAULT_POST_EXPIRE_SECONDS: u64 = 10 * 60; +pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60; pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024; pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200; pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024; @@ -46,7 +49,7 @@ pub struct OssConfig { endpoint: String, access_key_id: String, access_key_secret: String, - public_base_url: Option, + default_read_expire_seconds: u64, default_post_expire_seconds: u64, default_post_max_size_bytes: u64, default_success_action_status: u16, @@ -65,6 +68,28 @@ pub struct OssPostObjectRequest { pub success_action_status: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssSignedGetObjectUrlRequest { + pub object_key: String, + pub expire_seconds: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssHeadObjectRequest { + pub object_key: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssPutObjectRequest { + pub prefix: LegacyAssetPrefix, + pub path_segments: Vec, + pub file_name: String, + pub content_type: Option, + pub access: OssObjectAccess, + pub metadata: BTreeMap, + pub body: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct OssPostObjectResponse { #[serde(rename = "signatureVersion")] @@ -77,8 +102,6 @@ pub struct OssPostObjectResponse { pub object_key: String, #[serde(rename = "legacyPublicPath")] pub legacy_public_path: String, - #[serde(rename = "publicUrl", skip_serializing_if = "Option::is_none")] - pub public_url: Option, #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")] pub content_type: Option, pub access: OssObjectAccess, @@ -94,6 +117,51 @@ pub struct OssPostObjectResponse { pub form_fields: OssPostObjectFormFields, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct OssSignedGetObjectUrlResponse { + pub provider: &'static str, + pub bucket: String, + pub endpoint: String, + pub host: String, + #[serde(rename = "objectKey")] + pub object_key: String, + #[serde(rename = "expiresAt")] + pub expires_at: String, + #[serde(rename = "signedUrl")] + pub signed_url: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OssHeadObjectResponse { + pub bucket: String, + pub object_key: String, + pub content_length: u64, + pub content_type: Option, + pub etag: Option, + pub last_modified: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct OssPutObjectResponse { + pub provider: &'static str, + pub bucket: String, + pub endpoint: String, + pub host: String, + #[serde(rename = "objectKey")] + pub object_key: String, + #[serde(rename = "legacyPublicPath")] + pub legacy_public_path: String, + #[serde(rename = "contentType", skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(rename = "contentLength")] + pub content_length: u64, + pub access: OssObjectAccess, + #[serde(skip_serializing_if = "Option::is_none")] + pub etag: Option, + #[serde(rename = "lastModified", skip_serializing_if = "Option::is_none")] + pub last_modified: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct OssPostObjectFormFields { pub key: String, @@ -119,6 +187,8 @@ pub struct OssClient { pub enum OssError { InvalidConfig(String), InvalidRequest(String), + ObjectNotFound(String), + Request(String), SerializePolicy(String), Sign(String), } @@ -157,6 +227,12 @@ impl LegacyAssetPrefix { pub fn as_public_path_prefix(&self) -> String { format!("/{}", self.as_str()) } + + pub fn from_object_key(raw: &str) -> Option { + let normalized = raw.trim().trim_start_matches('/').trim(); + let prefix = normalized.split('/').next()?; + Self::parse(prefix) + } } impl OssConfig { @@ -166,7 +242,7 @@ impl OssConfig { endpoint: String, access_key_id: String, access_key_secret: String, - public_base_url: Option, + default_read_expire_seconds: u64, default_post_expire_seconds: u64, default_post_max_size_bytes: u64, default_success_action_status: u16, @@ -176,7 +252,12 @@ impl OssConfig { let access_key_id = normalize_required_value(access_key_id, "OSS AccessKeyId 不能为空")?; let access_key_secret = normalize_required_value(access_key_secret, "OSS AccessKeySecret 不能为空")?; - let public_base_url = normalize_optional_base_url(public_base_url); + + if default_read_expire_seconds == 0 { + return Err(OssError::InvalidConfig( + "OSS 私有读签名有效期必须大于 0".to_string(), + )); + } if default_post_expire_seconds == 0 { return Err(OssError::InvalidConfig( @@ -201,7 +282,7 @@ impl OssConfig { endpoint, access_key_id, access_key_secret, - public_base_url, + default_read_expire_seconds, default_post_expire_seconds, default_post_max_size_bytes, default_success_action_status, @@ -219,6 +300,14 @@ impl OssConfig { pub fn bucket(&self) -> &str { &self.bucket } + + pub fn access_key_id(&self) -> &str { + &self.access_key_id + } + + pub fn access_key_secret(&self) -> &str { + &self.access_key_secret + } } impl OssClient { @@ -226,6 +315,10 @@ impl OssClient { Self { config } } + pub fn config_bucket(&self) -> &str { + self.config.bucket() + } + pub fn sign_post_object( &self, request: OssPostObjectRequest, @@ -293,11 +386,6 @@ impl OssClient { let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes()); let signature = sign_policy(&self.config.access_key_secret, &encoded_policy)?; - let public_url = match request.access { - OssObjectAccess::Public => Some(self.public_url_for(&object_key)), - OssObjectAccess::Private => None, - }; - Ok(OssPostObjectResponse { signature_version: "v1", provider: "aliyun-oss", @@ -306,7 +394,6 @@ impl OssClient { host: self.config.upload_host(), object_key: object_key.clone(), legacy_public_path, - public_url, content_type: content_type.clone(), access: request.access, key_prefix: build_key_prefix(request.prefix, &sanitized_segments), @@ -325,14 +412,189 @@ impl OssClient { }) } - fn public_url_for(&self, object_key: &str) -> String { - let base_url = self - .config - .public_base_url - .clone() - .unwrap_or_else(|| self.config.upload_host()); + // 私有 bucket 的对象读取统一走短期签名 URL,避免把长期主凭证下发给浏览器。 + pub fn sign_get_object_url( + &self, + request: OssSignedGetObjectUrlRequest, + ) -> Result { + let expire_seconds = request + .expire_seconds + .unwrap_or(self.config.default_read_expire_seconds); - format!("{}/{}", base_url.trim_end_matches('/'), object_key) + if expire_seconds == 0 { + return Err(OssError::InvalidRequest( + "expireSeconds 必须大于 0".to_string(), + )); + } + + let object_key = normalize_object_key(&request.object_key)?; + let expires_at = OffsetDateTime::now_utc() + .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( + |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), + )?)) + .ok_or_else(|| OssError::InvalidRequest("expireSeconds 计算结果溢出".to_string()))?; + let expires_at_text = expires_at + .format(&Rfc3339) + .map_err(|error| OssError::Sign(format!("格式化过期时间失败:{error}")))?; + let expires_epoch_seconds = expires_at.unix_timestamp(); + + let canonical_resource = build_canonical_object_resource(&self.config.bucket, &object_key); + let string_to_sign = format!("GET\n\n\n{expires_epoch_seconds}\n{canonical_resource}"); + let signature = sign_policy(&self.config.access_key_secret, &string_to_sign)?; + let signed_url = format!( + "{}/{}?OSSAccessKeyId={}&Expires={}&Signature={}", + self.config.upload_host(), + encode_url_path(&object_key), + encode_url_query_value(&self.config.access_key_id), + expires_epoch_seconds, + encode_url_query_value(&signature) + ); + + Ok(OssSignedGetObjectUrlResponse { + provider: "aliyun-oss", + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + host: self.config.upload_host(), + object_key, + expires_at: expires_at_text, + signed_url, + }) + } + + // 上传完成确认前,服务端必须自己探测一次对象,不能只相信客户端回传的 object_key。 + pub async fn head_object( + &self, + client: &reqwest::Client, + request: OssHeadObjectRequest, + ) -> Result { + let object_key = normalize_object_key(&request.object_key)?; + let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) + .map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?; + let response = send_signed_request( + client, + &self.config, + Method::HEAD, + Some(&object_key), + target_url, + ) + .await?; + + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Err(OssError::ObjectNotFound(format!( + "OSS 对象不存在:{}", + request.object_key + ))); + } + + if !response.status().is_success() { + return Err(OssError::Request(format!( + "OSS HEAD Object 失败,状态码:{}", + response.status() + ))); + } + + let headers = response.headers(); + let content_length = headers + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let content_type = headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + let etag = headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim_matches('"').to_string()); + let last_modified = headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + + Ok(OssHeadObjectResponse { + bucket: self.config.bucket.clone(), + object_key, + content_length, + content_type, + etag, + last_modified, + }) + } + + // AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。 + pub async fn put_object( + &self, + client: &reqwest::Client, + request: OssPutObjectRequest, + ) -> Result { + if request.body.is_empty() { + return Err(OssError::InvalidRequest( + "服务端上传对象内容不能为空".to_string(), + )); + } + + let sanitized_segments = request + .path_segments + .iter() + .map(|segment| sanitize_path_segment(segment)) + .filter(|segment| !segment.is_empty()) + .collect::>(); + let file_name = sanitize_file_name(&request.file_name)?; + let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); + let content_type = normalize_optional_value(request.content_type); + let metadata = normalize_metadata(request.metadata)?; + let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key) + .map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?; + let content_length = u64::try_from(request.body.len()) + .map_err(|_| OssError::InvalidRequest("上传对象大小超出可支持范围".to_string()))?; + let builder = signed_request_builder( + client, + &self.config, + Method::PUT, + Some(&object_key), + target_url, + content_type.as_deref(), + &metadata, + )? + .header(reqwest::header::CONTENT_LENGTH, content_length) + .body(request.body); + + let response = builder + .send() + .await + .map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}")))?; + + if !response.status().is_success() { + return Err(OssError::Request(format!( + "OSS PutObject 失败,状态码:{}", + response.status() + ))); + } + + let headers = response.headers(); + let etag = headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim_matches('"').to_string()); + let last_modified = headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + + Ok(OssPutObjectResponse { + provider: "aliyun-oss", + bucket: self.config.bucket.clone(), + endpoint: self.config.endpoint.clone(), + host: self.config.upload_host(), + legacy_public_path: format!("/{object_key}"), + object_key, + content_type, + content_length, + access: request.access, + etag, + last_modified, + }) } } @@ -341,6 +603,8 @@ impl fmt::Display for OssError { match self { Self::InvalidConfig(message) | Self::InvalidRequest(message) + | Self::ObjectNotFound(message) + | Self::Request(message) | Self::SerializePolicy(message) | Self::Sign(message) => f.write_str(message), } @@ -383,6 +647,23 @@ fn build_policy_json( }) } +fn build_object_url( + bucket: &str, + endpoint: &str, + object_key: &str, +) -> Result { + let mut url = reqwest::Url::parse(&format!("https://{bucket}.{endpoint}/")) + .map_err(|error| error.to_string())?; + url = url + .join(object_key.trim_start_matches('/')) + .map_err(|error| error.to_string())?; + Ok(url) +} + +fn build_canonical_object_resource(bucket: &str, object_key: &str) -> String { + format!("/{bucket}/{object_key}") +} + fn build_object_key( prefix: LegacyAssetPrefix, path_segments: &[String], @@ -395,6 +676,42 @@ fn build_object_key( parts.join("/") } +fn normalize_object_key(raw: &str) -> Result { + let normalized = raw.trim().trim_start_matches('/').trim().to_string(); + if normalized.is_empty() { + return Err(OssError::InvalidRequest("objectKey 不能为空".to_string())); + } + + if LegacyAssetPrefix::from_object_key(&normalized).is_none() { + return Err(OssError::InvalidRequest( + "objectKey 必须落在受支持的 generated-* 前缀下".to_string(), + )); + } + + let segments = normalized.split('/').collect::>(); + if segments.len() < 2 { + return Err(OssError::InvalidRequest( + "objectKey 至少需要包含前缀和文件名".to_string(), + )); + } + + for segment in &segments { + if segment.is_empty() || *segment == "." || *segment == ".." { + return Err(OssError::InvalidRequest( + "objectKey 包含非法路径片段".to_string(), + )); + } + + if segment.contains('\\') { + return Err(OssError::InvalidRequest( + "objectKey 不能包含反斜杠".to_string(), + )); + } + } + + Ok(normalized) +} + fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String { let mut parts = Vec::with_capacity(path_segments.len() + 1); parts.push(prefix.as_str().to_string()); @@ -541,10 +858,6 @@ fn normalize_optional_value(value: Option) -> Option { }) } -fn normalize_optional_base_url(value: Option) -> Option { - normalize_optional_value(value).map(|value| value.trim_end_matches('/').to_string()) -} - fn normalize_endpoint(raw: &str) -> Result { let endpoint = raw .trim() @@ -588,6 +901,105 @@ fn sign_policy(access_key_secret: &str, encoded_policy: &str) -> Result, + target_url: reqwest::Url, +) -> Result { + signed_request_builder( + client, + config, + method, + object_key, + target_url, + None, + &BTreeMap::new(), + )? + .send() + .await + .map_err(|error| OssError::Request(format!("请求 OSS 失败:{error}"))) +} + +fn signed_request_builder( + client: &reqwest::Client, + config: &OssConfig, + method: Method, + object_key: Option<&str>, + target_url: reqwest::Url, + content_type: Option<&str>, + oss_headers: &BTreeMap, +) -> Result { + let date = fmt_http_date(SystemTime::now()); + let canonical_resource = match object_key.map(str::trim).filter(|value| !value.is_empty()) { + Some(object_key) => { + build_canonical_object_resource(config.bucket(), object_key.trim_start_matches('/')) + } + None => format!("/{}/", config.bucket()), + }; + let canonicalized_oss_headers = build_canonicalized_oss_headers(oss_headers); + let string_to_sign = format!( + "{}\n\n{}\n{}\n{}{}", + method.as_str(), + content_type.unwrap_or_default(), + date, + canonicalized_oss_headers, + canonical_resource + ); + let signature = sign_policy(config.access_key_secret(), &string_to_sign)?; + let mut builder = client + .request(method, target_url) + .header("Date", date) + .header( + "Authorization", + format!("OSS {}:{}", config.access_key_id(), signature), + ); + + if let Some(content_type) = content_type { + builder = builder.header(reqwest::header::CONTENT_TYPE, content_type); + } + + for (key, value) in oss_headers { + builder = builder.header(key.as_str(), value.as_str()); + } + + Ok(builder) +} + +fn build_canonicalized_oss_headers(headers: &BTreeMap) -> String { + headers + .iter() + .map(|(key, value)| format!("{}:{}\n", key.to_ascii_lowercase(), value.trim())) + .collect::() +} + +fn encode_url_path(path: &str) -> String { + path.split('/') + .map(encode_url_query_value) + .collect::>() + .join("/") +} + +fn encode_url_query_value(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + _ => { + use std::fmt::Write as _; + + let _ = write!(&mut encoded, "%{byte:02X}"); + } + } + } + + encoded +} + #[cfg(test)] mod tests { use super::*; @@ -599,7 +1011,7 @@ mod tests { "oss-cn-shanghai.aliyuncs.com".to_string(), "test-access-key-id".to_string(), "test-access-key-secret".to_string(), - Some("https://cdn.genarrative.local".to_string()), + DEFAULT_READ_EXPIRE_SECONDS, DEFAULT_POST_EXPIRE_SECONDS, DEFAULT_POST_MAX_SIZE_BYTES, DEFAULT_SUCCESS_ACTION_STATUS, @@ -618,7 +1030,7 @@ mod tests { } #[test] - fn sign_post_object_returns_legacy_compatible_key_and_urls() { + fn sign_post_object_returns_bucket_and_object_key_for_private_storage_truth() { let client = build_client(); let mut metadata = BTreeMap::new(); metadata.insert("asset-kind".to_string(), "character-visual".to_string()); @@ -650,12 +1062,7 @@ mod tests { response.legacy_public_path, "/generated-characters/hero_001/visual/asset_01/master.png" ); - assert_eq!( - response.public_url.as_deref(), - Some( - "https://cdn.genarrative.local/generated-characters/hero_001/visual/asset_01/master.png" - ) - ); + assert_eq!(response.bucket, "genarrative-assets".to_string()); assert_eq!( response.form_fields.oss_access_key_id, "test-access-key-id".to_string() @@ -713,7 +1120,7 @@ mod tests { policy["conditions"][4], json!(["eq", "$content-type", "image/png"]) ); - assert!(response.public_url.is_none()); + assert_eq!(response.bucket, "genarrative-assets".to_string()); } #[test] @@ -725,4 +1132,145 @@ mod tests { OssError::InvalidRequest("fileName 不能为空".to_string()) ); } + + #[test] + fn sign_get_object_url_returns_signed_private_read_url() { + let client = build_client(); + + let response = client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: "generated-characters/hero_001/visual/asset_01/master.png".to_string(), + expire_seconds: Some(300), + }) + .expect("signed get url should build"); + + assert_eq!(response.bucket, "genarrative-assets".to_string()); + assert_eq!( + response.object_key, + "generated-characters/hero_001/visual/asset_01/master.png".to_string() + ); + assert!(response + .signed_url + .starts_with("https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/generated-characters/hero_001/visual/asset_01/master.png?")); + assert!( + response + .signed_url + .contains("OSSAccessKeyId=test-access-key-id") + ); + assert!(response.signed_url.contains("&Expires=")); + assert!(response.signed_url.contains("&Signature=")); + } + + #[test] + fn sign_get_object_url_rejects_unsupported_prefix() { + let client = build_client(); + + let error = client + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: "workflow-cache/task-1.json".to_string(), + expire_seconds: Some(300), + }) + .expect_err("unsupported prefix should fail"); + + assert_eq!( + error, + OssError::InvalidRequest("objectKey 必须落在受支持的 generated-* 前缀下".to_string()) + ); + } + + #[test] + fn legacy_prefix_can_be_resolved_from_object_key() { + assert_eq!( + LegacyAssetPrefix::from_object_key( + "generated-custom-world-scenes/profile_01/landmark_01/scene.png" + ), + Some(LegacyAssetPrefix::CustomWorldScenes) + ); + assert_eq!( + LegacyAssetPrefix::from_object_key("workflow-cache/demo.json"), + None + ); + } + + #[test] + fn put_object_request_reuses_generated_object_key_contract() { + let request = OssPutObjectRequest { + prefix: LegacyAssetPrefix::CustomWorldCovers, + path_segments: vec!["Profile 001".to_string(), "asset_01".to_string()], + file_name: "Cover.PNG".to_string(), + content_type: Some(" image/png ".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::from([ + ("asset_kind".to_string(), "custom_world_cover".to_string()), + ("source job id".to_string(), "job_001".to_string()), + ]), + body: b"cover-bytes".to_vec(), + }; + let sanitized_segments = request + .path_segments + .iter() + .map(|segment| sanitize_path_segment(segment)) + .filter(|segment| !segment.is_empty()) + .collect::>(); + let file_name = sanitize_file_name(&request.file_name).expect("file name should sanitize"); + let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); + let metadata = normalize_metadata(request.metadata).expect("metadata should normalize"); + + assert_eq!( + object_key, + "generated-custom-world-covers/profile-001/asset_01/cover.png" + ); + assert_eq!( + metadata.get("x-oss-meta-asset-kind"), + Some(&"custom_world_cover".to_string()) + ); + assert_eq!( + metadata.get("x-oss-meta-source-job-id"), + Some(&"job_001".to_string()) + ); + } + + #[test] + fn canonicalized_oss_headers_matches_oss_v1_upload_signature_shape() { + let headers = BTreeMap::from([ + ( + "x-oss-meta-source-job-id".to_string(), + " job_001 ".to_string(), + ), + ( + "x-oss-meta-asset-kind".to_string(), + "character_visual".to_string(), + ), + ]); + + assert_eq!( + build_canonicalized_oss_headers(&headers), + "x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n" + ); + } + + #[tokio::test] + async fn put_object_rejects_empty_body_before_calling_oss() { + let client = build_client(); + let error = client + .put_object( + &reqwest::Client::new(), + OssPutObjectRequest { + prefix: LegacyAssetPrefix::Characters, + path_segments: vec!["hero".to_string()], + file_name: "master.png".to_string(), + content_type: Some("image/png".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::new(), + body: Vec::new(), + }, + ) + .await + .expect_err("empty server upload should fail before network"); + + assert_eq!( + error, + OssError::InvalidRequest("服务端上传对象内容不能为空".to_string()) + ); + } } diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml new file mode 100644 index 00000000..cf7b5e7d --- /dev/null +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "spacetime-client" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +module-assets = { path = "../module-assets" } +spacetimedb-sdk = "2.1.0" +tokio = { version = "1", features = ["rt", "sync", "time"] } diff --git a/server-rs/crates/spacetime-client/README.md b/server-rs/crates/spacetime-client/README.md index 572fcc33..f47a6153 100644 --- a/server-rs/crates/spacetime-client/README.md +++ b/server-rs/crates/spacetime-client/README.md @@ -12,12 +12,20 @@ ## 2. 当前阶段说明 -当前提交仅完成目录占位,不提前进入 bindings 生成、调用封装与订阅实现。 +当前目录已不再只是占位,当前阶段已经落下: + +1. 通过 `spacetime generate --lang rust --include-private` 生成的最小 Rust bindings +2. `DbConnection` 连接封装 +3. `confirm_asset_object_and_return` procedure 的最小调用适配 +4. `bind_asset_object_to_entity_and_return` procedure 的最小调用适配 +5. `api-server` 所需的 `asset_object` 确认与 `asset_entity_binding` 绑定返回值转换 + +`confirm_asset_object_and_return` 与 `bind_asset_object_to_entity_and_return` 的调用必须等到 SDK `on_connect` 回调后再发起。`DbConnection::build()` 只代表 WebSocket 已经初始化,不代表 SpacetimeDB 身份握手完成;如果过早调用 procedure,本地联调会表现为连接建立但请求长期没有回调,最终等到 idle timeout。 后续与本 package 直接相关的任务包括: -1. 设计 bindings 生成与更新流程 -2. 设计 reducer、view、订阅的统一调用接口 +1. 固化 bindings 生成与更新脚本 +2. 设计 reducer、procedure、view、订阅的统一调用接口 3. 设计身份透传与连接配置策略 4. 设计 Axum / worker / 测试环境下的客户端复用方式 diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs new file mode 100644 index 00000000..22a321d1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -0,0 +1,306 @@ +pub mod module_bindings; + +use std::{ + error::Error, + fmt, + sync::{Arc, Mutex}, + time::Duration, +}; + +use module_assets::{ + AssetEntityBindingRecord, AssetObjectAccessPolicy, AssetObjectRecord, + build_asset_entity_binding_record, build_asset_object_record, +}; +use spacetimedb_sdk::DbContext; +use tokio::{sync::oneshot, time::timeout}; + +use crate::module_bindings::{ + AssetEntityBindingInput as BindingAssetEntityBindingInput, + AssetEntityBindingProcedureResult as BindingAssetEntityBindingProcedureResult, + AssetEntityBindingSnapshot as BindingAssetEntityBindingSnapshot, + AssetObjectProcedureResult as BindingAssetObjectProcedureResult, + AssetObjectUpsertInput as BindingAssetObjectUpsertInput, + AssetObjectUpsertSnapshot as BindingAssetObjectUpsertSnapshot, DbConnection, + bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return as _, + confirm_asset_object_and_return_procedure::confirm_asset_object_and_return as _, +}; + +#[derive(Clone, Debug)] +pub struct SpacetimeClientConfig { + pub server_url: String, + pub database: String, + pub token: Option, +} + +#[derive(Clone, Debug)] +pub struct SpacetimeClient { + config: SpacetimeClientConfig, +} + +#[derive(Debug)] +pub enum SpacetimeClientError { + Build(String), + ConnectDropped, + Procedure(String), + Runtime(String), + Timeout, +} + +const CONFIRM_ASSET_OBJECT_TIMEOUT: Duration = Duration::from_secs(10); + +type ProcedureResultSender = + Arc>>>>; + +impl SpacetimeClient { + pub fn new(config: SpacetimeClientConfig) -> Self { + Self { config } + } + + pub async fn confirm_asset_object( + &self, + input: module_assets::AssetObjectUpsertInput, + ) -> Result { + let procedure_input = map_upsert_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .confirm_asset_object_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn bind_asset_object_to_entity( + &self, + input: module_assets::AssetEntityBindingInput, + ) -> Result { + let procedure_input = map_entity_binding_input(input); + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .bind_asset_object_to_entity_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_entity_binding_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + async fn call_after_connect( + &self, + call: impl FnOnce(&DbConnection, ProcedureResultSender) + Send + 'static, + ) -> Result + where + T: Send + 'static, + { + let config = self.config.clone(); + let (sender, receiver) = oneshot::channel(); + let result_sender = Arc::new(Mutex::new(Some(sender))); + let connect_sender = result_sender.clone(); + let disconnect_sender = result_sender.clone(); + + let connection = tokio::task::spawn_blocking(move || { + DbConnection::builder() + .with_uri(config.server_url) + .with_database_name(config.database) + .with_token(config.token) + .on_connect(move |connection, _, _| { + // SDK 收到 IdentityToken 后才调用 procedure,避免 WebSocket 已建好但身份握手未完成时丢请求。 + call(connection, connect_sender); + }) + .on_disconnect(move |_, error| { + let message = error + .map(|error| error.to_string()) + .unwrap_or_else(|| "SpacetimeDB 连接在 procedure 返回前断开".to_string()); + send_once( + &disconnect_sender, + Err(SpacetimeClientError::Procedure(message)), + ); + }) + .build() + .map_err(|error| SpacetimeClientError::Build(error.to_string())) + }) + .await + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))??; + + let runner = connection.run_threaded(); + let result = timeout(CONFIRM_ASSET_OBJECT_TIMEOUT, receiver).await; + let _ = connection.disconnect(); + // SDK 线程会在断开消息被处理后自行退出;HTTP 请求不能同步等待该线程,否则 Windows 本地联调可能卡在收尾阶段。 + drop(runner); + + result + .map_err(|_| SpacetimeClientError::Timeout)? + .map_err(|_| SpacetimeClientError::ConnectDropped)? + } +} + +fn send_once(sender: &ProcedureResultSender, result: Result) { + if let Some(sender) = sender + .lock() + .expect("spacetime result sender should not poison") + .take() + { + let _ = sender.send(result); + } +} + +fn map_entity_binding_input( + input: module_assets::AssetEntityBindingInput, +) -> BindingAssetEntityBindingInput { + BindingAssetEntityBindingInput { + binding_id: input.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + updated_at_micros: input.updated_at_micros, + } +} + +fn map_upsert_input(input: module_assets::AssetObjectUpsertInput) -> BindingAssetObjectUpsertInput { + BindingAssetObjectUpsertInput { + asset_object_id: input.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: map_access_policy(input.access_policy), + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + updated_at_micros: input.updated_at_micros, + } +} + +fn map_procedure_result( + result: BindingAssetObjectProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回对象快照".to_string()) + })?; + + Ok(build_asset_object_record(map_snapshot(snapshot))) +} + +fn map_entity_binding_procedure_result( + result: BindingAssetEntityBindingProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回绑定快照".to_string()) + })?; + + Ok(build_asset_entity_binding_record( + map_entity_binding_snapshot(snapshot), + )) +} + +fn map_entity_binding_snapshot( + snapshot: BindingAssetEntityBindingSnapshot, +) -> module_assets::AssetEntityBindingSnapshot { + module_assets::AssetEntityBindingSnapshot { + binding_id: snapshot.binding_id, + asset_object_id: snapshot.asset_object_id, + entity_kind: snapshot.entity_kind, + entity_id: snapshot.entity_id, + slot: snapshot.slot, + asset_kind: snapshot.asset_kind, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_snapshot( + snapshot: BindingAssetObjectUpsertSnapshot, +) -> module_assets::AssetObjectUpsertSnapshot { + module_assets::AssetObjectUpsertSnapshot { + asset_object_id: snapshot.asset_object_id, + bucket: snapshot.bucket, + object_key: snapshot.object_key, + access_policy: map_access_policy_back(snapshot.access_policy), + content_type: snapshot.content_type, + content_length: snapshot.content_length, + content_hash: snapshot.content_hash, + version: snapshot.version, + source_job_id: snapshot.source_job_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + entity_id: snapshot.entity_id, + asset_kind: snapshot.asset_kind, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_access_policy( + value: AssetObjectAccessPolicy, +) -> crate::module_bindings::AssetObjectAccessPolicy { + match value { + AssetObjectAccessPolicy::Private => { + crate::module_bindings::AssetObjectAccessPolicy::Private + } + AssetObjectAccessPolicy::PublicRead => { + crate::module_bindings::AssetObjectAccessPolicy::PublicRead + } + } +} + +fn map_access_policy_back( + value: crate::module_bindings::AssetObjectAccessPolicy, +) -> AssetObjectAccessPolicy { + match value { + crate::module_bindings::AssetObjectAccessPolicy::Private => { + AssetObjectAccessPolicy::Private + } + crate::module_bindings::AssetObjectAccessPolicy::PublicRead => { + AssetObjectAccessPolicy::PublicRead + } + } +} + +impl fmt::Display for SpacetimeClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Build(message) | Self::Procedure(message) | Self::Runtime(message) => { + f.write_str(message) + } + Self::ConnectDropped => f.write_str("SpacetimeDB 连接在返回结果前已断开"), + Self::Timeout => f.write_str("SpacetimeDB procedure 调用超时"), + } + } +} + +impl Error for SpacetimeClientError {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_input_type.rs new file mode 100644 index 00000000..10a39937 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetEntityBindingInput { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for AssetEntityBindingInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_procedure_result_type.rs new file mode 100644 index 00000000..1c51596f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetEntityBindingProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for AssetEntityBindingProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_snapshot_type.rs new file mode 100644 index 00000000..d559ea04 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetEntityBindingSnapshot { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for AssetEntityBindingSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_type.rs new file mode 100644 index 00000000..f12154e1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_entity_binding_type.rs @@ -0,0 +1,78 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetEntityBinding { + pub binding_id: String, + pub asset_object_id: String, + pub entity_kind: String, + pub entity_id: String, + pub slot: String, + pub asset_kind: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AssetEntityBinding { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AssetEntityBinding`. +/// +/// Provides typed access to columns for query building. +pub struct AssetEntityBindingCols { + pub binding_id: __sdk::__query_builder::Col, + pub asset_object_id: __sdk::__query_builder::Col, + pub entity_kind: __sdk::__query_builder::Col, + pub entity_id: __sdk::__query_builder::Col, + pub slot: __sdk::__query_builder::Col, + pub asset_kind: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col>, + pub profile_id: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AssetEntityBinding { + type Cols = AssetEntityBindingCols; + fn cols(table_name: &'static str) -> Self::Cols { + AssetEntityBindingCols { + binding_id: __sdk::__query_builder::Col::new(table_name, "binding_id"), + asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"), + entity_kind: __sdk::__query_builder::Col::new(table_name, "entity_kind"), + entity_id: __sdk::__query_builder::Col::new(table_name, "entity_id"), + slot: __sdk::__query_builder::Col::new(table_name, "slot"), + asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `AssetEntityBinding`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AssetEntityBindingIxCols { + pub asset_object_id: __sdk::__query_builder::IxCol, + pub binding_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AssetEntityBinding { + type IxCols = AssetEntityBindingIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AssetEntityBindingIxCols { + asset_object_id: __sdk::__query_builder::IxCol::new(table_name, "asset_object_id"), + binding_id: __sdk::__query_builder::IxCol::new(table_name, "binding_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AssetEntityBinding {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_access_policy_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_access_policy_type.rs new file mode 100644 index 00000000..17d02930 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_access_policy_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum AssetObjectAccessPolicy { + Private, + + PublicRead, +} + +impl __sdk::InModule for AssetObjectAccessPolicy { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_procedure_result_type.rs new file mode 100644 index 00000000..9ccf22ed --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for AssetObjectProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_type.rs new file mode 100644 index 00000000..c9d57ac5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_type.rs @@ -0,0 +1,95 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_access_policy_type::AssetObjectAccessPolicy; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObject { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: AssetObjectAccessPolicy, + pub content_type: Option, + pub content_length: u64, + pub content_hash: Option, + pub version: u32, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, + pub asset_kind: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AssetObject { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AssetObject`. +/// +/// Provides typed access to columns for query building. +pub struct AssetObjectCols { + pub asset_object_id: __sdk::__query_builder::Col, + pub bucket: __sdk::__query_builder::Col, + pub object_key: __sdk::__query_builder::Col, + pub access_policy: __sdk::__query_builder::Col, + pub content_type: __sdk::__query_builder::Col>, + pub content_length: __sdk::__query_builder::Col, + pub content_hash: __sdk::__query_builder::Col>, + pub version: __sdk::__query_builder::Col, + pub source_job_id: __sdk::__query_builder::Col>, + pub owner_user_id: __sdk::__query_builder::Col>, + pub profile_id: __sdk::__query_builder::Col>, + pub entity_id: __sdk::__query_builder::Col>, + pub asset_kind: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AssetObject { + type Cols = AssetObjectCols; + fn cols(table_name: &'static str) -> Self::Cols { + AssetObjectCols { + asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"), + bucket: __sdk::__query_builder::Col::new(table_name, "bucket"), + object_key: __sdk::__query_builder::Col::new(table_name, "object_key"), + access_policy: __sdk::__query_builder::Col::new(table_name, "access_policy"), + content_type: __sdk::__query_builder::Col::new(table_name, "content_type"), + content_length: __sdk::__query_builder::Col::new(table_name, "content_length"), + content_hash: __sdk::__query_builder::Col::new(table_name, "content_hash"), + version: __sdk::__query_builder::Col::new(table_name, "version"), + source_job_id: __sdk::__query_builder::Col::new(table_name, "source_job_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + entity_id: __sdk::__query_builder::Col::new(table_name, "entity_id"), + asset_kind: __sdk::__query_builder::Col::new(table_name, "asset_kind"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `AssetObject`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AssetObjectIxCols { + pub asset_kind: __sdk::__query_builder::IxCol, + pub asset_object_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AssetObject { + type IxCols = AssetObjectIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AssetObjectIxCols { + asset_kind: __sdk::__query_builder::IxCol::new(table_name, "asset_kind"), + asset_object_id: __sdk::__query_builder::IxCol::new(table_name, "asset_object_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AssetObject {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_upsert_input_type.rs new file mode 100644 index 00000000..85fcadc6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_upsert_input_type.rs @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_access_policy_type::AssetObjectAccessPolicy; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectUpsertInput { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: AssetObjectAccessPolicy, + pub content_type: Option, + pub content_length: u64, + pub content_hash: Option, + pub version: u32, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, + pub asset_kind: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for AssetObjectUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_upsert_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_upsert_snapshot_type.rs new file mode 100644 index 00000000..b349f18d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_upsert_snapshot_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_access_policy_type::AssetObjectAccessPolicy; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectUpsertSnapshot { + pub asset_object_id: String, + pub bucket: String, + pub object_key: String, + pub access_policy: AssetObjectAccessPolicy, + pub content_type: Option, + pub content_length: u64, + pub content_hash: Option, + pub version: u32, + pub source_job_id: Option, + pub owner_user_id: Option, + pub profile_id: Option, + pub entity_id: Option, + pub asset_kind: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for AssetObjectUpsertSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs new file mode 100644 index 00000000..b709d5c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_entity_binding_input_type::AssetEntityBindingInput; +use super::asset_entity_binding_procedure_result_type::AssetEntityBindingProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct BindAssetObjectToEntityAndReturnArgs { + pub input: AssetEntityBindingInput, +} + +impl __sdk::InModule for BindAssetObjectToEntityAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `bind_asset_object_to_entity_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait bind_asset_object_to_entity_and_return { + fn bind_asset_object_to_entity_and_return(&self, input: AssetEntityBindingInput) { + self.bind_asset_object_to_entity_and_return_then(input, |_, _| {}); + } + + fn bind_asset_object_to_entity_and_return_then( + &self, + input: AssetEntityBindingInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl bind_asset_object_to_entity_and_return for super::RemoteProcedures { + fn bind_asset_object_to_entity_and_return_then( + &self, + input: AssetEntityBindingInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetEntityBindingProcedureResult>( + "bind_asset_object_to_entity_and_return", + BindAssetObjectToEntityAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs new file mode 100644 index 00000000..b20bc5b2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bind_asset_object_to_entity_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_entity_binding_input_type::AssetEntityBindingInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct BindAssetObjectToEntityArgs { + pub input: AssetEntityBindingInput, +} + +impl From for super::Reducer { + fn from(args: BindAssetObjectToEntityArgs) -> Self { + Self::BindAssetObjectToEntity { input: args.input } + } +} + +impl __sdk::InModule for BindAssetObjectToEntityArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `bind_asset_object_to_entity`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait bind_asset_object_to_entity { + /// Request that the remote module invoke the reducer `bind_asset_object_to_entity` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`bind_asset_object_to_entity:bind_asset_object_to_entity_then`] to run a callback after the reducer completes. + fn bind_asset_object_to_entity(&self, input: AssetEntityBindingInput) -> __sdk::Result<()> { + self.bind_asset_object_to_entity_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `bind_asset_object_to_entity` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn bind_asset_object_to_entity_then( + &self, + input: AssetEntityBindingInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl bind_asset_object_to_entity for super::RemoteReducers { + fn bind_asset_object_to_entity_then( + &self, + input: AssetEntityBindingInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(BindAssetObjectToEntityArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs new file mode 100644 index 00000000..0b4f26b2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_procedure_result_type::AssetObjectProcedureResult; +use super::asset_object_upsert_input_type::AssetObjectUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ConfirmAssetObjectAndReturnArgs { + pub input: AssetObjectUpsertInput, +} + +impl __sdk::InModule for ConfirmAssetObjectAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `confirm_asset_object_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait confirm_asset_object_and_return { + fn confirm_asset_object_and_return(&self, input: AssetObjectUpsertInput) { + self.confirm_asset_object_and_return_then(input, |_, _| {}); + } + + fn confirm_asset_object_and_return_then( + &self, + input: AssetObjectUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl confirm_asset_object_and_return for super::RemoteProcedures { + fn confirm_asset_object_and_return_then( + &self, + input: AssetObjectUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( + "confirm_asset_object_and_return", + ConfirmAssetObjectAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs new file mode 100644 index 00000000..183c2efa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/confirm_asset_object_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_input_type::AssetObjectUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct ConfirmAssetObjectArgs { + pub input: AssetObjectUpsertInput, +} + +impl From for super::Reducer { + fn from(args: ConfirmAssetObjectArgs) -> Self { + Self::ConfirmAssetObject { input: args.input } + } +} + +impl __sdk::InModule for ConfirmAssetObjectArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `confirm_asset_object`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait confirm_asset_object { + /// Request that the remote module invoke the reducer `confirm_asset_object` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`confirm_asset_object:confirm_asset_object_then`] to run a callback after the reducer completes. + fn confirm_asset_object(&self, input: AssetObjectUpsertInput) -> __sdk::Result<()> { + self.confirm_asset_object_then(input, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `confirm_asset_object` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn confirm_asset_object_then( + &self, + input: AssetObjectUpsertInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl confirm_asset_object for super::RemoteReducers { + fn confirm_asset_object_then( + &self, + input: AssetObjectUpsertInput, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(ConfirmAssetObjectArgs { input }, callback) + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs new file mode 100644 index 00000000..961c26d0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -0,0 +1,823 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +pub mod asset_entity_binding_input_type; +pub mod asset_entity_binding_procedure_result_type; +pub mod asset_entity_binding_snapshot_type; +pub mod asset_entity_binding_type; +pub mod asset_object_access_policy_type; +pub mod asset_object_procedure_result_type; +pub mod asset_object_type; +pub mod asset_object_upsert_input_type; +pub mod asset_object_upsert_snapshot_type; +pub mod bind_asset_object_to_entity_and_return_procedure; +pub mod bind_asset_object_to_entity_reducer; +pub mod confirm_asset_object_and_return_procedure; +pub mod confirm_asset_object_reducer; + +pub use asset_entity_binding_input_type::AssetEntityBindingInput; +pub use asset_entity_binding_procedure_result_type::AssetEntityBindingProcedureResult; +pub use asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot; +pub use asset_entity_binding_type::AssetEntityBinding; +pub use asset_object_access_policy_type::AssetObjectAccessPolicy; +pub use asset_object_procedure_result_type::AssetObjectProcedureResult; +pub use asset_object_type::AssetObject; +pub use asset_object_upsert_input_type::AssetObjectUpsertInput; +pub use asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; +pub use bind_asset_object_to_entity_reducer::bind_asset_object_to_entity; +pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; +pub use confirm_asset_object_reducer::confirm_asset_object; + +#[derive(Clone, PartialEq, Debug)] + +/// One of the reducers defined by this module. +/// +/// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events +/// to indicate which reducer caused the event. + +pub enum Reducer { + BindAssetObjectToEntity { input: AssetEntityBindingInput }, + ConfirmAssetObject { input: AssetObjectUpsertInput }, +} + +impl __sdk::InModule for Reducer { + type Module = RemoteModule; +} + +impl __sdk::Reducer for Reducer { + fn reducer_name(&self) -> &'static str { + match self { + Reducer::BindAssetObjectToEntity { .. } => "bind_asset_object_to_entity", + Reducer::ConfirmAssetObject { .. } => "confirm_asset_object", + _ => unreachable!(), + } + } + #[allow(clippy::clone_on_copy)] + fn args_bsatn(&self) -> Result, __sats::bsatn::EncodeError> { + match self { + Reducer::BindAssetObjectToEntity { input } => __sats::bsatn::to_vec( + &bind_asset_object_to_entity_reducer::BindAssetObjectToEntityArgs { + input: input.clone(), + }, + ), + Reducer::ConfirmAssetObject { input } => { + __sats::bsatn::to_vec(&confirm_asset_object_reducer::ConfirmAssetObjectArgs { + input: input.clone(), + }) + } + _ => unreachable!(), + } + } +} + +#[derive(Default, Debug)] +#[allow(non_snake_case)] +#[doc(hidden)] +pub struct DbUpdate {} + +impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { + type Error = __sdk::Error; + fn try_from(raw: __ws::v2::TransactionUpdate) -> Result { + let mut db_update = DbUpdate::default(); + for table_update in __sdk::transaction_update_iter_table_updates(raw) { + match &table_update.table_name[..] { + unknown => { + return Err(__sdk::InternalError::unknown_name( + "table", + unknown, + "DatabaseUpdate", + ) + .into()); + } + } + } + Ok(db_update) + } +} + +impl __sdk::InModule for DbUpdate { + type Module = RemoteModule; +} + +impl __sdk::DbUpdate for DbUpdate { + fn apply_to_client_cache( + &self, + cache: &mut __sdk::ClientCache, + ) -> AppliedDiff<'_> { + let mut diff = AppliedDiff::default(); + + diff + } + fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { + let mut db_update = DbUpdate::default(); + for table_rows in raw.tables { + match &table_rows.table[..] { + unknown => { + return Err( + __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), + ); + } + } + } + Ok(db_update) + } + fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { + let mut db_update = DbUpdate::default(); + for table_rows in raw.tables { + match &table_rows.table[..] { + unknown => { + return Err( + __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), + ); + } + } + } + Ok(db_update) + } +} + +#[derive(Default)] +#[allow(non_snake_case)] +#[doc(hidden)] +pub struct AppliedDiff<'r> { + __unused: std::marker::PhantomData<&'r ()>, +} + +impl __sdk::InModule for AppliedDiff<'_> { + type Module = RemoteModule; +} + +impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { + fn invoke_row_callbacks( + &self, + event: &EventContext, + callbacks: &mut __sdk::DbCallbacks, + ) { + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct RemoteModule; + +impl __sdk::InModule for RemoteModule { + type Module = Self; +} + +/// The `reducers` field of [`EventContext`] and [`DbConnection`], +/// with methods provided by extension traits for each reducer defined by the module. +pub struct RemoteReducers { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteReducers { + type Module = RemoteModule; +} + +/// The `procedures` field of [`DbConnection`] and other [`DbContext`] types, +/// with methods provided by extension traits for each procedure defined by the module. +pub struct RemoteProcedures { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteProcedures { + type Module = RemoteModule; +} + +/// The `db` field of [`EventContext`] and [`DbConnection`], +/// with methods provided by extension traits for each table defined by the module. +pub struct RemoteTables { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteTables { + type Module = RemoteModule; +} + +/// A connection to a remote module, including a materialized view of a subset of the database. +/// +/// Connect to a remote module by calling [`DbConnection::builder`] +/// and using the [`__sdk::DbConnectionBuilder`] builder-pattern constructor. +/// +/// You must explicitly advance the connection by calling any one of: +/// +/// - [`DbConnection::frame_tick`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr( + target_arch = "wasm32", + doc = "- [`DbConnection::run_background_task`]." +)] +/// - [`DbConnection::run_async`]. +/// - [`DbConnection::advance_one_message`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] +/// - [`DbConnection::advance_one_message_async`]. +/// +/// Which of these methods you should call depends on the specific needs of your application, +/// but you must call one of them, or else the connection will never progress. +pub struct DbConnection { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + #[doc(hidden)] + + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for DbConnection { + type Module = RemoteModule; +} + +impl __sdk::DbContext for DbConnection { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl DbConnection { + /// Builder-pattern constructor for a connection to a remote module. + /// + /// See [`__sdk::DbConnectionBuilder`] for required and optional configuration for the new connection. + pub fn builder() -> __sdk::DbConnectionBuilder { + __sdk::DbConnectionBuilder::new() + } + + /// If any WebSocket messages are waiting, process one of them. + /// + /// Returns `true` if a message was processed, or `false` if the queue is empty. + /// Callers should invoke this message in a loop until it returns `false` + /// or for as much time is available to process messages. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::frame_tick`] each frame + /// to fully exhaust the queue whenever time is available. + pub fn advance_one_message(&self) -> __sdk::Result { + self.imp.advance_one_message() + } + + /// Process one WebSocket message, potentially blocking the current thread until one is received. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::run_threaded`] to spawn a thread + /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] + pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { + self.imp.advance_one_message_blocking() + } + + /// Process one WebSocket message, `await`ing until one is received. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::run_async`] to run an `async` loop + /// which advances the connection when polled. + pub async fn advance_one_message_async(&self) -> __sdk::Result<()> { + self.imp.advance_one_message_async().await + } + + /// Process all WebSocket messages waiting in the queue, + /// then return without `await`ing or blocking the current thread. + pub fn frame_tick(&self) -> __sdk::Result<()> { + self.imp.frame_tick() + } + + /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] + pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { + self.imp.run_threaded() + } + + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + + /// Run an `async` loop which processes WebSocket messages when polled. + pub async fn run_async(&self) -> __sdk::Result<()> { + self.imp.run_async().await + } +} + +impl __sdk::DbConnection for DbConnection { + fn new(imp: __sdk::DbContextImpl) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +/// A handle on a subscribed query. +// TODO: Document this better after implementing the new subscription API. +#[derive(Clone)] +pub struct SubscriptionHandle { + imp: __sdk::SubscriptionHandleImpl, +} + +impl __sdk::InModule for SubscriptionHandle { + type Module = RemoteModule; +} + +impl __sdk::SubscriptionHandle for SubscriptionHandle { + fn new(imp: __sdk::SubscriptionHandleImpl) -> Self { + Self { imp } + } + + /// Returns true if this subscription has been terminated due to an unsubscribe call or an error. + fn is_ended(&self) -> bool { + self.imp.is_ended() + } + + /// Returns true if this subscription has been applied and has not yet been unsubscribed. + fn is_active(&self) -> bool { + self.imp.is_active() + } + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + fn unsubscribe_then(self, on_end: __sdk::OnEndedCallback) -> __sdk::Result<()> { + self.imp.unsubscribe_then(Some(on_end)) + } + + fn unsubscribe(self) -> __sdk::Result<()> { + self.imp.unsubscribe_then(None) + } +} + +/// Alias trait for a [`__sdk::DbContext`] connected to this module, +/// with that trait's associated types bounded to this module's concrete types. +/// +/// Users can use this trait as a boundary on definitions which should accept +/// either a [`DbConnection`] or an [`EventContext`] and operate on either. +pub trait RemoteDbContext: + __sdk::DbContext< + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, +> +{ +} +impl< + Ctx: __sdk::DbContext< + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + >, + > RemoteDbContext for Ctx +{ +} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::Event`], +/// passed to [`__sdk::Table::on_insert`], [`__sdk::Table::on_delete`] and [`__sdk::TableWithPrimaryKey::on_update`] callbacks. +pub struct EventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: __sdk::Event, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for EventContext { + type Event = __sdk::Event; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for EventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for EventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::EventContext for EventContext {} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::ReducerEvent`], +/// passed to on-reducer callbacks. +pub struct ReducerEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: __sdk::ReducerEvent, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ReducerEventContext { + type Event = __sdk::ReducerEvent; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for ReducerEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ReducerEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ReducerEventContext for ReducerEventContext {} + +/// An [`__sdk::DbContext`] passed to procedure callbacks. +pub struct ProcedureEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ProcedureEventContext { + type Event = (); + fn event(&self) -> &Self::Event { + &() + } + fn new(imp: __sdk::DbContextImpl, _event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +impl __sdk::InModule for ProcedureEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ProcedureEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ProcedureEventContext for ProcedureEventContext {} + +/// An [`__sdk::DbContext`] passed to [`__sdk::SubscriptionBuilder::on_applied`] and [`SubscriptionHandle::unsubscribe_then`] callbacks. +pub struct SubscriptionEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for SubscriptionEventContext { + type Event = (); + fn event(&self) -> &Self::Event { + &() + } + fn new(imp: __sdk::DbContextImpl, _event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +impl __sdk::InModule for SubscriptionEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for SubscriptionEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::SubscriptionEventContext for SubscriptionEventContext {} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::Error`], +/// passed to [`__sdk::DbConnectionBuilder::on_disconnect`], [`__sdk::DbConnectionBuilder::on_connect_error`] and [`__sdk::SubscriptionBuilder::on_error`] callbacks. +pub struct ErrorContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: Option<__sdk::Error>, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ErrorContext { + type Event = Option<__sdk::Error>; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for ErrorContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ErrorContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ErrorContext for ErrorContext {} + +impl __sdk::SpacetimeModule for RemoteModule { + type DbConnection = DbConnection; + type EventContext = EventContext; + type ReducerEventContext = ReducerEventContext; + type ProcedureEventContext = ProcedureEventContext; + type SubscriptionEventContext = SubscriptionEventContext; + type ErrorContext = ErrorContext; + type Reducer = Reducer; + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + type DbUpdate = DbUpdate; + type AppliedDiff<'r> = AppliedDiff<'r>; + type SubscriptionHandle = SubscriptionHandle; + type QueryBuilder = __sdk::QueryBuilder; + + fn register_tables(client_cache: &mut __sdk::ClientCache) {} + const ALL_TABLE_NAMES: &'static [&'static str] = &[]; +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml new file mode 100644 index 00000000..578db830 --- /dev/null +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "spacetime-module" +edition.workspace = true +version.workspace = true +license.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +log = { workspace = true } +module-assets = { path = "../module-assets", default-features = false, features = ["spacetime-types"] } +spacetimedb = { workspace = true, features = ["unstable"] } diff --git a/server-rs/crates/spacetime-module/README.md b/server-rs/crates/spacetime-module/README.md index 346e7ce1..fedca342 100644 --- a/server-rs/crates/spacetime-module/README.md +++ b/server-rs/crates/spacetime-module/README.md @@ -14,14 +14,29 @@ ## 2. 当前阶段说明 -当前阶段仍未进入具体 schema 与 reducer 实现,但已经补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口固定下来。 +当前阶段已落下第一批真实 schema 骨架,并已补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口与首版资产对象表固定下来。 后续与本 crate 直接相关的任务包括: -1. 建立模块聚合入口 -2. 设计表、reducer、view 的聚合方式 +1. 继续扩充模块聚合入口 +2. 继续设计表、reducer、view 的聚合方式 3. 接入身份 claims 透传 -4. 在实体 module scaffold 落地后接入 publish / dev 循环 +4. 在当前 scaffold 基础上接入 publish / dev 循环 + +当前已落地: + +1. `spacetime-module` 真实 `cdylib` crate scaffold +2. `asset_object` 首版表骨架 +3. `bucket + object_key` 双列对象定位索引 +4. `module-assets` 的访问策略与字段校验类型接入 +5. 面向 Axum 的 `asset_object` 确认持久化入口 +6. `asset_entity_binding` 通用绑定表 +7. 面向 Axum 的 `bind_asset_object_to_entity_and_return` 绑定 procedure + +`asset_object` 的详细设计见: + +1. [../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md) +2. [../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](../../../docs/technical/ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md) 当前身份透传设计依据: @@ -30,7 +45,7 @@ 当前本地开发脚本约定: 1. `../../scripts/spacetime-dev.ps1` 与 `../../scripts/spacetime-dev.sh` 当前固定执行 `spacetime start` 的 standalone 模式。 -2. 默认监听 `127.0.0.1:3001`,避免与 `api-server` 默认 `3000` 端口冲突。 +2. 默认监听 `127.0.0.1:3000`,与 `spacetime` CLI 的 `local` server 默认口径保持一致。 3. 本地数据目录固定到 `server-rs/.spacetimedb/local`,避免污染全局 SpacetimeDB 根目录。 4. 当前阶段暂不自动 publish `crates/spacetime-module`,待 module 实体 scaffold 与聚合入口落地后再扩展。 diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs new file mode 100644 index 00000000..16631139 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -0,0 +1,327 @@ +use module_assets::{ + ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingInput, + AssetEntityBindingProcedureResult, AssetEntityBindingSnapshot, AssetObjectAccessPolicy, + AssetObjectProcedureResult, AssetObjectUpsertInput, AssetObjectUpsertSnapshot, + INITIAL_ASSET_OBJECT_VERSION, validate_asset_entity_binding_fields, + validate_asset_object_fields, +}; +use spacetimedb::{ProcedureContext, ReducerContext, Table, Timestamp}; + +#[spacetimedb::table( + accessor = asset_object, + index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key])) +)] +pub struct AssetObject { + #[primary_key] + asset_object_id: String, + // 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。 + bucket: String, + object_key: String, + access_policy: AssetObjectAccessPolicy, + content_type: Option, + content_length: u64, + content_hash: Option, + version: u32, + source_job_id: Option, + owner_user_id: Option, + profile_id: Option, + entity_id: Option, + #[index(btree)] + asset_kind: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = asset_entity_binding, + index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])), + index(accessor = by_asset_object_id, btree(columns = [asset_object_id])) +)] +pub struct AssetEntityBinding { + #[primary_key] + binding_id: String, + asset_object_id: String, + entity_kind: String, + entity_id: String, + slot: String, + asset_kind: String, + owner_user_id: Option, + profile_id: Option, + created_at: Timestamp, + updated_at: Timestamp, +} + +// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。 +#[spacetimedb::reducer(init)] +pub fn init(_ctx: &ReducerContext) { + log::info!( + "spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,默认对象 ID 前缀={},默认绑定 ID 前缀={},初始版本={}", + ASSET_OBJECT_ID_PREFIX, + ASSET_BINDING_ID_PREFIX, + INITIAL_ASSET_OBJECT_VERSION + ); +} + +// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。 +#[spacetimedb::reducer] +pub fn confirm_asset_object( + ctx: &ReducerContext, + input: AssetObjectUpsertInput, +) -> Result<(), String> { + upsert_asset_object(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。 +#[spacetimedb::procedure] +pub fn confirm_asset_object_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectUpsertInput, +) -> AssetObjectProcedureResult { + match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。 +#[spacetimedb::reducer] +pub fn bind_asset_object_to_entity( + ctx: &ReducerContext, + input: AssetEntityBindingInput, +) -> Result<(), String> { + upsert_asset_entity_binding(ctx, input).map(|_| ()) +} + +// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。 +#[spacetimedb::procedure] +pub fn bind_asset_object_to_entity_and_return( + ctx: &mut ProcedureContext, + input: AssetEntityBindingInput, +) -> AssetEntityBindingProcedureResult { + match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) { + Ok(record) => AssetEntityBindingProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => AssetEntityBindingProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +fn upsert_asset_object( + ctx: &ReducerContext, + input: AssetObjectUpsertInput, +) -> Result { + validate_asset_object_fields( + &input.bucket, + &input.object_key, + &input.asset_kind, + input.version, + ) + .map_err(|error| error.to_string())?; + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + // 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。 + let current = ctx + .db + .asset_object() + .iter() + .find(|row| row.bucket == input.bucket && row.object_key == input.object_key); + + let snapshot = match current { + Some(existing) => { + ctx.db + .asset_object() + .asset_object_id() + .delete(&existing.asset_object_id); + let row = AssetObject { + asset_object_id: existing.asset_object_id.clone(), + bucket: input.bucket.clone(), + object_key: input.object_key.clone(), + access_policy: input.access_policy, + content_type: input.content_type.clone(), + content_length: input.content_length, + content_hash: input.content_hash.clone(), + version: input.version, + source_job_id: input.source_job_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + entity_id: input.entity_id.clone(), + asset_kind: input.asset_kind.clone(), + created_at: existing.created_at, + updated_at, + }; + ctx.db.asset_object().insert(row); + + AssetObjectUpsertSnapshot { + asset_object_id: existing.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: input.access_policy, + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: input.updated_at_micros, + } + } + None => { + let created_at = updated_at; + let row = AssetObject { + asset_object_id: input.asset_object_id.clone(), + bucket: input.bucket.clone(), + object_key: input.object_key.clone(), + access_policy: input.access_policy, + content_type: input.content_type.clone(), + content_length: input.content_length, + content_hash: input.content_hash.clone(), + version: input.version, + source_job_id: input.source_job_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + entity_id: input.entity_id.clone(), + asset_kind: input.asset_kind.clone(), + created_at, + updated_at, + }; + ctx.db.asset_object().insert(row); + + AssetObjectUpsertSnapshot { + asset_object_id: input.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: input.access_policy, + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} + +fn upsert_asset_entity_binding( + ctx: &ReducerContext, + input: AssetEntityBindingInput, +) -> Result { + validate_asset_entity_binding_fields( + &input.binding_id, + &input.asset_object_id, + &input.entity_kind, + &input.entity_id, + &input.slot, + &input.asset_kind, + ) + .map_err(|error| error.to_string())?; + + if ctx + .db + .asset_object() + .asset_object_id() + .find(&input.asset_object_id) + .is_none() + { + return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string()); + } + + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + // 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。 + let current = ctx.db.asset_entity_binding().iter().find(|row| { + row.entity_kind == input.entity_kind + && row.entity_id == input.entity_id + && row.slot == input.slot + }); + + let snapshot = match current { + Some(existing) => { + ctx.db + .asset_entity_binding() + .binding_id() + .delete(&existing.binding_id); + let row = AssetEntityBinding { + binding_id: existing.binding_id.clone(), + asset_object_id: input.asset_object_id.clone(), + entity_kind: input.entity_kind.clone(), + entity_id: input.entity_id.clone(), + slot: input.slot.clone(), + asset_kind: input.asset_kind.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + created_at: existing.created_at, + updated_at, + }; + ctx.db.asset_entity_binding().insert(row); + + AssetEntityBindingSnapshot { + binding_id: existing.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + created_at_micros: existing.created_at.to_micros_since_unix_epoch(), + updated_at_micros: input.updated_at_micros, + } + } + None => { + let created_at = updated_at; + let row = AssetEntityBinding { + binding_id: input.binding_id.clone(), + asset_object_id: input.asset_object_id.clone(), + entity_kind: input.entity_kind.clone(), + entity_id: input.entity_id.clone(), + slot: input.slot.clone(), + asset_kind: input.asset_kind.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + created_at, + updated_at, + }; + ctx.db.asset_entity_binding().insert(row); + + AssetEntityBindingSnapshot { + binding_id: input.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + created_at_micros: input.updated_at_micros, + updated_at_micros: input.updated_at_micros, + } + } + }; + + Ok(snapshot) +} diff --git a/server-rs/scripts/oss-smoke.ps1 b/server-rs/scripts/oss-smoke.ps1 new file mode 100644 index 00000000..a792a965 --- /dev/null +++ b/server-rs/scripts/oss-smoke.ps1 @@ -0,0 +1,519 @@ +[CmdletBinding()] +param( + [Alias("h")] + [switch]$Help, + [string]$ApiHost = "127.0.0.1", + [int]$Port = 3310, + [string]$Log = "warn,tower_http=warn", + [int]$StartupTimeoutSeconds = 30, + [string]$LegacyPrefix = "/generated-character-drafts/*", + [string[]]$PathSegments = @("oss-smoke"), + [string]$FileName = "tmp_oss_upload_test.txt", + [string]$FileContent = "Genarrative OSS smoke test", + [switch]$KeepObject, + [switch]$JsonOnly +) + +$ErrorActionPreference = "Stop" + +function Write-Usage { + @( + 'Usage:' + ' ./server-rs/scripts/oss-smoke.ps1' + ' ./server-rs/scripts/oss-smoke.ps1 -LegacyPrefix "/generated-characters/*" -PathSegments hero_001,visual,asset_01' + ' ./server-rs/scripts/oss-smoke.ps1 -KeepObject' + '' + 'Notes:' + ' 1. Load OSS config from repository root .env and .env.local' + ' 2. Start a temporary local api-server process' + ' 3. Request /api/assets/direct-upload-tickets and perform a real PostObject upload' + ' 4. Verify bucket access, uploaded object visibility, and delete the test object by default' + ) -join [Environment]::NewLine +} + +function Assert-Condition { + param( + [bool]$Condition, + [string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Read-EnvFile { + param( + [string]$Path, + [hashtable]$Target + ) + + if (-not (Test-Path $Path)) { + return + } + + foreach ($line in Get-Content -Encoding UTF8 $Path) { + $trimmed = $line.Trim() + if ([string]::IsNullOrWhiteSpace($trimmed) -or $trimmed.StartsWith('#')) { + continue + } + + $separatorIndex = $trimmed.IndexOf('=') + if ($separatorIndex -lt 1) { + continue + } + + $key = $trimmed.Substring(0, $separatorIndex).Trim() + $value = $trimmed.Substring($separatorIndex + 1).Trim() + + if ($value.Length -ge 2 -and $value.StartsWith('"') -and $value.EndsWith('"')) { + $value = $value.Substring(1, $value.Length - 2) + } + + $Target[$key] = $value + } +} + +function Set-InheritedEnvVar { + param( + [string]$Name, + [string]$Value, + [hashtable]$Snapshot + ) + + if (-not $Snapshot.ContainsKey($Name)) { + $Snapshot[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process") + } + + [Environment]::SetEnvironmentVariable($Name, $Value, "Process") +} + +function Restore-InheritedEnvVars { + param([hashtable]$Snapshot) + + foreach ($entry in $Snapshot.GetEnumerator()) { + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process") + } +} + +function ConvertTo-Rfc1123Date { + return [DateTime]::UtcNow.ToString("r", [System.Globalization.CultureInfo]::InvariantCulture) +} + +function New-HmacSha1Signature { + param( + [string]$Secret, + [string]$Content + ) + + $hmac = New-Object System.Security.Cryptography.HMACSHA1 + try { + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret) + $hashBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Content)) + return [Convert]::ToBase64String($hashBytes) + } + finally { + $hmac.Dispose() + } +} + +function Invoke-SignedOssRequest { + param( + [string]$Method, + [string]$Bucket, + [string]$Endpoint, + [string]$AccessKeyId, + [string]$AccessKeySecret, + [string]$ObjectKey = "" + ) + + $dateValue = ConvertTo-Rfc1123Date + $canonicalResource = if ([string]::IsNullOrWhiteSpace($ObjectKey)) { + "/$Bucket/" + } + else { + "/$Bucket/$ObjectKey" + } + $stringToSign = "$Method`n`n`n$dateValue`n$canonicalResource" + $signature = New-HmacSha1Signature -Secret $AccessKeySecret -Content $stringToSign + $uri = if ([string]::IsNullOrWhiteSpace($ObjectKey)) { + "https://$Bucket.$Endpoint/" + } + else { + "https://$Bucket.$Endpoint/$ObjectKey" + } + + try { + $response = Invoke-WebRequest ` + -Uri $uri ` + -Method $Method ` + -Headers @{ + "Date" = $dateValue + "Authorization" = "OSS $AccessKeyId`:$signature" + } ` + -UseBasicParsing ` + -TimeoutSec 30 + + return @{ + ok = $true + status = [int]$response.StatusCode + url = $uri + } + } + catch { + $statusCode = $null + $body = $_.Exception.Message + + if ($_.Exception.Response) { + try { + $statusCode = [int]$_.Exception.Response.StatusCode + } + catch { + $statusCode = $null + } + } + + return @{ + ok = $false + status = $statusCode + url = $uri + body = $body + } + } +} + +function Wait-ForHealthz { + param( + [string]$Uri, + [int]$TimeoutSeconds, + $Process + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + while ((Get-Date) -lt $deadline) { + if ($Process.HasExited) { + throw "api-server exited before /healthz became ready." + } + + try { + $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 2 + if ($response.StatusCode -eq 200) { + return + } + + $lastError = "Unexpected status code $($response.StatusCode)" + } + catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Milliseconds 300 + } + + throw "Timed out waiting for /healthz readiness. Last error: $lastError" +} + +function Invoke-JsonPost { + param( + [string]$Uri, + [string]$Body + ) + + $response = Invoke-WebRequest ` + -Uri $Uri ` + -Method Post ` + -ContentType "application/json" ` + -Headers @{ "x-request-id" = "oss-smoke-$([guid]::NewGuid().ToString('N').Substring(0, 8))" } ` + -Body $Body ` + -UseBasicParsing ` + -TimeoutSec 30 + + return $response.Content | ConvertFrom-Json +} + +function New-MultipartFormData { + param( + [hashtable]$Fields, + [string]$FilePath, + [string]$FileFieldName = "file" + ) + + $boundary = "----CodexBoundary$([Guid]::NewGuid().ToString('N'))" + $encoding = [System.Text.Encoding]::UTF8 + $memory = New-Object System.IO.MemoryStream + + try { + foreach ($entry in $Fields.GetEnumerator()) { + $prefix = "--$boundary`r`nContent-Disposition: form-data; name=""$($entry.Key)""`r`n`r`n$($entry.Value)`r`n" + $bytes = $encoding.GetBytes($prefix) + $memory.Write($bytes, 0, $bytes.Length) + } + + $fileInfo = Get-Item -LiteralPath $FilePath + $mimeType = "application/octet-stream" + if ($fileInfo.Extension -ieq ".txt") { + $mimeType = "text/plain" + } + + $fileHeader = "--$boundary`r`nContent-Disposition: form-data; name=""$FileFieldName""; filename=""$($fileInfo.Name)""`r`nContent-Type: $mimeType`r`n`r`n" + $fileHeaderBytes = $encoding.GetBytes($fileHeader) + $memory.Write($fileHeaderBytes, 0, $fileHeaderBytes.Length) + + $fileBytes = [System.IO.File]::ReadAllBytes($FilePath) + $memory.Write($fileBytes, 0, $fileBytes.Length) + + $fileFooterBytes = $encoding.GetBytes("`r`n--$boundary--`r`n") + $memory.Write($fileFooterBytes, 0, $fileFooterBytes.Length) + + return @{ + Boundary = $boundary + Body = $memory.ToArray() + } + } + finally { + $memory.Dispose() + } +} + +function Remove-IfExists { + param([string]$Path) + + if (Test-Path $Path) { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + } +} + +if ($Help) { + Write-Usage + exit 0 +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$serverRsDir = Split-Path -Parent $scriptDir +$repoRoot = Split-Path -Parent $serverRsDir +$manifestPath = Join-Path $serverRsDir "Cargo.toml" +$binaryPath = Join-Path $serverRsDir "target\debug\api-server.exe" +$baseUrl = "http://$ApiHost`:$Port" +$healthzUrl = "$baseUrl/healthz" +$runId = [Guid]::NewGuid().ToString("N") +$stdoutLog = Join-Path $env:TEMP "genarrative-server-rs-oss-smoke-$runId.stdout.log" +$stderrLog = Join-Path $env:TEMP "genarrative-server-rs-oss-smoke-$runId.stderr.log" +$uploadFilePath = Join-Path $env:TEMP "$runId-$FileName" +$envSnapshot = @{} +$mergedEnv = @{} +$serverProcess = $null +$deleteResult = $null +$signedObjectHead = $null + +if (-not (Test-Path $manifestPath)) { + throw "Missing server-rs/Cargo.toml, cannot start OSS smoke script." +} + +Read-EnvFile -Path (Join-Path $repoRoot ".env") -Target $mergedEnv +Read-EnvFile -Path (Join-Path $repoRoot ".env.local") -Target $mergedEnv + +$bucket = [string]($mergedEnv["ALIYUN_OSS_BUCKET"]) +$endpoint = [string]($mergedEnv["ALIYUN_OSS_ENDPOINT"]) +$accessKeyId = [string]($mergedEnv["ALIYUN_OSS_ACCESS_KEY_ID"]) +$accessKeySecret = [string]($mergedEnv["ALIYUN_OSS_ACCESS_KEY_SECRET"]) + +Assert-Condition (-not [string]::IsNullOrWhiteSpace($bucket)) "Missing ALIYUN_OSS_BUCKET in .env/.env.local." +Assert-Condition (-not [string]::IsNullOrWhiteSpace($endpoint)) "Missing ALIYUN_OSS_ENDPOINT in .env/.env.local." +Assert-Condition (-not [string]::IsNullOrWhiteSpace($accessKeyId)) "Missing ALIYUN_OSS_ACCESS_KEY_ID in .env/.env.local." +Assert-Condition (-not [string]::IsNullOrWhiteSpace($accessKeySecret)) "Missing ALIYUN_OSS_ACCESS_KEY_SECRET in .env/.env.local." + +$bucketHead = Invoke-SignedOssRequest ` + -Method "HEAD" ` + -Bucket $bucket ` + -Endpoint $endpoint ` + -AccessKeyId $accessKeyId ` + -AccessKeySecret $accessKeySecret + +[System.IO.File]::WriteAllText($uploadFilePath, "$FileContent`n", [System.Text.Encoding]::UTF8) + +Push-Location $serverRsDir +try { + Write-Host "[server-rs:oss-smoke] step: cargo build -p api-server" + cargo build -p api-server --manifest-path $manifestPath + + Assert-Condition (Test-Path $binaryPath) "Missing api-server binary at $binaryPath after cargo build." + + foreach ($entry in $mergedEnv.GetEnumerator()) { + Set-InheritedEnvVar -Name $entry.Key -Value ([string]$entry.Value) -Snapshot $envSnapshot + } + Set-InheritedEnvVar -Name "GENARRATIVE_API_HOST" -Value $ApiHost -Snapshot $envSnapshot + Set-InheritedEnvVar -Name "GENARRATIVE_API_PORT" -Value "$Port" -Snapshot $envSnapshot + Set-InheritedEnvVar -Name "GENARRATIVE_API_LOG" -Value $Log -Snapshot $envSnapshot + + Write-Host "[server-rs:oss-smoke] step: start api-server binary" + $serverProcess = Start-Process ` + -FilePath $binaryPath ` + -WorkingDirectory $repoRoot ` + -PassThru ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog + + Restore-InheritedEnvVars -Snapshot $envSnapshot + + Write-Host "[server-rs:oss-smoke] step: wait for /healthz readiness" + Wait-ForHealthz -Uri $healthzUrl -TimeoutSeconds $StartupTimeoutSeconds -Process $serverProcess + + $timestampSegment = Get-Date -Format "yyyyMMdd-HHmmss" + $resolvedPathSegments = @($PathSegments + $timestampSegment) + $ticketRequestBody = @{ + legacyPrefix = $LegacyPrefix + pathSegments = $resolvedPathSegments + fileName = $FileName + contentType = "text/plain" + metadata = @{ + origin = "server-rs-oss-smoke" + "asset-kind" = "manual-test" + } + } | ConvertTo-Json -Depth 5 + + Write-Host "[server-rs:oss-smoke] step: request direct upload ticket" + $ticketEnvelope = Invoke-JsonPost -Uri "$baseUrl/api/assets/direct-upload-tickets" -Body $ticketRequestBody + $upload = $ticketEnvelope.upload + if ($null -eq $upload) { + $upload = $ticketEnvelope.data.upload + } + Assert-Condition ($null -ne $upload) "OSS direct upload ticket response is missing upload payload." + + $formFields = @{} + $upload.formFields.psobject.Properties | ForEach-Object { + $formFields[$_.Name] = [string]$_.Value + } + + $multipart = New-MultipartFormData -Fields $formFields -FilePath $uploadFilePath + + Write-Host "[server-rs:oss-smoke] step: upload test object to OSS" + $uploadResponse = $null + try { + $uploadResponse = Invoke-WebRequest ` + -Uri $upload.host ` + -Method Post ` + -ContentType "multipart/form-data; boundary=$($multipart.Boundary)" ` + -Body $multipart.Body ` + -UseBasicParsing ` + -TimeoutSec 60 + + $uploadResult = @{ + ok = $true + status = [int]$uploadResponse.StatusCode + body = $uploadResponse.Content + } + } + catch { + $statusCode = $null + if ($_.Exception.Response) { + try { + $statusCode = [int]$_.Exception.Response.StatusCode + } + catch { + $statusCode = $null + } + } + + $uploadResult = @{ + ok = $false + status = $statusCode + body = $_.Exception.Message + } + } + + $publicHead = $null + if (-not [string]::IsNullOrWhiteSpace([string]$upload.publicUrl)) { + try { + $publicResponse = Invoke-WebRequest ` + -Uri ([string]$upload.publicUrl) ` + -Method Head ` + -UseBasicParsing ` + -TimeoutSec 30 + + $publicHead = @{ + ok = $true + status = [int]$publicResponse.StatusCode + url = [string]$upload.publicUrl + } + } + catch { + $statusCode = $null + if ($_.Exception.Response) { + try { + $statusCode = [int]$_.Exception.Response.StatusCode + } + catch { + $statusCode = $null + } + } + + $publicHead = @{ + ok = $false + status = $statusCode + url = [string]$upload.publicUrl + body = $_.Exception.Message + } + } + } + + $signedObjectHead = Invoke-SignedOssRequest ` + -Method "HEAD" ` + -Bucket $bucket ` + -Endpoint $endpoint ` + -AccessKeyId $accessKeyId ` + -AccessKeySecret $accessKeySecret ` + -ObjectKey ([string]$upload.objectKey) + + if (-not $KeepObject) { + $deleteResult = Invoke-SignedOssRequest ` + -Method "DELETE" ` + -Bucket $bucket ` + -Endpoint $endpoint ` + -AccessKeyId $accessKeyId ` + -AccessKeySecret $accessKeySecret ` + -ObjectKey ([string]$upload.objectKey) + } + + $result = [ordered]@{ + bucketHead = $bucketHead + ticket = [ordered]@{ + host = [string]$upload.host + objectKey = [string]$upload.objectKey + legacyPublicPath = [string]$upload.legacyPublicPath + publicUrl = if ([string]::IsNullOrWhiteSpace([string]$upload.publicUrl)) { + $null + } + else { + [string]$upload.publicUrl + } + } + upload = $uploadResult + publicHead = $publicHead + signedObjectHead = $signedObjectHead + delete = $deleteResult + } + + if ($JsonOnly) { + $result | ConvertTo-Json -Depth 8 + } + else { + Write-Host "[server-rs:oss-smoke] result:" + $result | ConvertTo-Json -Depth 8 + } +} +finally { + Restore-InheritedEnvVars -Snapshot $envSnapshot + + if ($null -ne $serverProcess -and -not $serverProcess.HasExited) { + Stop-Process -Id $serverProcess.Id -Force + $serverProcess.WaitForExit() + } + + Pop-Location + + Remove-IfExists -Path $uploadFilePath + Remove-IfExists -Path $stdoutLog + Remove-IfExists -Path $stderrLog +} diff --git a/server-rs/scripts/spacetime-dev.ps1 b/server-rs/scripts/spacetime-dev.ps1 index e1ba1e7c..b6f6b8ab 100644 --- a/server-rs/scripts/spacetime-dev.ps1 +++ b/server-rs/scripts/spacetime-dev.ps1 @@ -3,7 +3,7 @@ param( [Alias("h")] [switch]$Help, [string]$ListenHost = "127.0.0.1", - [int]$Port = 3001, + [int]$Port = 3000, [string]$RootDir = "" ) @@ -18,7 +18,8 @@ function Write-Usage { 'Notes:' ' 1. Start local standalone SpacetimeDB for the Genarrative Rust backend' ' 2. Store local SpacetimeDB state in server-rs/.spacetimedb/local by default' - ' 3. Current stage only boots the standalone server and does not publish a module yet' + ' 3. Default port is 3000 to align with the spacetime CLI local server alias' + ' 4. Current stage already has crates/spacetime-module scaffold, but still does not auto-publish the module' ) -join [Environment]::NewLine } @@ -56,7 +57,7 @@ Write-Host "[server-rs:spacetime-dev] working dir: $serverRsDir" Write-Host "[server-rs:spacetime-dev] root dir: $RootDir" Write-Host "[server-rs:spacetime-dev] listen addr: $listenAddress" Write-Host "[server-rs:spacetime-dev] mode: standalone" -Write-Host "[server-rs:spacetime-dev] note: module publish is deferred until crates/spacetime-module scaffold lands" +Write-Host "[server-rs:spacetime-dev] note: module scaffold already exists; publish remains manual in this stage" Push-Location $serverRsDir try { diff --git a/server-rs/scripts/spacetime-dev.sh b/server-rs/scripts/spacetime-dev.sh index 946a26e4..4dcd01d1 100644 --- a/server-rs/scripts/spacetime-dev.sh +++ b/server-rs/scripts/spacetime-dev.sh @@ -13,7 +13,8 @@ usage() { 说明: 1. 启动 Genarrative Rust 后端使用的本地 standalone SpacetimeDB 2. 默认把本地数据目录放到 `server-rs/.spacetimedb/local` - 3. 当前阶段只负责启动 standalone server,暂不自动 publish `crates/spacetime-module` + 3. 默认端口使用 `3000`,与 `spacetime` CLI 的 local server 昵称保持一致 + 4. 当前阶段已具备 `crates/spacetime-module` scaffold,但暂不自动 publish EOF } @@ -25,7 +26,7 @@ fi SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SERVER_RS_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" LISTEN_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}" -PORT="${GENARRATIVE_SPACETIME_PORT:-3001}" +PORT="${GENARRATIVE_SPACETIME_PORT:-3000}" ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SERVER_RS_DIR}/.spacetimedb/local}" if [[ ! -f "${SERVER_RS_DIR}/crates/spacetime-module/README.md" ]]; then @@ -46,7 +47,7 @@ echo "[server-rs:spacetime-dev] 工作目录: ${SERVER_RS_DIR}" echo "[server-rs:spacetime-dev] 数据目录: ${ROOT_DIR}" echo "[server-rs:spacetime-dev] 监听地址: ${LISTEN_HOST}:${PORT}" echo "[server-rs:spacetime-dev] 模式: standalone" -echo "[server-rs:spacetime-dev] 说明: 当前阶段暂不自动 publish crates/spacetime-module" +echo "[server-rs:spacetime-dev] 说明: 当前阶段已落 crate scaffold,但仍不自动 publish crates/spacetime-module" cd "${SERVER_RS_DIR}" spacetime --root-dir "${ROOT_DIR}" start --edition standalone --listen-addr "${LISTEN_HOST}:${PORT}" diff --git a/spacetime.json b/spacetime.json new file mode 100644 index 00000000..d19f9314 --- /dev/null +++ b/spacetime.json @@ -0,0 +1,14 @@ +{ + "server": "local", + "module-path": "./server-rs/crates/spacetime-module", + "generate": [ + { + "language": "typescript", + "out-dir": "./src/spacetime/generated" + }, + { + "language": "rust", + "out-dir": "./server-rs/crates/spacetime-client/src/module_bindings" + } + ] +} diff --git a/spacetime.local.json b/spacetime.local.json new file mode 100644 index 00000000..77ed651c --- /dev/null +++ b/spacetime.local.json @@ -0,0 +1,3 @@ +{ + "database": "xushi-p4wfr" +} diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 7514003f..80ec7a69 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -125,6 +125,21 @@ test('auth gate keeps platform content visible when phone login is available', a expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); }); +test('auth gate does not auto-create a guest account when dev guest switch is not explicitly enabled', async () => { + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: [], + }); + + render( + +
应用内容
+
, + ); + + expect(await screen.findByText('应用内容')).toBeTruthy(); + expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); +}); + test('auth gate opens a login modal for protected actions and resumes after login', async () => { const user = userEvent.setup(); const onAuthenticated = vi.fn(); @@ -159,3 +174,39 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull(); }); + +test('auth gate shows sms send feedback in the login modal', async () => { + const user = userEvent.setup(); + + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone'], + }); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + + const dialog = screen.getByRole('dialog', { name: '登录账号' }); + await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); + await user.click(within(dialog).getByRole('button', { name: '获取验证码' })); + + await waitFor(() => { + expect(authMocks.sendPhoneLoginCode).toHaveBeenCalledWith( + '13800000000', + 'login', + { + challengeId: undefined, + answer: '', + }, + ); + }); + + expect( + within(dialog).getByText('验证码已发送,有效期约 5 分钟。'), + ).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy(); +}); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 31894296..0e09e0ef 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -59,7 +59,8 @@ type AuthStatus = const allowDevGuestAutoAuth = import.meta.env.DEV && - import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false'; + // 开发游客兜底必须显式开启,避免抢占正式手机号验证码登录入口。 + import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true'; export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index c05ee89f..4634e73c 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -50,6 +50,7 @@ export function LoginScreen({ const [code, setCode] = useState(''); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [cooldownSeconds, setCooldownSeconds] = useState(0); + const [hint, setHint] = useState(''); const phoneLoginEnabled = availableLoginMethods.includes('phone'); const wechatLoginEnabled = availableLoginMethods.includes('wechat'); @@ -142,12 +143,16 @@ export function LoginScreen({ className="platform-button platform-button--secondary h-12 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55" onClick={() => { void (async () => { + setHint(''); try { const result = await onSendCode(phone, { challengeId: captchaChallenge?.challengeId, answer: captchaAnswer, }); setCooldownSeconds(result.cooldownSeconds); + setHint( + `验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, + ); setCaptchaAnswer(''); } catch { // Error state is handled by the parent. @@ -169,6 +174,12 @@ export function LoginScreen({ answer={captchaAnswer} onAnswerChange={setCaptchaAnswer} /> + + {hint ? ( +
+ {hint} +
+ ) : null} ) : null} diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index 4962b9c4..0b0867f4 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -175,6 +175,26 @@ describe('apiClient', () => { expect(window.dispatchEvent).not.toHaveBeenCalled(); }); + it('keeps the current access token when a public request explicitly skips auth', async () => { + setStoredAccessToken('still-valid-token'); + vi.mocked(window.dispatchEvent).mockClear(); + fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + + const response = await fetchWithApiAuth( + '/api/runtime/custom-world-gallery', + { method: 'GET' }, + { + skipAuth: true, + skipRefresh: true, + }, + ); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(getStoredAccessToken()).toBe('still-valid-token'); + expect(window.dispatchEvent).not.toHaveBeenCalled(); + }); + it('retries transient get requests before unwrapping the response envelope', async () => { fetchMock .mockRejectedValueOnce(new TypeError('network unavailable')) diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 1611e567..e9235bba 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -518,7 +518,8 @@ export async function fetchWithApiAuth( } catch { clearStoredAccessToken(); } - } else if (response.status === 401) { + } else if (response.status === 401 && hasAuthHeader && !options.skipAuth) { + // 公开只读请求不能因为服务端异常 401 顺手把正式登录态清空。 clearStoredAccessToken(); } diff --git a/src/spacetime/generated/bind_asset_object_to_entity_and_return_procedure.ts b/src/spacetime/generated/bind_asset_object_to_entity_and_return_procedure.ts new file mode 100644 index 00000000..1dedcf5c --- /dev/null +++ b/src/spacetime/generated/bind_asset_object_to_entity_and_return_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AssetEntityBindingInput, + AssetEntityBindingProcedureResult, +} from "./types"; + +export const params = { + get input() { + return AssetEntityBindingInput; + }, +}; +export const returnType = AssetEntityBindingProcedureResult \ No newline at end of file diff --git a/src/spacetime/generated/bind_asset_object_to_entity_reducer.ts b/src/spacetime/generated/bind_asset_object_to_entity_reducer.ts new file mode 100644 index 00000000..1a355e26 --- /dev/null +++ b/src/spacetime/generated/bind_asset_object_to_entity_reducer.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AssetEntityBindingInput, +} from "./types"; + +export default { + get input() { + return AssetEntityBindingInput; + }, +}; diff --git a/src/spacetime/generated/confirm_asset_object_and_return_procedure.ts b/src/spacetime/generated/confirm_asset_object_and_return_procedure.ts new file mode 100644 index 00000000..b0ac99d3 --- /dev/null +++ b/src/spacetime/generated/confirm_asset_object_and_return_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AssetObjectUpsertInput, + AssetObjectProcedureResult, +} from "./types"; + +export const params = { + get input() { + return AssetObjectUpsertInput; + }, +}; +export const returnType = AssetObjectProcedureResult \ No newline at end of file diff --git a/src/spacetime/generated/confirm_asset_object_reducer.ts b/src/spacetime/generated/confirm_asset_object_reducer.ts new file mode 100644 index 00000000..1d063f5b --- /dev/null +++ b/src/spacetime/generated/confirm_asset_object_reducer.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AssetObjectUpsertInput, +} from "./types"; + +export default { + get input() { + return AssetObjectUpsertInput; + }, +}; diff --git a/src/spacetime/generated/index.ts b/src/spacetime/generated/index.ts new file mode 100644 index 00000000..e61b355a --- /dev/null +++ b/src/spacetime/generated/index.ts @@ -0,0 +1,113 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import BindAssetObjectToEntityReducer from "./bind_asset_object_to_entity_reducer"; +import ConfirmAssetObjectReducer from "./confirm_asset_object_reducer"; + +// Import all procedure arg schemas +import * as BindAssetObjectToEntityAndReturnProcedure from "./bind_asset_object_to_entity_and_return_procedure"; +import * as ConfirmAssetObjectAndReturnProcedure from "./confirm_asset_object_and_return_procedure"; + +// Import all table schema definitions + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("bind_asset_object_to_entity", BindAssetObjectToEntityReducer), + __reducerSchema("confirm_asset_object", ConfirmAssetObjectReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("bind_asset_object_to_entity_and_return", BindAssetObjectToEntityAndReturnProcedure.params, BindAssetObjectToEntityAndReturnProcedure.returnType), + __procedureSchema("confirm_asset_object_and_return", ConfirmAssetObjectAndReturnProcedure.params, ConfirmAssetObjectAndReturnProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.1.0" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/src/spacetime/generated/types.ts b/src/spacetime/generated/types.ts new file mode 100644 index 00000000..ff8ade1a --- /dev/null +++ b/src/spacetime/generated/types.ts @@ -0,0 +1,140 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AssetEntityBinding = __t.object("AssetEntityBinding", { + bindingId: __t.string(), + assetObjectId: __t.string(), + entityKind: __t.string(), + entityId: __t.string(), + slot: __t.string(), + assetKind: __t.string(), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AssetEntityBinding = __Infer; + +export const AssetEntityBindingInput = __t.object("AssetEntityBindingInput", { + bindingId: __t.string(), + assetObjectId: __t.string(), + entityKind: __t.string(), + entityId: __t.string(), + slot: __t.string(), + assetKind: __t.string(), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + updatedAtMicros: __t.i64(), +}); +export type AssetEntityBindingInput = __Infer; + +export const AssetEntityBindingProcedureResult = __t.object("AssetEntityBindingProcedureResult", { + ok: __t.bool(), + get record() { + return __t.option(AssetEntityBindingSnapshot); + }, + errorMessage: __t.option(__t.string()), +}); +export type AssetEntityBindingProcedureResult = __Infer; + +export const AssetEntityBindingSnapshot = __t.object("AssetEntityBindingSnapshot", { + bindingId: __t.string(), + assetObjectId: __t.string(), + entityKind: __t.string(), + entityId: __t.string(), + slot: __t.string(), + assetKind: __t.string(), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + createdAtMicros: __t.i64(), + updatedAtMicros: __t.i64(), +}); +export type AssetEntityBindingSnapshot = __Infer; + +export const AssetObject = __t.object("AssetObject", { + assetObjectId: __t.string(), + bucket: __t.string(), + objectKey: __t.string(), + get accessPolicy() { + return AssetObjectAccessPolicy; + }, + contentType: __t.option(__t.string()), + contentLength: __t.u64(), + contentHash: __t.option(__t.string()), + version: __t.u32(), + sourceJobId: __t.option(__t.string()), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + entityId: __t.option(__t.string()), + assetKind: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AssetObject = __Infer; + +// The tagged union or sum type for the algebraic type `AssetObjectAccessPolicy`. +export const AssetObjectAccessPolicy = __t.enum("AssetObjectAccessPolicy", { + Private: __t.unit(), + PublicRead: __t.unit(), +}); +export type AssetObjectAccessPolicy = __Infer; + +export const AssetObjectProcedureResult = __t.object("AssetObjectProcedureResult", { + ok: __t.bool(), + get record() { + return __t.option(AssetObjectUpsertSnapshot); + }, + errorMessage: __t.option(__t.string()), +}); +export type AssetObjectProcedureResult = __Infer; + +export const AssetObjectUpsertInput = __t.object("AssetObjectUpsertInput", { + assetObjectId: __t.string(), + bucket: __t.string(), + objectKey: __t.string(), + get accessPolicy() { + return AssetObjectAccessPolicy; + }, + contentType: __t.option(__t.string()), + contentLength: __t.u64(), + contentHash: __t.option(__t.string()), + version: __t.u32(), + sourceJobId: __t.option(__t.string()), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + entityId: __t.option(__t.string()), + assetKind: __t.string(), + updatedAtMicros: __t.i64(), +}); +export type AssetObjectUpsertInput = __Infer; + +export const AssetObjectUpsertSnapshot = __t.object("AssetObjectUpsertSnapshot", { + assetObjectId: __t.string(), + bucket: __t.string(), + objectKey: __t.string(), + get accessPolicy() { + return AssetObjectAccessPolicy; + }, + contentType: __t.option(__t.string()), + contentLength: __t.u64(), + contentHash: __t.option(__t.string()), + version: __t.u32(), + sourceJobId: __t.option(__t.string()), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + entityId: __t.option(__t.string()), + assetKind: __t.string(), + createdAtMicros: __t.i64(), + updatedAtMicros: __t.i64(), +}); +export type AssetObjectUpsertSnapshot = __Infer; + diff --git a/src/spacetime/generated/types/procedures.ts b/src/spacetime/generated/types/procedures.ts new file mode 100644 index 00000000..e1f57ca6 --- /dev/null +++ b/src/spacetime/generated/types/procedures.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as BindAssetObjectToEntityAndReturnProcedure from "../bind_asset_object_to_entity_and_return_procedure"; +import * as ConfirmAssetObjectAndReturnProcedure from "../confirm_asset_object_and_return_procedure"; + +export type BindAssetObjectToEntityAndReturnArgs = __Infer; +export type BindAssetObjectToEntityAndReturnResult = __Infer; +export type ConfirmAssetObjectAndReturnArgs = __Infer; +export type ConfirmAssetObjectAndReturnResult = __Infer; + diff --git a/src/spacetime/generated/types/reducers.ts b/src/spacetime/generated/types/reducers.ts new file mode 100644 index 00000000..69fc851e --- /dev/null +++ b/src/spacetime/generated/types/reducers.ts @@ -0,0 +1,14 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import BindAssetObjectToEntityReducer from "../bind_asset_object_to_entity_reducer"; +import ConfirmAssetObjectReducer from "../confirm_asset_object_reducer"; + +export type BindAssetObjectToEntityParams = __Infer; +export type ConfirmAssetObjectParams = __Infer; +