diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d7c198f3e..c4712a723 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -218,6 +218,8 @@ module.exports = { '!src/services/frontendRuntimeConfigService.ts', '!src/services/frontendRuntimeConfigService.test.ts', '!src/services/gameDistributionClient.ts', + '!src/services/creatorProfileClient.ts', + '!src/services/creatorProfileClient.test.ts', '!src/services/gameDistributionClient.test.ts', '!src/services/gamePlayClientId.ts', '!src/services/sseStream.ts', diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index f9dd352eb..c4b548c33 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -157,7 +157,7 @@ http { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/mine|games/play|games/publish)/?$" { try_files $uri /index.html =404; } diff --git a/deploy/nginx/README.md b/deploy/nginx/README.md index e022e295c..889329223 100644 --- a/deploy/nginx/README.md +++ b/deploy/nginx/README.md @@ -107,4 +107,4 @@ curl -sSI -H 'Accept-Encoding: br' \ - 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。 - 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。 - 审核通过时 `api-server` 按 gameId 派生同源路径 `/games//` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。 -- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 +- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/creators`、`/creators/connections`、`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。 diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index e2879dff2..eb002984e 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -206,7 +206,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 15d8c4d04..bbcee962f 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -234,7 +234,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|games|games/detail|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/detail|games/mine|games/play|games/publish)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/docs/README.md b/docs/README.md index e486b628d..64673640a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,9 +21,8 @@ - [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。 - [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价已实现并通过本地验证,待用户验收,未部署。 - [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。 -- [创作者主页与关注粉丝合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同)、[关系与公开查询里程碑](./project-memory/plans/【里程碑】创作者关系与公开查询-2026-10-05.md)和[主页与交互里程碑](./project-memory/plans/【里程碑】创作者主页与关系交互-2026-10-05.md):产品规则已确认,未实现;第四项默认进入自己主页,他人的关注/粉丝列表统一只读并支持主页跳转。 -- [创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md):待实现的分层落点、关系表/DTO、授权、关系列表分页、组件状态、深链与验证边界;游戏列表沿用最多 48 项限制,不做额外分页改造。 -- [创作者关系与公开查询实施计划](./project-memory/plans/【实施计划】创作者关系与公开查询-2026-10-05.md):首阶段开发计划已就绪,明确契约、事务、facade/HTTP、隔离运行时验收顺序与回滚边界;未开始业务实现。 +- [创作者主页与关注粉丝合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同):前后端已实现并通过工程验证,用户已确认提交交付,未部署。第四项默认进入自己主页,他人的关注/粉丝列表统一只读并支持主页跳转。 +- [创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md):分层落点、关系表/DTO、授权、关系列表分页、组件状态、深链与验证边界;游戏列表沿用最多 48 项限制,不做额外分页改造。 - [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。 - [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。 - [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。 diff --git a/docs/project-memory/plans/【实施计划】创作者关系与公开查询-2026-10-05.md b/docs/project-memory/plans/【实施计划】创作者关系与公开查询-2026-10-05.md deleted file mode 100644 index 03fd65a19..000000000 --- a/docs/project-memory/plans/【实施计划】创作者关系与公开查询-2026-10-05.md +++ /dev/null @@ -1,128 +0,0 @@ -# 创作者关系与公开查询实施计划 - -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | ready(计划已形成,尚未开始实现) | -| Date | 2026-10-05 | -| Owner | 接手本里程碑的开发者或 Agent | -| Milestone | [创作者关系与公开查询](./【里程碑】创作者关系与公开查询-2026-10-05.md) | -| Parent Spec | [创作者主页与关注粉丝合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同) | -| Engineering | [创作者主页与关注粉丝工程设计](../../technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md) | - -## 交付结果与优先级 - -交付可由真实 API 读写、由 SpacetimeDB 持久化的关注关系、公开主页摘要、关注/粉丝分页列表和作者公开游戏查询;以权限、双向关系、幂等、计数、只读列表及游戏过滤的运行时证据验收。 - -必须项:契约与纯规则 → 表与事务 → facade 与 HTTP → 定向测试与隔离环境回读。风险项:服务身份、关系方向、认证快照同步、账号不存在时的统计、生成绑定、作者过滤与 48 项截取顺序。可选的计数缓存、性能专项优化和界面优化不进入本里程碑。 - -开始实施时用约 15 分钟核对分支、最新源文件、工具链和开发环境。按下述四个检查点推进,每个检查点至少输出一次结果;单个阻塞调查超过 30 分钟时记录具体限制并优先完成其余独立必需项,不因调查扩展产品范围。时间盒用于控制调查,不削减验收要求。 - -## 修改边界 - -| 层 | 允许修改的范围 | -| --- | --- | -| 共享契约 | 新增 `server-rs/crates/shared-contracts/src/creator.rs`、`packages/shared/src/contracts/creator.ts` 及导出;对应序列化/契约测试 | -| 领域规则 | `server-rs/crates/module-auth/src/` 新增关系子模块及导出,承载方向、用户对键和输入规则;不依赖数据库或 HTTP | -| 数据与事务 | `server-rs/crates/spacetime-module/src/` 新增 creator 模块、注册入口、`migration.rs`;必要的 `game_distribution.rs` 可选作者过滤 | -| 数据访问 | `server-rs/crates/spacetime-client/src/` 新增 creator facade/mapper、注册入口、内部输入与绑定;game-distribution 输入追加可选作者参数 | -| HTTP | `server-rs/crates/api-server/src/modules/creator.rs`、`modules.rs`、`app.rs`;现有 game-distribution query 与调用透传;对应路由/权限测试 | -| 生成物和文档 | 仓库脚本生成的绑定、现有 schema/DDD/契约守卫所需登记、后端表目录、主规范中的实现证据及本里程碑验收记录 | - -本阶段不修改主站页面、导航、共享 UI、前端服务调用层、nginx/Pingora 路由、AGC 客户端、后台管理、external v1 或正式游戏运行态。这些页面接入工作属于下一里程碑。既有游戏广场与“我的游戏”继续最多读取 48 项;只增加作者查询能力,不增加游戏分页,不重构两张既有列表。 - -## 实现顺序与检查点 - -### 检查点一:冻结契约与纯规则 - -1. 对照主规范的八项接口合同和工程设计 DTO,增加 Rust/TS 同名模型与导出;保留现有响应信封和错误映射。 -2. 在关系领域子模块明确关注、取消关注、移除粉丝三种命令的方向及自关注拒绝规则。用用户 ID 字节长度前缀生成有向用户对键,覆盖中文、多字节和易拼接碰撞 ID。 -3. 校验列表 limit、游标结构、长度边界和排序键,具体长度常量按项目既有输入上限约定固定并写入定向测试;游标绑定主人和列表类型,不承担授权。 -4. 增加共享契约序列化测试:字段名、null、时间格式与响应信封内的数据形状;记录本阶段实际测试名称,避免过滤条件零匹配。 - -退出判据:DTO 与产品合同一致,方向/唯一键/参数规则测试通过;接口中没有可由普通调用者指定的操作者身份。 - -### 检查点二:表、事务和绑定 - -1. 新增私有 `user_follow` 表及双方向索引,字段按工程设计;不修改账号/游戏主键,不新增账号状态或计数表。 -2. 新增事务写路径,先校验受信 API 服务身份,再检查认证操作者和目标账号存在性。关注只建立出边;取消只删除出边;移除粉丝只删除入边。重复相同写操作不重复插行、扣数或改既有时间。 -3. 新增主页、关系和双向列表读取:每次请求内部一次事务读出所需事实,账号不存在时按合同返回/过滤。只有访问者等于列表主人时生成逐行关系;游客、他人关注列表与他人粉丝列表仅读取列表本身和公开资料。 -4. 关系列表先过滤不存在的对端,再统计和分页;按既定时间/主键顺序读取 limit+1。头像昵称直接投影当前账号资料,不保存副本。 -5. game-distribution 查询追加可选作者 ID,按作者与现役公开可见性过滤后排序、截取最多 48 项;不传作者保持当前返回语义,`nextCursor` 仍为 null。 -6. 同步 `migration.rs`、后端数据契约的表目录及必要守卫登记,运行仓库绑定生成和 schema 检查。确认认证全量快照更新不包含或清空新关系表。 - -退出判据:模块可编译、生成物一致、schema 门禁通过;无破坏性 schema 变更。事务的真实执行在检查点四验证,host 测试不能替代。 - -### 检查点三:facade 与 HTTP 接入 - -1. 在 `spacetime-client` 增加 typed 输入、结果、mapper 和调用方法,复用现有连接及服务身份,不建立第二条数据访问链。 -2. 注册 creator 路由,复用现有认证校验、状态码与响应信封;操作者取认证上下文。公开主页无访问者字段,关系 GET 和三个写入口需要登录。 -3. 列表有效认证只用于判断主人身份;认证无效返回 401,无凭据按游客读取。带认证列表与关系接口设置 `private, no-store`。不允许客户端参数强制他人列表返回逐行关系。 -4. 透传游戏查询的可选 `authorId`,显式空值返回 400。回归旧查询,确认没有新增游戏分页或更改原有过滤行为。 -5. 添加路由与 facade 测试:401/403/404/400、幂等回执、输入伪造、公开字段白名单、缓存响应头,以及他人两类列表不触发额外访问者关系投影。 - -退出判据:API 与 DTO 一致,授权和列表只读分支测试通过;本阶段仍不开始前端接线。 - -### 检查点四:隔离运行时验证与证据 - -1. 核实实际开发栈状态和隔离数据库目标,按现有运维脚本构建/发布 module 并启动 API;不使用生产库,不使用删库或 `spacetime --root-dir`。 -2. 以 A/B/C 三个测试账号执行关注、取消、重新关注、互关与移除粉丝;验证 A 移除 B 粉丝后 `A → B` 保留、`B → A` 消失,并从数据库与 API 回读计数。 -3. 验证游客、失效凭据、自操作拒绝、第三方越权及普通身份直接调用 procedure 被拒。测试身份凭据不打印到日志、不写入提交。 -4. 用超过 50 条且含同时间关系的夹具验证分页,覆盖主人/类型不匹配游标、空列表和已不存在对端。验证认证快照刷新后关系仍保留。 -5. 作者游戏夹具混合多个作者、超过 48 个目标作者游戏及非公开/下架/删除作品,确认过滤先于截取且最多 48 项;旧目录查询结果口径保持一致。 -6. 覆盖相同请求重复、相反动作并发和写入结果未知后的回读;最终结果按事务提交顺序解释,不承诺跨请求冻结快照。 - -退出判据:逐条填写下方证据矩阵;环境缺失的项写明原因,不以通过的单测代替未完成运行时证据。完成后停止,待本里程碑验收再制定页面阶段实施计划。 - -## 验证命令 - -以下均为后续工程实现时执行,本次文档任务不构建或发布数据库。`creator` 为新增测试的功能分组,需检查实际匹配数量;相关 game-distribution 模块回归单独运行。 - -```bash -cargo test --locked --manifest-path server-rs/Cargo.toml -p module-auth creator -cargo test --locked --manifest-path server-rs/Cargo.toml -p shared-contracts creator -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client creator -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server creator -cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server game_distribution -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module creator -cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module game_distribution -cargo check --locked --manifest-path server-rs/Cargo.toml -p spacetime-module -npm run spacetime:generate -npm run check:generated-bindings -npm run check:server-rs-ddd -npm run check:game-distribution-dto-parity -npm run typecheck -npm run dev:api-server -``` - -绑定生成需在依赖新绑定的 facade 编译前执行,schema/DDD 门禁在生成后执行;以上清单不是要求按行机械运行。`dev:api-server` 是持续服务命令,启动后根据 `.app/dev-stack.json` 中的实际地址检查 `/healthz` 并执行接口 smoke,不把默认端口当作运行时真相。Rust 格式检查使用当前仓库约定;新增 TS 契约测试按实际文件定向执行,不为占位断言创建无效测试。 - -文档和代码交付均运行: - -```bash -npm run check:encoding -npm run check:doc-index -git diff --check -``` - -## 风险与回滚点 - -- Schema 发布前:可以回退本里程碑代码和生成物;不覆盖他人改动,不修改已有表字段顺序/类型。若出现破坏性迁移需求,先更新主规范和迁移计划,再确认实施。 -- Schema 发布后:保留关系表和已写数据,业务回退使用保留新表兼容 schema 的版本;不通过删除数据库或关系表恢复旧版本。后端和对应 bindings 同步,先确认兼容再重启服务。 -- 认证投影:关系表独立于账号快照,回归同步账号资料不会删除关系;缺失账号过滤与计数同口径。 -- 范围控制:读时精确计数先复用索引;发现规模风险只记录证据,不自行增加缓存、后台任务或账号状态。游戏目录现有 48 项限制保持不变。 -- 交付边界:本计划只授权范围内的开发验证,不等同于生产部署;本次只补文档并本地提交,不推送远程。 - -## 验收证据矩阵 - -| 对照项 | 验证方式 | 当前结果 | -| --- | --- | --- | -| 唯一键、方向、自关注与幂等 | 领域测试 + API/数据库回读 | 未实施 | -| 认证与第三方越权 | HTTP 负向用例 + 普通身份调用 procedure | 未实施 | -| 他人两类列表只读、无访问者关系查询 | 路由/投影定向测试 + API 响应 | 未实施 | -| 公开字段、计数、分页、账号存在性 | 契约测试 + 多页隔离夹具 | 未实施 | -| 作者游戏过滤与原有 48 项边界 | game-distribution 回归 + API 夹具 | 未实施 | -| Schema、迁移、绑定及认证快照兼容 | 生成检查、DDD 门禁 + 实际运行时回读 | 未实施 | -| 重复/并发写与结果未知 | 事务集成用例与写后查询 | 未实施 | - -实际实现时补充命令结果、证据位置和未验证原因。本里程碑与页面阶段全部验收后,将持久结论融合回主规范并删除已完成临时计划。 diff --git a/docs/project-memory/plans/【里程碑】创作者主页与关系交互-2026-10-05.md b/docs/project-memory/plans/【里程碑】创作者主页与关系交互-2026-10-05.md deleted file mode 100644 index 2ac745371..000000000 --- a/docs/project-memory/plans/【里程碑】创作者主页与关系交互-2026-10-05.md +++ /dev/null @@ -1,40 +0,0 @@ -# 创作者主页与关系交互 - -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | ready(产品规则已确认,实施仍依赖前阶段验收) | -| Date | 2026-10-05 | -| Parent Spec | [创作者主页与关注粉丝合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同) | - -## 目标与范围 - -在既有主站接入第四项创作者主页、游戏详情作者链接和关注按钮、公开主页与游戏列表、关注和粉丝列表及本人管理操作。 - -复用公共游戏卡、账号资料、登录能力、确认弹窗与页面导航;通用关注表现和用户行抽到共享组件,后端副作用与正式关系由业务层持有。不包含主页装修、资料编辑新系统和关注消息通知。 - -## 前置条件 - -- [创作者关系与公开查询](./【里程碑】创作者关系与公开查询-2026-10-05.md)已验收。 -- 主规范的产品规则已确认;开始编码前单独落地本阶段实施计划。 - -## 验收标准 - -- [ ] 桌面第四项进入自己主页,第五项为“我的”;移动入口为“游戏 / 创作者主页 / 我的”。 -- [ ] 游戏详情可进入自己或他人主页,关注按钮与作者链接互不误触。 -- [ ] 主页显示真实计数和公开游戏,点击进入现有详情;本人无自关注按钮。 -- [ ] 公开关注/粉丝列表支持分页;只有本人有移除粉丝动作。 -- [ ] 他人的关注、粉丝列表均仅展示资料和主页链接,无关系操作按钮,不补查或比对访问者关系;预置关注缓存不改变只读表现,自己的列表仍可管理关系。 -- [ ] 自己/他人的关注/粉丝四类列表,游客和登录用户均可点击列表用户头像或昵称进入其创作者主页,覆盖本人及无公开游戏用户;返回恢复来源列表和分页/滚动位置,关系操作按钮不误触跳转。 -- [ ] 取消关注后可在当前行重新关注;刷新后的正式列表正确。 -- [ ] 粉丝列表回关/取消回关和移除粉丝分别改变正确方向。 -- [ ] 移除粉丝需二次确认,取消弹窗不发写请求;取消关注直接执行,不弹确认。 -- [ ] 登录、退出、账号切换、请求迟到和网络失败不污染关系状态。 -- [ ] 主页/详情/列表往返状态一致,深链刷新和返回位置正确。 -- [ ] 375px 移动和桌面布局、长昵称、空态/错误态、键盘和触摸操作可用。 - -## 证据与停止点 - -前端定向 Vitest、`npm run typecheck`、`npm run check:encoding`、`npm run check:doc-index` 和 `git diff --check`;真实浏览器配合三个测试账号完成关系闭环,验证公开与私有游戏隔离。 - -提交最终逐条证据,等待验收;通过后将持久结论更新到主规范和相关共享记忆,删除已完成临时计划。本里程碑不授权自动生产部署。当前无实现或浏览器证据。 diff --git a/docs/project-memory/plans/【里程碑】创作者关系与公开查询-2026-10-05.md b/docs/project-memory/plans/【里程碑】创作者关系与公开查询-2026-10-05.md deleted file mode 100644 index 14d911c9f..000000000 --- a/docs/project-memory/plans/【里程碑】创作者关系与公开查询-2026-10-05.md +++ /dev/null @@ -1,37 +0,0 @@ -# 创作者关系与公开查询 - -| 字段 | 值 | -| --- | --- | -| Version | 1.0 | -| Status | ready(产品规则已确认,未实现) | -| Date | 2026-10-05 | -| Parent Spec | [创作者主页与关注粉丝合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同) | - -## 目标与范围 - -为公开创作者主页提供真实用户资料、关注/粉丝计数、分页关系列表、访问者关系和作者公开游戏筛选,并支持认证用户关注、取消关注和移除自己粉丝。 - -不包含页面接入、消息通知、推荐、拉黑、既有游戏列表分页改造和生产部署。不新增独立创作者身份,公开游戏沿用现有可见性口径及最多 48 项限制。 - -## 前置条件 - -- 主规范的产品规则已确认;账号公开性按现有账号存在性判断,不新增账号状态体系。 -- 对应[实施计划](./【实施计划】创作者关系与公开查询-2026-10-05.md)已补齐,覆盖 schema、DTO、事务、API、测试及回滚边界;开工时按计划核验当前代码和隔离开发环境。 - -## 验收标准 - -- [ ] 用户对唯一、不能自关注、重复写入与删除幂等。 -- [ ] 关注和粉丝方向正确,移除粉丝不取消本人对对方的关注。 -- [ ] 第三方不能代替他人写出边或删除入边。 -- [ ] 游客可读公开资料和列表,认证关系不混入共享公共缓存。 -- [ ] 他人的关注、粉丝列表即使带有效认证也只返回公开行,relationship 为 null;不额外查询访问者与各行用户之间的关系,自己的列表所需关系查询继续有效。 -- [ ] 分页、计数、失效账号过滤一致,响应不泄露私有账号字段。 -- [ ] 作者游戏筛选发生在最多 48 项截取前,过滤未公开、下架和删除作品;不增加游戏分页,未传作者的广场请求保持原有行为。 -- [ ] schema、迁移、表目录、生成绑定与共享 DTO 同步。 -- [ ] 正常、重复、并发和权限负向用例通过真实 API/SpacetimeDB 验证。 - -## 证据与停止点 - -领域/契约定向测试、schema 与 DDD 边界检查、编码/文档检查;通过 `npm run dev:api-server` 启动并验证 `/healthz` 后,以三个测试账号验证本人/对方/第三方关系。新增测试数据仅写隔离开发环境。 - -提供主规范逐条证据矩阵及未验证项;通过验收后才进入页面里程碑。当前产品规则已确认,无实现或运行时证据。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8898803a0..21352fc95 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,12 +2,13 @@ ## 2026-10-05 创作者主页与关注粉丝的产品边界 -- 已确认但未实现:桌面第四项“创作者主页”默认进入当前账号主页,“我的”移到第五项;他人的关注/粉丝列表公开可查看,自己或他人的两类列表均可点击用户进入其创作者主页。 +- 产品已确认:桌面第四项“创作者主页”默认进入当前账号主页,“我的”移到第五项;他人的关注/粉丝列表公开可查看,自己或他人的两类列表均可点击用户进入其创作者主页。 - 关注为单向关系,取消回关与移除粉丝分别影响不同方向;只有本人可移除自己的粉丝,自己不能关注自己。 - 他人的关注、粉丝列表统一只读:保留头像/昵称进入用户主页,不显示任何关系操作按钮;前后端均不额外检测访问者与列表用户的关注关系,已有缓存也不用于显示关系动作。 - 已确认:移动端入口为“游戏 / 创作者主页 / 我的”;自己的主页也只展示公开游戏;自己的关注列表取消后当前行暂留以便重新关注;移除粉丝二次确认,取消关注不弹确认。 - 用户明确本次不额外改造游戏目录分页:既有游戏广场和“我的游戏”保持现状,作者主页按作者过滤后沿用最多 48 项限制。新关注/粉丝列表的分页仍按主规范设计。 -- 行为真相见[创作者主页与关注粉丝合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同),工程落点见[工程设计](../../technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md)。当前仅完成文档,不代表功能上线。 +- 关系纯规则放在 `module-auth::creator`,现有认证服务通过 `services` feature 隔离宿主依赖,数据库 WASM 只使用纯规则。私有 `user_follow` 不参与认证快照替换;只通过受信服务过程读写,HTTP 操作者取认证身份。 +- 行为真相见[创作者主页与关注粉丝合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同),工程落点见[工程设计](../../technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md)。后端已完成隔离验证并由用户验收通过;页面工程验证通过,用户已要求提交并推送;证据归并工程设计,已完成临时计划删除,未上线。 ## 2026-10-03 首页自动建项与 AI 项目命名解耦(Issue 599) diff --git a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md index 380676d60..7843d8dfe 100644 --- a/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md +++ b/docs/technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md @@ -3,20 +3,20 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | ready(产品规则已确认,业务未实现、未部署) | +| Status | implemented(工程验证通过,用户已确认提交交付,未部署) | | Date | 2026-10-05 | | Parent Spec | [创作者主页与关注粉丝合同](../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同) | -本文明确主站创作者主页、关注关系和列表操作的工程边界,供后续按里程碑实施。产品行为以父规范为准;本次只提交文档,不改业务代码或数据库。 +本文明确主站创作者主页、关注关系和列表操作的工程边界。产品行为以父规范为准;后端实现及隔离验证已由用户验收通过,前端已完成工程验证,用户已要求提交并推送。 ## 当前实现与修改边界 -| 层 | 现有入口与拟修改范围 | +| 层 | 现有入口与修改范围 | | --- | --- | | 主站导航 | `src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`platformEntryActiveTypes.ts`、`src/routing/activeAppPageRoutes.ts`;增加创作者主页与关系列表 stage,保持既有创作/项目/游戏/我的入口 | | 详情与公共 UI | `src/components/game-distribution/GameDetailPage.tsx`、`packages/shared/src/components/GameDetailDisplay/`;扩展作者区域插槽,公共关注按钮和用户行放共享组件 | -| 新页面与访问层 | 在 `src/components/creator/`、`src/services/creatorClient.ts` 增加主页、关系列表和 HTTP 访问;复用现有认证、响应解包、错误处理和游戏卡表现 | -| DTO | `server-rs/crates/shared-contracts/src/creator.rs`、`packages/shared/src/contracts/creator.ts`(拟新增);分别在现有导出入口注册 | +| 新页面与访问层 | 在 `src/components/creator/`、`src/services/creatorProfileClient.ts` 增加主页、关系列表和 HTTP 访问;复用现有认证、响应解包、错误处理和游戏卡表现 | +| DTO | `server-rs/crates/shared-contracts/src/creator.rs`、`packages/shared/src/contracts/creator.ts`;分别在现有导出入口注册 | | 领域 | `module-auth` 内新增纯关系规则子模块;不新增账号系统,不把 HTTP、数据库和 UI 状态放入领域规则 | | 持久化 | `spacetime-module` 新增关系模块,复用 `auth/tables.rs` 中的用户身份;同步模块注册、`migration.rs`、表目录和生成绑定 | | Facade | `spacetime-client` 新增 creator typed facade 与 mapper,复用现有连接和受信服务身份 | @@ -30,7 +30,7 @@ ## 关系模型和授权 -拟新增私有表 `user_follow`: +私有表 `user_follow`: | 字段 | 类型与约束 | | --- | --- | @@ -41,6 +41,8 @@ 同一用户对最多一行,双方不得相同。删除后重新关注视为新的建立时间。不另存关系计数、昵称、头像或互关布尔值;读时按对应方向索引计算,并以 `user_account` 当前存在性过滤对端。当前账号表和公开查询没有独立的封禁/公开状态,不能凭空增加状态字段。 +`module-auth` 的现有认证服务通过默认启用的 `services` feature 保持原有导出;API 和 facade 显式启用该 feature。数据库仅依赖关闭默认 feature 的纯关系模块,避免将短信、网络和宿主运行时依赖引入 WASM。用户 ID 上限为 256 UTF-8 字节,游标上限为 4096 字节;空值、控制字符及超长输入拒绝,游标未知字段和非规范时间戳拒绝。 + 写入沿用项目受信 API 服务身份调用 procedure 的模式:HTTP 从已验证 access token 派生操作者 ID,procedure 先校验调用服务身份,再在事务内检查操作者、目标和方向。普通客户端不能直接传入任意操作者绕过认证。新表不进入认证全量快照替换流程,登录或刷新账号资料不能清空关系。 关注只插入 `actor → target`;取消只删除同一方向;移除粉丝只删除 `follower → actor`。事务回执返回 actor 相对目标的最新关系,不把移除粉丝误报为 actor 已取消关注。用户不再存在时不暴露其资料,计数和列表同步排除;本次不增加账号注销或清理任务。 @@ -109,13 +111,16 @@ type CreatorRelationshipResponse = { - 进入页面、返回和重新聚焦时回读;旧请求通过请求序号/取消机制隔离。认证变化清除私有关系缓存和旧页请求,不让上个账号响应覆盖新账号。 - 新主页路由接入页面标题、导航高亮、返回兜底和滚动位置恢复。移动点击区域至少 44px;加载、错误、空态分别呈现,不因关系失败阻断公开游戏阅读。 +- 顶层访问层登记到 `vite.config.ts` 的现役模块白名单及 ESLint 范围,开发代理显式转发 `/api/creators`。页面深链同步三份 nginx 模板与 Pingora 路由清单。 +- 浏览器历史条目仅保存 URL、访问者、已加载页数和滚动位置,不持久化关系列表或暂留行;新导航清除继承的恢复标记。同一舞台切换用户、页签以及返回时,应用重读 URL 并重新渲染。 + ## 兼容、验证和交付 新关系表从空数据开始;同步迁移登记、后端表目录和 Rust 生成绑定。新增 procedure 与 facade 同步发布,不手改生成文件;游戏查询新增的 authorId 是可选能力,旧 HTTP 请求继续有效。后端部署先于新前端,回滚前端不删除关系数据;回滚后端必须保留新表的兼容 schema,禁止删库回退。 -实施顺序和验收边界分别见[关系与公开查询里程碑](../project-memory/plans/【里程碑】创作者关系与公开查询-2026-10-05.md)、[主页与关系交互里程碑](../project-memory/plans/【里程碑】创作者主页与关系交互-2026-10-05.md)。首阶段[实施计划](../project-memory/plans/【实施计划】创作者关系与公开查询-2026-10-05.md)已就绪;前阶段验收通过后再制定下一阶段的单里程碑实施计划并进入实现。 +后端已由用户验收通过;页面工程验证完成后,用户要求提交并推送。已将持久结论和验证证据归并到主规范及本文,删除已完成的临时里程碑和实施计划。 -实施时按范围执行以下命令;本次文档提交只执行最后三项: +按范围执行以下命令,后端与前端证据见本文对应章节;本文保留逐项验收对照: ```bash cargo test --locked --manifest-path server-rs/Cargo.toml -p module-auth creator @@ -135,4 +140,43 @@ git diff --check `creator` 测试组为实施时新增的定向分组,届时核对实际匹配数量,零匹配不算通过;补充共享 DTO/UI 和 facade 的对应测试。host 单测不替代 SpacetimeDB 事务验证,运行时通过 `npm run dev:api-server` 启动并验证实际开发地址 `/healthz`,使用隔离开发库的 A/B/C 三账号完成权限、重复写入、双向关系与未知结果回读。 -浏览器覆盖桌面与 375px 移动,四类列表跳转、返回位置、自己的空游戏主页、互关后移除粉丝和登录切换。他人的关注、粉丝两类列表夹具均包含访问者已关注、未关注及访问者本人,断言一律无关系操作,后端响应 relationship 为 null 且不调用访问者关系投影,前端无补查/写请求;预置关系缓存也不得改变只读表现,点击用户进入主页仍可正常操作。游戏边界夹具应包含超过 48 项及其他作者作品,证明作者过滤在截取前,广场原有上限保持不变;关系列表用超过 50 项和同时间关系验证分页。所有执行结果回填里程碑证据,本次不宣称实现或运行时验收完成。 +浏览器覆盖桌面与 375px 移动,四类列表跳转、返回位置、自己的空游戏主页、互关后移除粉丝和登录切换。他人的关注、粉丝两类列表夹具均包含访问者已关注、未关注及访问者本人,断言一律无关系操作,后端响应 relationship 为 null 且不调用访问者关系投影,前端无补查/写请求;预置关系缓存也不得改变只读表现,点击用户进入主页仍可正常操作。游戏边界夹具包含超过 48 项及其他作者作品,证明作者过滤在截取前,广场原有上限保持不变;关系列表用超过 50 项和同时间关系验证分页。后端可重复运行 `python3 scripts/test-creator-runtime.py --database creator-hub-validation`;执行结果保留在以下证据矩阵中。 + +## 后端验证证据 + +用户已验收通过关系与公开查询实现,并授权进入页面阶段。 + + +| 对照项 | 验证方式 | 当前结果 | +| --- | --- | --- | +| 唯一键、方向、自关注与幂等 | 领域测试 + API/数据库回读 | 5 条纯规则测试通过;认证模块完整 45 条通过;运行时重复关注保持原时间、重关更新时间,移除粉丝只删入边 | +| 认证与第三方越权 | HTTP 负向用例 + 普通身份调用 procedure | 新增 HTTP 4 条通过;隔离 A/B/C 与普通数据库身份验证 400/401/403/404 和操作者归属 | +| 他人两类列表只读、无访问者关系查询 | 路由/投影定向测试 + API 响应 | 模块 1 条测试断言关系查询闭包不执行;facade 2 条通过;游客/他人两类列表运行时 relationship 均为 null,本人列表正常返回 | +| 公开字段、计数、分页、账号存在性 | 契约测试 + 多页隔离夹具 | DTO 2 条通过;58 个测试账号、55 个同时间双向关系、失效对端夹具,多页去重与 total/计数一致;错主人/类型游标返回 400 | +| 作者游戏过滤与原有 48 项边界 | game-distribution 回归 + API 夹具 | API 44 条与模块 4 条回归通过;55 个目标作者、55 个更晚其它作者、6 个非公开或无有效版本夹具,作者查询与旧广场各返回正确的 48 项 | +| Schema、迁移、绑定及认证快照兼容 | 生成检查、DDD 门禁 + 实际运行时回读 | WASM 构建和 CLI 生成通过;schema、DDD、生成绑定、38 组 DTO 对照通过;登录刷新前后关系导出相同 | +| 重复/并发写与结果未知 | 事务集成用例与写后查询 | 8 个同向并发只产生一行,相反动作并发后 API 与数据库一致;丢弃写回执后读取正式关系再执行后续意图 | + +2026-10-05 通过仓库启动器启动 `creator-hub-validation`,CLI/standalone 为 2.8.3(固定 commit 核对通过),API `/healthz` 返回 200。可复核运行时证据由 `scripts/test-creator-runtime.py` 的四组 PASS 断言产生,不依赖 host 测试模拟事务。类型、编码、文档索引及 diff 检查通过;本地依赖缺失已按原锁文件恢复,不改依赖版本。 + +后端证据边界:不包含生产发布;浏览器与前端交互另见下方前端证据。未知结果用例覆盖丢弃回执后的回读,不冒充真实网络故障注入。游戏夹具只验证公开目录投影,不写对象存储或测试游戏运行态。 + +## 前端验证证据 + +2026-10-05 工程验证通过,用户确认提交并推送;未部署生产。 + +| 合同条款 | 自动化和实际运行证据 | +| --- | --- | +| 桌面第四入口、移动三入口、标题和深链 | 导航/路由/标题 Vitest;三份 nginx SPA 检查及 Pingora 对照通过;实际浏览器直达与刷新 `/creators`、`/creators/connections`;游客直接访问他人关注/粉丝列表后,返回均落到所属作者主页 | +| 本人和他人主页、详情作者和公开游戏 | 主页测试覆盖裸路由登录、显式目标登录后保持、自己无关注按钮、空游戏及独立错误;已关注状态显示“已关注”,可访问名称为“取消关注”;真实 API 页面显示该作者 48 项,详情作者可返回自己主页 | +| 四类列表及他人只读 | 前端测试覆盖四类用户链接、预置关系缓存不改变他人只读;实际浏览器验证他人两类列表无按钮、无 relationship 补查请求 | +| 暂留、回关及移除方向 | 前端测试和真实浏览器验证取消后原行重新关注、刷新去行、粉丝回关/取消回关、移除确认及取消弹窗;移除后实际 API 确认自己的出边仍在 | +| 返回、分页与滚动 | 实际加载 40 行,进入用户主页后返回,重新读取两页并恢复滚动;历史条目只保存位置与页数,新导航不继承暂留行 | +| 失效、单飞和身份切换 | 状态测试覆盖重复点击、旧读覆盖保护、退出再登录同账号时丢弃旧写、未知结果只回读、仍未知禁写、回读恢复;页面测试覆盖切账号/目标的迟到响应 | +| 桌面和 375px 移动 | Chromium 实际验收无横向溢出、移动三入口、键盘 Enter 进入主页、访客关注仅唤起登录;窄屏按钮移至资料下方,共享组件测试覆盖长昵称和独立交互 | + +定向 Vitest 共 12 个文件、126 项通过;覆盖 creator 页面/状态/访问层、游戏访问层及三个游戏页面、导航、路由、标题、Vite 接入和共享 UI。当前 Node 原生 webstorage 与旧 jsdom 环境冲突,使用 `NODE_OPTIONS=--no-experimental-webstorage npm run test -- ...` 执行;不修改业务存储策略。`npm run typecheck`、`npm run build:raw`、定向 ESLint、Rust 格式检查、编码检查(5319 文件)、文档索引(245 份)及 `git diff --check` 均通过。 + +可重复浏览器脚本为 `scripts/test-creator-web.cjs`:先运行隔离后端脚本、再用仓库 `dev:web` 启动同库并把 `RUST_SERVER_TARGET` 指向该隔离 API;脚本从 `.app/dev-stack.json` 读取实际端口,仅接受 `creator-hub-validation` 和 loopback。通过 `CREATOR_PLAYWRIGHT_DIR` 指向临时 Playwright 安装,`PLAYWRIGHT_BROWSERS_PATH` 指向临时浏览器目录。截图输出到临时目录,不提交登录信息、数据库或构建产物。 + +验证边界:浏览器使用真实 API/SpacetimeDB;超时、旧请求与账号切换由可控前端测试覆盖,没有声称做真实断网注入。游戏夹具无发行包,不验证游玩加载;未部署生产,未用真实用户数据。本次交付按用户指令提交并推送,生产部署另行执行。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 670dd508d..a460496e9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -257,6 +257,12 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ## SpacetimeDB 表目录 +### `user_follow` + +- 私有关注关系表:`relationship_id` 为有向用户对主键,`follower_user_id`、`followee_user_id` 分别建立索引,`created_at` 记录首次建立时间。 +- 关注建立出边,取消删除出边,移除粉丝仅删除入边;重复写入不改变原时间。受信 API 身份调用事务过程,操作者来源于 HTTP 认证。 +- 独立于认证投影同步,不随账号快照清空;计数和列表均排除不存在的对端。他人列表不计算访问者关系。详见[创作者工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md)。 + 下列 `###` 标题是 schema guard 的机器可读表目录。新增、删除或改名表时必须同步这里。 ### `ai_result_reference` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 2814e0e7d..bda78f1fa 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -556,7 +556,7 @@ | 字段 | 值 | | --- | --- | | Version | 1.0 | -| Status | ready(产品规则与首阶段实施计划已就绪,未实现) | +| Status | implemented(工程验证通过,用户已确认提交交付,未部署) | | Date | 2026-10-05 | | 范围 | 主站导航、公开创作者主页、游戏详情作者入口、关注关系与列表管理 | @@ -564,7 +564,7 @@ 让用户从游戏详情进入任意作者主页、查看作者公开游戏、关注或取消关注,并在自己的列表管理关注和粉丝。验收以真实账号关系、计数、公开游戏过滤及跨页回读一致为准。 -2026-10-05 源码核验:`PlatformEntryActiveFlowShell` 的桌面导航实际为“创作 / 项目 / 游戏 / 我的”,移动端为“游戏 / 我的”;`activeAppPageRoutes` 已包含游戏列表、详情、游玩与作者管理路由。本文前部旧三入口说明不作为本提案的导航基线。现有 `GameDetailPage` 使用共享 `GameDetailDisplay`,作者身份已有稳定 `author.id`,昵称缺失时经公开用户接口查询;现有 schema 尚无关注关系表。 +2026-10-05 实现状态:桌面导航为“创作 / 项目 / 游戏 / 创作者主页 / 我的”,移动端为“游戏 / 创作者主页 / 我的”;`activeAppPageRoutes` 已接入创作者主页和关系列表。`GameDetailPage` 通过共享 `GameDetailDisplay` 的作者插槽提供主页链接和独立关注按钮。私有关注关系表、查询/写入 API 以及页面交互已实现;后端已由用户验收,前端工程验证通过,用户已要求提交并推送。本文前部旧入口说明不作为本专题的导航基线。 必须项为入口、主页、双向列表查看、关注/取消关注/重新关注、本人移除粉丝、公开游戏列表及详情跳转。风险项为关系方向、越权、公开/私有游戏隔离、并发请求与关系列表分页回读。私信、关注动态、通知、拉黑、推荐榜单、主页装修、关系隐私设置和既有游戏目录分页改造不纳入首版。 @@ -627,7 +627,7 @@ | `DELETE /api/creators/me/followers/{followerId}` | 当前登录用户移除自己的粉丝,幂等 | | 扩展 `GET /api/game-distribution/games?authorId=` | 服务端精确作者过滤,复用公开游戏可见性与卡片契约 | -以上为待实现合同,尚不是可调用 API。关注/粉丝列表默认 20、上限 50,按关系建立时间与稳定身份倒序,返回 `items/nextCursor/total`;游标绑定主人和列表类型,非法/错域游标返回 400。增删期间重新加载可能改变页内成员,前端按用户 ID 去重,重新关注产生新的建立时间。此分页规则仅用于新关系列表;游戏列表继续最多读取 48 项,不实现分页。 +以上接口已在本地隔离环境实现并验证,未部署。关注/粉丝列表默认 20、上限 50,按关系建立时间与稳定身份倒序,返回 `items/nextCursor/total`;游标绑定主人和列表类型,非法/错域游标返回 400。增删期间重新加载可能改变页内成员,前端按用户 ID 去重,重新关注产生新的建立时间。此分页规则仅用于新关系列表;游戏列表继续最多读取 48 项,不实现分页。 关系写入成功返回 200 与当前访问者关系;重复删除不存在的关系仍成功。不合法输入或关注自己返回 400,未认证 401,越权 403,目标不存在或不可公开 404。本人读取自己的关系列表时以有效认证批量附带各行关系;游客和他人的关注、粉丝列表该字段为 null,后者即使已登录也不计算逐行关系。带关系的响应禁用共享缓存,认证失效时清除访问者关系并按游客重新读取。 @@ -638,7 +638,7 @@ 1. 关系与公开查询:先交付真实持久化、权限、幂等、双向查询、准确计数、公开资料和作者游戏筛选;通过 API 与数据库运行时验收后进入页面接入。 2. 页面与交互:接入导航、主页、列表、详情作者链接、共享关注控件和移除弹窗,完成真实账号桌面/移动端闭环验收。 -每阶段先评审里程碑,再编写该阶段的单独实施计划;本次只交付规划,不进入业务实现。每阶段完成后给出证据并等待验收,不以单元测试替代真实运行时验证。 +每阶段先评审里程碑,再编写该阶段的单独实施计划。后端阶段已完成工程实现及隔离验证并由用户验收通过,页面阶段的工程与真实浏览器验证已通过,用户已确认提交交付;证据保留在工程设计,已完成的临时计划删除,不以单元测试替代真实运行时验证。 | 验收条款 | 证据要求 | | --- | --- | @@ -657,8 +657,8 @@ - 已确认:第四项默认进入自己主页;他人关注和粉丝列表公开可查看;自己或他人的关注/粉丝列表均可点击任意用户进入其创作者主页;他人的关注、粉丝列表统一只读,无关系操作且不检测访问者与列表用户的关系;本次不额外改造游戏目录分页,保持既有广场和“我的游戏”行为。 - 已确认:移动端采用“游戏 / 创作者主页 / 我的”;自己的主页也只列公开游戏;自己的关注列表取消后当前行暂留以便重新关注;移除粉丝二次确认,取消关注不弹确认。 -- 产品待定项已收口,接口命名、路由和关系列表分页按本文及工程设计落地。[关系与公开查询实施计划](./project-memory/plans/【实施计划】创作者关系与公开查询-2026-10-05.md)已补齐;前阶段验收后再制定页面阶段实施计划。本次只修改文档,不提前编写业务代码。 +- 产品待定项已收口,接口命名、路由和关系列表分页按本文及工程设计落地。[工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md#后端验证证据)已记录后端验证证据;用户已验收通过,页面按独立实施计划推进。 - 源码核验:现有公开用户查询按账号存在性返回资料,`user_account` 没有独立封禁/公开状态字段。首版复用该存在性口径,关系计数和列表一致排除不存在的账号,不在本功能内增加账号状态体系。 -- 本次证据只覆盖源码调研和规划文档检查;业务、数据库迁移与浏览器验收均未执行。 +- 后端证据:领域、DTO、facade、HTTP 定向测试、WASM 构建与绑定生成、schema/DDD/类型/编码/文档检查通过;隔离运行时脚本覆盖双向关系、权限、同时间分页、计数、账号快照、并发和作者游戏过滤。浏览器及生产部署未执行。 工程落点、DTO、事务与验证命令见[创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md)。本节是行为主规范,工程设计不另立产品规则。 diff --git a/packages/shared/src/components/CreatorControls/index.test.tsx b/packages/shared/src/components/CreatorControls/index.test.tsx new file mode 100644 index 000000000..8b84c6228 --- /dev/null +++ b/packages/shared/src/components/CreatorControls/index.test.tsx @@ -0,0 +1,46 @@ +/* @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { CreatorFollowButton, CreatorUserRow } from './index'; +afterEach(cleanup); +it('用户链接与关注按钮互相独立,长昵称完整且不嵌套交互元素', () => { + const navigate = vi.fn(), + follow = vi.fn(); + const name = '很长的创作者名字'.repeat(10); + render( + { + event.preventDefault(); + navigate(); + }} + actions={} + />, + ); + const link = screen.getByRole('link', { name }); + expect(link.querySelector('button')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '关注' })); + expect(follow).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + fireEvent.click(link); + expect(navigate).toHaveBeenCalledTimes(1); +}); +it('写入过程中按钮不可重复触发', () => { + const click = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button')); + expect(click).not.toHaveBeenCalled(); +}); + +it('已关注状态文案仍明确提供取消关注的可访问名称', () => { + const click = vi.fn(); + render( + , + ); + const button = screen.getByRole('button', { name: '取消关注' }); + expect(button.textContent).toBe('已关注'); + fireEvent.click(button); + expect(click).toHaveBeenCalledTimes(1); +}); diff --git a/packages/shared/src/components/CreatorControls/index.tsx b/packages/shared/src/components/CreatorControls/index.tsx new file mode 100644 index 000000000..c133c5199 --- /dev/null +++ b/packages/shared/src/components/CreatorControls/index.tsx @@ -0,0 +1,66 @@ +import './style.css'; + +import type { MouseEventHandler, ReactNode } from 'react'; + +export function CreatorFollowButton({ + followed, + pending = false, + disabled = false, + followLabel = '关注', + followedLabel = '取消关注', + onClick, +}: { + followed: boolean; + pending?: boolean; + disabled?: boolean; + followLabel?: string; + followedLabel?: string; + onClick: () => void; +}) { + return ( + + ); +} +export function CreatorUserRow({ + name, + avatarUrl, + subtitle, + href, + onClick, + actions, +}: { + name: string; + avatarUrl?: string | null; + subtitle?: string; + href: string; + onClick?: MouseEventHandler; + actions?: ReactNode; +}) { + return ( + + ); +} diff --git a/packages/shared/src/components/CreatorControls/style.css b/packages/shared/src/components/CreatorControls/style.css new file mode 100644 index 000000000..67f5f4da1 --- /dev/null +++ b/packages/shared/src/components/CreatorControls/style.css @@ -0,0 +1,77 @@ +.creator-user-row { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + min-width: 0; +} +.creator-user-link { + display: flex; + align-items: center; + gap: 0.75rem; + color: inherit; + text-decoration: none; + min-width: 0; + flex: 1; + min-height: 44px; +} +.creator-avatar { + display: grid; + place-items: center; + flex: none; + width: 48px; + height: 48px; + border-radius: 50%; + overflow: hidden; + background: var(--platform-panel-fill, #eee); + border: 1px solid var(--platform-surface-border, #ccc); +} +.creator-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} +.creator-user-copy { + display: grid; + min-width: 0; + gap: 0.25rem; + overflow-wrap: anywhere; +} +.creator-user-copy small { + color: var(--platform-text-soft); +} +.creator-user-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.creator-follow { + min-height: 44px; + padding: 0.5rem 1rem; + border: 1px solid var(--platform-surface-border, #ccc); + border-radius: 999px; + background: var(--platform-panel-fill, #fff); + color: inherit; + cursor: pointer; + white-space: nowrap; +} +.creator-follow:disabled { + opacity: 0.55; + cursor: default; +} +.creator-follow:focus-visible, +.creator-user-link:focus-visible { + outline: 2px solid #b85d3d; + outline-offset: 3px; +} +.creator-follow--active { + color: var(--platform-text-soft); +} +@media (max-width: 480px) { + .creator-user-row > .creator-user-link { + flex-basis: 100%; + } + .creator-user-row > .creator-user-actions { + margin-left: 60px; + } +} diff --git a/packages/shared/src/components/GameCard/index.tsx b/packages/shared/src/components/GameCard/index.tsx new file mode 100644 index 000000000..683e20c1b --- /dev/null +++ b/packages/shared/src/components/GameCard/index.tsx @@ -0,0 +1,38 @@ +import './style.css'; + +import type { ReactNode } from 'react'; + +import type { GameDistributionGame } from '../../contracts/gameDistribution'; +import { GameRatingSummaryText } from '../GameRatingSummaryText'; +export function GameCard({ + game, + cover, + onOpen, +}: { + game: GameDistributionGame; + cover: ReactNode; + onOpen: () => void; +}) { + return ( + + ); +} diff --git a/packages/shared/src/components/GameCard/style.css b/packages/shared/src/components/GameCard/style.css new file mode 100644 index 000000000..59225d1f9 --- /dev/null +++ b/packages/shared/src/components/GameCard/style.css @@ -0,0 +1,65 @@ +.game-card { + display: block; + min-width: 0; + overflow: hidden; + border: 1px solid var(--platform-surface-border); + border-radius: 1.45rem; + padding: 0; + background: var(--platform-panel-fill); + color: inherit; + cursor: pointer; + text-align: left; + box-shadow: var(--platform-panel-shadow); + transition: 180ms ease; +} +.game-card:hover { + border-color: rgba(199, 101, 61, 0.38); + box-shadow: 0 1rem 2.4rem rgba(104, 54, 36, 0.14); + transform: translateY(-3px); +} +.game-card__body { + display: block; + padding: 0.95rem 1rem 1.1rem; +} +.game-card__title-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} +.game-card__title-row strong { + overflow: hidden; + font-size: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} +.game-card__play-count { + flex: none; + color: var(--platform-text-soft); + font-size: 0.66rem; +} +.game-card__summary { + display: block; + overflow: hidden; + margin-top: 0.42rem; + color: var(--platform-text-base); + font-size: 0.78rem; + line-height: 1.55; + text-overflow: ellipsis; + white-space: nowrap; +} +.game-card__rating { + display: block; + margin-top: 0.42rem; + color: var(--platform-text-base); + font-size: 0.78rem; + line-height: 1.55; + overflow-wrap: anywhere; +} +.game-card__meta { + display: flex; + gap: 0.38rem; + margin-top: 0.85rem; + color: var(--platform-text-soft); + font-size: 0.7rem; +} diff --git a/packages/shared/src/components/GameDetailDisplay/index.tsx b/packages/shared/src/components/GameDetailDisplay/index.tsx index 1cd432067..7d555cd27 100644 --- a/packages/shared/src/components/GameDetailDisplay/index.tsx +++ b/packages/shared/src/components/GameDetailDisplay/index.tsx @@ -57,6 +57,7 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps { playCount?: number; }; /** 「游玩方式」文案;缺省「未标注操作方式」。 */ + authorContent?: ReactNode; controlsLabel?: string; /** 「当前版本」文案;缺省 —。 */ versionLabel?: string; @@ -199,6 +200,7 @@ export function GameDetailDisplay({ icon, assetUrls, renderAsset, + authorContent, controlsLabel, versionLabel, infoCards, @@ -234,14 +236,15 @@ export function GameDetailDisplay({ {rating ? (
{rating}
) : null} - {authorName ? ( -
- - 由 {authorName} 制作 -
- ) : null} + {authorContent ?? + (authorName ? ( +
+ + 由 {authorName} 制作 +
+ ) : null)}
{actions} {typeof game.playCount === 'number' ? ( diff --git a/packages/shared/src/components/index.ts b/packages/shared/src/components/index.ts index c60cc131a..516b5d610 100644 --- a/packages/shared/src/components/index.ts +++ b/packages/shared/src/components/index.ts @@ -247,3 +247,5 @@ export { // 后台管理端 chrome:页面壳、面板、按钮、提示、表格与表单外壳。 // 只承载表现与交互,路由、鉴权和后台接口仍留在 apps/admin-web。 export * from './admin'; +export { CreatorFollowButton, CreatorUserRow } from './CreatorControls'; +export { GameCard } from './GameCard'; diff --git a/packages/shared/src/contracts/creator.ts b/packages/shared/src/contracts/creator.ts new file mode 100644 index 000000000..b39ee8b89 --- /dev/null +++ b/packages/shared/src/contracts/creator.ts @@ -0,0 +1,37 @@ +export interface CreatorUser { + id: string; + publicUserCode: string; + displayName: string; + avatarUrl: string | null; +} + +export interface CreatorRelationship { + isSelf: boolean; + isFollowing: boolean; + isFollowedBy: boolean; +} + +export interface CreatorProfile { + user: CreatorUser; + followingCount: number; + followerCount: number; +} + +export interface CreatorConnection { + user: CreatorUser; + /** 列表方向的关系建立时间,RFC 3339。 */ + followedAt: string; + /** 仅本人访问自己的列表时返回关系。 */ + relationship: CreatorRelationship | null; +} + +export interface CreatorConnections { + items: CreatorConnection[]; + nextCursor: string | null; + total: number; +} + +export interface CreatorRelationshipResponse { + userId: string; + relationship: CreatorRelationship; +} diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 30d615d47..0b2c30e57 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,3 +1,4 @@ +export type * from './creator'; export * from './editorAgent'; export type * from './editorAudio'; export * from './gameCreationApp'; diff --git a/scripts/check-game-distribution-dto-parity.mjs b/scripts/check-game-distribution-dto-parity.mjs index a149af079..9f59ba38d 100644 --- a/scripts/check-game-distribution-dto-parity.mjs +++ b/scripts/check-game-distribution-dto-parity.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// 检查 game-distribution 的 Rust DTO 与手写 TS DTO 是否一致。 +// 检查游戏分发与创作者公开查询的 Rust DTO 与手写 TS DTO 是否一致。 // // 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。 // Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对: @@ -16,11 +16,19 @@ const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs'; const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts'; const ADMIN_RUST_FILE = 'server-rs/crates/shared-contracts/src/admin.rs'; const ADMIN_TS_FILE = 'apps/admin-web/src/api/adminApiTypes.ts'; +const CREATOR_RUST_FILE = 'server-rs/crates/shared-contracts/src/creator.rs'; +const CREATOR_TS_FILE = 'packages/shared/src/contracts/creator.ts'; const API_MODULE_FILE = 'server-rs/crates/api-server/src/modules/game_distribution.rs'; // [Rust 类型名, TS 类型名] const PAIRS = [ + ['CreatorUser', 'CreatorUser'], + ['CreatorRelationship', 'CreatorRelationship'], + ['CreatorProfile', 'CreatorProfile'], + ['CreatorConnection', 'CreatorConnection'], + ['CreatorConnections', 'CreatorConnections'], + ['CreatorRelationshipResponse', 'CreatorRelationshipResponse'], [ 'GameDistributionPublishMetadataSuggestionRequest', 'GameDistributionPublishMetadataSuggestionRequest', @@ -124,7 +132,7 @@ function renamedMember(value, kind, rename) { function rustDefinitions(source) { const result = new Map(); const pattern = - /\n(?(?:#\[[^\n]*\]\n)*)pub (?struct|enum) (?GameDistribution\w+|AdminGameReview\w*)(?[\s\S]*?)\n\}/g; + /\n(?(?:#\[[^\n]*\]\n)*)pub (?struct|enum) (?GameDistribution\w+|AdminGameReview\w*|Creator\w+)(?[\s\S]*?)\n\}/g; let match; while ((match = pattern.exec(source))) { const { attrs, kind, name, body } = match.groups; @@ -145,7 +153,7 @@ function rustDefinitions(source) { function tsDefinitions(source) { const result = new Map(); const objectPattern = - /export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*)(?: =)? \{([\s\S]*?)\n\};?/g; + /export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*|Creator\w+)(?: =)? \{([\s\S]*?)\n\};?/g; let match; while ((match = objectPattern.exec(source))) { const members = []; @@ -159,7 +167,7 @@ function tsDefinitions(source) { result.set(match[1], { kind: 'struct', members, required }); } const unionPattern = - /export type (GameDistribution\w+|AdminGameReview\w*) =\s*([^;]+);/g; + /export type (GameDistribution\w+|AdminGameReview\w*|Creator\w+) =\s*([^;]+);/g; while ((match = unionPattern.exec(source))) { if (result.has(match[1])) continue; result.set(match[1], { @@ -324,12 +332,12 @@ function insertedKeys(body) { const rust = rustDefinitions( fs.readFileSync(RUST_FILE, 'utf8') + '\n' + - fs.readFileSync(ADMIN_RUST_FILE, 'utf8'), + fs.readFileSync(ADMIN_RUST_FILE, 'utf8') + '\n' + fs.readFileSync(CREATOR_RUST_FILE, 'utf8'), ); const ts = tsDefinitions( fs.readFileSync(TS_FILE, 'utf8') + '\n' + - fs.readFileSync(ADMIN_TS_FILE, 'utf8'), + fs.readFileSync(ADMIN_TS_FILE, 'utf8') + '\n' + fs.readFileSync(CREATOR_TS_FILE, 'utf8'), ); const failures = []; const mappedRust = new Set(PAIRS.map(([rustName]) => rustName)); diff --git a/scripts/test-creator-runtime.py b/scripts/test-creator-runtime.py new file mode 100644 index 000000000..40c88d46f --- /dev/null +++ b/scripts/test-creator-runtime.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""创作者关系真实 API / SpacetimeDB 验证,仅允许显式指定的隔离本地库。 + +先用仓库 dev 启动器启动全新 creator-hub-validation 库和 API,启用开发密码注册。 +运行:python3 scripts/test-creator-runtime.py --database creator-hub-validation +测试凭据只保存在内存或隔离 data dir,不输出令牌,不触发外部媒体服务。 +""" +import argparse +import concurrent.futures +import hashlib +import json +import os +from pathlib import Path +import time +import urllib.error +import urllib.parse +import urllib.request + + +def request(url, method="GET", body=None, token=None, expected=200): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, method=method, data=data, headers=headers) + try: + response = urllib.request.urlopen(req, timeout=30) + except urllib.error.HTTPError as error: + response = error + assert response.status == expected, f"{method} {urllib.parse.urlsplit(url).path}: {response.status} != {expected}" + raw = response.read() + return (json.loads(raw) if raw else None), response.headers + + +def option(raw): + return raw[1] if raw[0] == 0 else None + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--database", required=True) + parser.add_argument("--dev-stack", default=".app/dev-stack.json") + args = parser.parse_args() + state = json.loads(Path(args.dev_stack).read_text()) + assert args.database == state["database"] == "creator-hub-validation", "只允许专用隔离库" + api = state["services"]["api-server"]["url"] + server = state["services"]["spacetime"]["url"] + for url in [api, server]: + parsed = urllib.parse.urlsplit(url) + assert parsed.scheme == "http" and parsed.hostname in ["127.0.0.1", "localhost", "::1"] + data_dir = Path(state["spacetimeDataDir"]) + service = json.loads((data_dir / "dev-api-identities/local-node.json").read_text()) + operator_path = data_dir / "creator-test-operator.json" + if operator_path.exists(): + operator = json.loads(operator_path.read_text()) + else: + operator, _ = request(server + "/v1/identity", "POST", {}) + fd = os.open(operator_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as file: + json.dump(operator, file) + scope = hashlib.sha256((server + "\n" + args.database).encode()).hexdigest() + secret = json.loads((data_dir / "dev-runtime-service-bootstrap-secrets" / (scope + ".json")).read_text())["secret"] + result, _ = request(server + "/v1/database/" + args.database + "/call/authorize_database_migration_operator", "POST", [{"bootstrap_secret": secret, "operator_identity_hex": operator["identity"], "note": "创作者隔离测试夹具"}], operator["token"]) + assert result[0], "测试迁移身份授权失败" + + def call(name, value, token=operator["token"]): + return request(server + "/v1/database/" + args.database + "/call/" + name, "POST", [value], token)[0] + + def export(table): + result = call("export_database_migration_to_file", {"include_tables": [table]}) + assert result[0], option(result[-1]) + return json.loads(option(result[2]))["tables"][0]["rows"] + + def fixtures(tables): + result = call("import_database_migration_incremental_from_file", {"migration_json": json.dumps({"schema_version": 1, "exported_at_micros": int(time.time() * 1e6), "tables": [{"name": k, "rows": v} for k, v in tables.items()]}), "include_tables": list(tables), "replace_existing": False, "dry_run": False}) + assert result[0], option(result[-1]) + + def login(number): + value, _ = request(api + "/api/auth/entry", "POST", {"purePhoneNumber": str(13900006000 + number), "password": "Creator-test-2026"}) + return value["user"]["id"], value["token"] + + def relation(viewer, target): + return request(api + "/api/creators/" + target + "/relationship", token=viewer[1])[0]["relationship"] + + def write(viewer, target, method="PUT", remove=False, expected=200): + path = "/api/creators/me/followers/" + target if remove else "/api/creators/" + target + "/follow" + return request(api + path, method, token=viewer[1], expected=expected)[0] + + def profile(user): + value, _ = request(api + "/api/creators/" + user) + assert set(value["user"]) == {"id", "publicUserCode", "displayName", "avatarUrl"} + return value + + request(api + "/healthz") + a, b, c = [login(n) for n in [1, 2, 3]] + # 重跑时先清理测试账号的相互出边;不清表或删除已有业务数据。 + for actor in [a, b, c]: + for target in [a, b, c]: + if actor != target: + write(actor, target[0], "DELETE") + assert relation(a, a[0]) == {"isSelf": True, "isFollowing": False, "isFollowedBy": False} + for kind in ["following", "followers"]: + empty, _ = request(api + f"/api/creators/{c[0]}/{kind}") + assert empty == {"items": [], "nextCursor": None, "total": 0} + write(a, b[0]) + first = next(row for row in export("user_follow") if row["follower_user_id"] == a[0] and row["followee_user_id"] == b[0]) + write(a, b[0]) + second = next(row for row in export("user_follow") if row["relationship_id"] == first["relationship_id"]) + assert first == second, "重复关注不能改时间" + write(b, a[0]) + assert relation(a, b[0]) == {"isSelf": False, "isFollowing": True, "isFollowedBy": True} + write(a, b[0], "DELETE", remove=True) + write(a, b[0], "DELETE", remove=True) + assert relation(a, b[0]) == {"isSelf": False, "isFollowing": True, "isFollowedBy": False} + write(b, a[0]) # 移除不是拉黑。 + write(a, b[0], "DELETE") + assert not relation(a, b[0])["isFollowing"] + write(a, b[0]) + third = next(row for row in export("user_follow") if row["relationship_id"] == first["relationship_id"]) + assert third["created_at"] != first["created_at"] + write(a, a[0], expected=400) + write(a, a[0], "DELETE", expected=400) + write(a, a[0], "DELETE", remove=True, expected=400) + write(a, "missing-creator", expected=404) + request(api + "/api/creators/" + b[0] + "/follow", "PUT", expected=401) + request(api + "/api/creators/" + b[0] + "/followers", token="invalid", expected=401) + request(api + "/api/creators/" + a[0] + "/followers/" + b[0], "DELETE", token=c[1], expected=404) + write(c, b[0], "DELETE", remove=True) + assert relation(a, b[0])["isFollowedBy"], "第三方移除不能改变他人入边" + stranger, _ = request(server + "/v1/identity", "POST", {}) + denied = call("follow_creator_and_return", {"actor_id": a[0], "target_id": b[0]}, stranger["token"]) + assert option(denied[0]) is None and option(denied[1]) == "forbidden" + denied = call("list_creator_connections_and_return", {"owner_id": a[0], "viewer_id": [0, a[0]], "kind": "following", "limit": 20, "cursor": None}, stranger["token"]) + assert option(denied[0]) is None and option(denied[1]) == "forbidden" + print("PASS 双向关系、幂等、重新关注、移除粉丝、自操作、认证和越权", flush=True) + + peers = [login(n) for n in range(10, 65)] + stamp = {"__timestamp_micros_since_unix_epoch__": 1700000000000000} + rows = [] + for peer, _ in peers: + for follower, followee in [(a[0], peer), (peer, a[0])]: + rows.append({"relationship_id": f"{len(follower.encode())}:{follower}{followee}", "follower_user_id": follower, "followee_user_id": followee, "created_at": stamp}) + # 不存在的对端必须同时从计数和列表排除。 + rows.extend([{"relationship_id": f"{len(x.encode())}:{x}{y}", "follower_user_id": x, "followee_user_id": y, "created_at": stamp} for x, y in [(a[0], "missing-peer"), ("missing-peer", a[0])]]) + fixtures({"user_follow": rows}) + for kind, count_key in [("following", "followingCount"), ("followers", "followerCount")]: + for viewer in [None, a[1], b[1], c[1]]: + seen, cursor, total = [], None, None + while True: + path = f"/api/creators/{a[0]}/{kind}?limit=20" + if cursor: + path += "&cursor=" + urllib.parse.quote(cursor) + result, headers = request(api + path, token=viewer) + total = result["total"] + assert headers["Cache-Control"] == "private, no-store" + for row in result["items"]: + assert (row["relationship"] is not None) == (viewer == a[1]) + assert set(row["user"]) == {"id", "publicUserCode", "displayName", "avatarUrl"} + seen.append(row["user"]["id"]) + cursor = result["nextCursor"] + if not cursor: + break + request(api + f"/api/creators/{b[0]}/{kind}?cursor=" + cursor, expected=400) + other = "followers" if kind == "following" else "following" + request(api + f"/api/creators/{a[0]}/{other}?cursor=" + cursor, expected=400) + assert len(seen) == len(set(seen)) == total == profile(a[0])[count_key] + assert total == 56 and "missing-peer" not in seen + before = export("user_follow") + a = login(1) # 触发认证快照刷新和持久化。 + assert export("user_follow") == before + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(lambda _: write(c, b[0]), range(8))) + assert len([row for row in export("user_follow") if row["follower_user_id"] == c[0] and row["followee_user_id"] == b[0]]) == 1 + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(lambda n: write(c, b[0], "PUT" if n % 2 else "DELETE"), range(8))) + db_has = any(row["follower_user_id"] == c[0] and row["followee_user_id"] == b[0] for row in export("user_follow")) + assert relation(c, b[0])["isFollowing"] == db_has + # 丢弃成功回执,模拟调用方不知道写入结果;正式读取决定下一动作。 + write(c, b[0]) + assert relation(c, b[0])["isFollowing"] + write(c, b[0], "DELETE") + assert not relation(c, b[0])["isFollowing"] + print("PASS 同时间多页、公开只读、失效用户过滤、认证快照保留、并发及未知结果回读", flush=True) + + none = {"none": []} + some = lambda value: {"some": value} + games, versions = [], [] + # 其它作者的更新作品占满旧广场前 48 项,目标作者仍应得到自己的 48 项。 + for prefix, owner, count in [("target", a[0], 55), ("other", b[0], 55), ("hidden", a[0], 6)]: + for n in range(count): + game_id = f"creator-web-fixture-{prefix}-{n:03}" + version_id = game_id + "-v1" + created = {"__timestamp_micros_since_unix_epoch__": 1800000000000000 + (1000 if prefix == "other" else 0) + n} + game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", cover_asset_id=none, author_name=none, author_avatar_url=none, device_support_desktop=True, device_support_mobile=True, device_support_touch=True, input_modes_json='["touch"]', orientation="any", publication_revision=1, active_version_id=some(version_id), visibility="published", play_count=0, created_at=created, updated_at=created, local_project_id=none, cover_object_key=none, screenshots_json=none, deleted_at=none) + version = dict(version_id=version_id, game_id=game_id, owner_user_id=owner, version_number=1, package_sha256="0" * 64, package_bytes=1, package_file_count=1, package_entry_path="index.html", status="published", review_reason=none, created_at=created, updated_at=created, local_project_id=none, package_object_key=none, package_manifest_json=none, entry_url=some("/games/game_" + hashlib.sha256(game_id.encode()).hexdigest()[:32] + "/"), reviewed_by_user_id=none, uploaded_at=none, submitted_at=none, reviewed_at=none, published_at=some(created), revoked_at=none, last_error_code=none, last_error_message=none, metadata_json=none) + if prefix == "hidden": + if n == 0: game["visibility"] = "unpublished" + if n == 1: game["visibility"] = "suspended" + if n == 2: game["deleted_at"] = some(created) + if n == 3: game["active_version_id"] = none + if n == 4: version["status"] = "pending_review" + if n == 5: version["game_id"] = "wrong-game" + games.append(game) + versions.append(version) + fixtures({"game_distribution_game": games, "game_distribution_version": versions}) + target, _ = request(api + "/api/game-distribution/games?authorId=" + a[0]) + assert len(target["games"]) == 48 and target["nextCursor"] is None + assert all(game["author"]["id"] == a[0] and game["id"].startswith("creator-web-fixture-target-") for game in target["games"]) + assert [game["id"] for game in target["games"]] == [f"creator-web-fixture-target-{n:03}" for n in range(54, 6, -1)] + gallery, _ = request(api + "/api/game-distribution/games") + assert len(gallery["games"]) == 48 and gallery["nextCursor"] is None + assert all(game["author"]["id"] == b[0] for game in gallery["games"]) + request(api + "/api/game-distribution/games?authorId=", expected=400) + empty, _ = request(api + "/api/game-distribution/games?authorId=" + c[0]) + assert empty["games"] == [] + print("PASS 作者过滤先于 48 项截取、公开版本隔离、旧广场上限与空作者校验", flush=True) + print("PASS 创作者后端隔离运行时验收", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-creator-web.cjs b/scripts/test-creator-web.cjs new file mode 100644 index 000000000..f7fbe8786 --- /dev/null +++ b/scripts/test-creator-web.cjs @@ -0,0 +1,258 @@ +/** 隔离创作者数据库的真实浏览器验收。先运行 test-creator-runtime.py,再启动同库 dev:web。 + * CREATOR_PLAYWRIGHT_DIR 指向临时安装的 playwright;浏览器、截图与登录信息均不入库。 + */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const state = JSON.parse(fs.readFileSync('.app/dev-stack.json', 'utf8')); +assert.equal(state.database, 'creator-hub-validation'); +const web = state.services.web.url; +const api = state.services['api-server'].url; +for (const url of [web, api]) assert.equal(new URL(url).hostname, '127.0.0.1'); +const runtimeRequire = process.env.CREATOR_PLAYWRIGHT_DIR + ? createRequire( + path.resolve(process.env.CREATOR_PLAYWRIGHT_DIR, 'package.json'), + ) + : require; +const { chromium } = runtimeRequire('playwright'); +async function json(endpoint, token, method = 'GET', body) { + const response = await fetch(api + endpoint, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + assert.ok(response.ok, `${method} ${endpoint}: ${response.status}`); + const result = await response.json(); + return result.data ?? result; +} +async function login(n) { + return json('/api/auth/entry', null, 'POST', { + purePhoneNumber: String(13900006000 + n), + password: 'Creator-test-2026', + }); +} +async function waitRows(page, count) { + await page.waitForFunction( + (n) => document.querySelectorAll('.creator-list > li').length === n, + count, + ); +} +(async () => { + const [a, b] = await Promise.all([login(1), login(2)]); + const browser = await chromium.launch({ headless: true }); + const errors = []; + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + }); + await context.addInitScript( + (token) => + localStorage.setItem('genarrative.auth.access-token.v1', token), + a.token, + ); + const page = await context.newPage(); + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(web + '/creators'); + await page.locator('.game-card').first().waitFor(); + assert.equal(await page.locator('.game-card').count(), 48); + assert.deepEqual( + await page + .getByRole('navigation', { name: '平台导航', exact: true }) + .getByRole('button') + .allTextContents(), + ['创作', '项目', '游戏', '创作者主页', '我的'], + ); + assert.equal( + await page + .locator('.creator-profile') + .getByRole('button', { name: '取消关注' }) + .count(), + 0, + ); + await page.screenshot({ path: '/tmp/creator-home-desktop.png' }); + await page.locator('.game-card').first().click(); + await page.locator('.game-detail-page').waitFor(); + await page.locator('.game-detail-page .creator-user-link').click(); + await page.locator('.creator-counts').waitFor(); + assert.equal(new URL(page.url()).searchParams.get('id'), a.user.id); + await page + .getByRole('navigation', { name: '平台导航', exact: true }) + .getByRole('button', { name: '创作者主页' }) + .click(); + assert.equal(new URL(page.url()).search, ''); // 同舞台切回本人,URL 改变也会重绘。 + await page.locator('.creator-counts a').first().click(); + await waitRows(page, 20); + await page.getByRole('button', { name: '加载更多' }).click(); + await waitRows(page, 40); + await page.locator('.platform-tab-panel').evaluate((el) => { + el.scrollTop = 650; + }); + await page.waitForTimeout(100); + await page.locator('.creator-list .creator-user-link').nth(7).click(); + await page.locator('.creator-counts').waitFor(); + await page.goBack(); + await waitRows(page, 40); + assert.ok( + await page + .locator('.platform-tab-panel') + .evaluate((el) => el.scrollTop > 200), + ); + const first = page.locator('.creator-list > li').first(); + const target = new URL( + await first.locator('a').getAttribute('href'), + web, + ).searchParams.get('id'); + await first.getByRole('button', { name: '取消关注' }).click(); + await first.getByRole('button', { name: '重新关注' }).waitFor(); + await first.getByRole('button', { name: '重新关注' }).click(); + await first.getByRole('button', { name: '取消关注' }).waitFor(); + await first.getByRole('button', { name: '取消关注' }).click(); + await first.getByRole('button', { name: '重新关注' }).waitFor(); + await page.getByRole('button', { name: '刷新列表' }).click(); + await page.waitForFunction( + (id) => + !document.querySelector(`.creator-list a[href="/creators?id=${id}"]`), + target, + ); + await json(`/api/creators/${target}/follow`, a.token, 'PUT'); + // 同一个列表舞台切换到粉丝,验证双向独立和移除确认。 + await page + .getByRole('navigation', { name: '关系列表' }) + .getByRole('link', { name: '粉丝', exact: true }) + .click(); + await page.getByRole('heading', { name: /我的粉丝/ }).waitFor(); + await waitRows(page, 20); + const fan = page.locator('.creator-list > li').first(); + const fanId = new URL( + await fan.locator('a').getAttribute('href'), + web, + ).searchParams.get('id'); + await fan.getByRole('button', { name: '取消关注' }).click(); + await fan.getByRole('button', { name: '回关' }).waitFor(); + await fan.getByRole('button', { name: '回关' }).click(); + await fan.getByRole('button', { name: '取消关注' }).waitFor(); + await fan.getByRole('button', { name: '移除粉丝' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: '取消', exact: true }).click(); + await fan.getByRole('button', { name: '移除粉丝' }).click(); + await dialog.getByRole('button', { name: '移除粉丝', exact: true }).click(); + await page.waitForFunction( + (id) => + !document.querySelector(`.creator-list a[href="/creators?id=${id}"]`), + fanId, + ); + const relation = await json(`/api/creators/${fanId}/relationship`, a.token); + assert.equal(relation.relationship.isFollowing, true); + assert.equal(relation.relationship.isFollowedBy, false); + console.log( + 'PASS desktop: navigation, author/game links, pagination/back/scroll, unfollow retention, follow-back and remove', + ); + // 上一步可能移除了 B 的入边,重建只读列表验收所需的双方关系。 + await json(`/api/creators/${a.user.id}/follow`, b.token, 'PUT'); + await json(`/api/creators/${b.user.id}/follow`, a.token, 'PUT'); + // 他人两类列表只有主页链接,不读取访问者与列表用户的关系。 + for (const tab of ['following', 'followers']) { + const requests = []; + const record = (request) => { + if (request.url().includes('/relationship')) + requests.push(request.url()); + }; + page.on('request', record); + await page.goto(`${web}/creators/connections?id=${b.user.id}&tab=${tab}`); + await page.locator('.creator-list > li').first().waitFor(); + assert.equal(await page.locator('.creator-list button').count(), 0); + assert.equal(requests.length, 0); + page.off('request', record); + await page.locator('.creator-list a').first().click(); + await page.locator('.creator-counts').waitFor(); + } + await page.goto(`${web}/creators?id=${b.user.id}`); + await page.locator('.creator-relation button').first().waitFor(); + const follow = page.locator('.creator-relation button').first(); + await page.waitForFunction( + () => !document.querySelector('.creator-relation button')?.disabled, + ); + const oldLabel = await follow.innerText(); + await follow.click(); + await page.waitForFunction( + (label) => + document.querySelector('.creator-relation button')?.textContent !== + label && + !document.querySelector('.creator-relation button')?.disabled, + oldLabel, + ); + await follow.click(); + console.log( + 'PASS public lists: both read-only, no relationship requests, all users link to creator pages', + ); + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto(web + '/creators'); + await page.locator('.creator-profile').waitFor(); + const welcome = page.getByRole('button', { name: '好', exact: true }); + if (await welcome.isVisible()) await welcome.click(); + assert.deepEqual( + await page + .getByRole('navigation', { name: '移动平台导航' }) + .getByRole('button') + .allTextContents(), + ['游戏', '创作者主页', '我的'], + ); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ); + await page.screenshot({ path: '/tmp/creator-home-mobile.png' }); + await page.locator('.creator-counts a').last().click(); + await page.locator('.creator-list > li').first().waitFor(); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ); + await page.screenshot({ path: '/tmp/creator-followers-mobile.png' }); + await page.locator('.creator-list a').first().focus(); + await page.keyboard.press('Enter'); + await page.locator('.creator-counts').waitFor(); + const guest = await browser.newContext({ + viewport: { width: 375, height: 812 }, + }); + const guestPage = await guest.newPage(); + await guestPage.goto(`${web}/creators?id=${b.user.id}`); + await guestPage.locator('.creator-profile').waitFor(); + const guestWelcome = guestPage.getByRole('button', { + name: '好', + exact: true, + }); + if (await guestWelcome.isVisible()) await guestWelcome.click(); + for (const tab of ['following', 'followers']) { + await guestPage.goto( + `${web}/creators/connections?id=${b.user.id}&tab=${tab}`, + ); + await guestPage.locator('.creator-list > li').first().waitFor(); + if (await guestWelcome.isVisible()) await guestWelcome.click(); + assert.equal(await guestPage.locator('.creator-list button').count(), 0); + await guestPage + .getByRole('button', { name: '返回', exact: true }) + .click(); + await guestPage.locator('.creator-profile').waitFor(); + assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id); + } + await guestPage.getByRole('button', { name: '关注', exact: true }).click(); + await guestPage.getByRole('dialog').waitFor(); + assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id); + assert.deepEqual(errors, []); + console.log( + 'PASS mobile 375px: three navigation entries, no horizontal overflow, keyboard links, guest login without follow', + ); + } finally { + await browser.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index ea9aba518..6d2625bc2 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2879,6 +2879,7 @@ dependencies = [ name = "module-auth" version = "0.1.0" dependencies = [ + "base64", "platform-auth", "serde", "serde_json", @@ -5471,6 +5472,7 @@ dependencies = [ "log", "module-ai", "module-assets", + "module-auth", "module-editor-agent", "module-game-distribution", "module-runtime", diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 6292a9490..f8c2b6308 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -21,7 +21,7 @@ rmcp = { workspace = true, features = ["server", "transport-streamable-http-serv webp = { workspace = true } module-ai = { workspace = true } module-assets = { workspace = true, features = ["server-service"] } -module-auth = { workspace = true } +module-auth = { workspace = true, features = ["services"] } module-editor-agent = { workspace = true } module-game-distribution = { workspace = true } module-runtime = { workspace = true } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 732e0d046..031712b71 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -50,6 +50,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::external_api::router(state.clone())) .merge(modules::frontend_runtime_config::router(state.clone())) .merge(modules::game_distribution::router(state.clone())) + .merge(modules::creator::router(state.clone())) .merge(modules::assets::router(state.clone())) .merge(modules::editor_project::router(state.clone())) .merge(modules::platform::router(state.clone())) diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index f03412553..4fe0fd5f0 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod assets; pub mod auth; pub mod client_downloads; +pub mod creator; pub mod editor_project; pub mod external_api; pub mod external_generation; diff --git a/server-rs/crates/api-server/src/modules/creator.rs b/server-rs/crates/api-server/src/modules/creator.rs new file mode 100644 index 000000000..08898f900 --- /dev/null +++ b/server-rs/crates/api-server/src/modules/creator.rs @@ -0,0 +1,343 @@ +use axum::{ + Router, + extract::{Extension, Path, Query, Request, State, rejection::QueryRejection}, + http::{HeaderValue, StatusCode, header}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{delete, get, put}, +}; +use module_auth::creator::{ + ConnectionCursor, ConnectionKind, CreatorAction, normalize_user_id, parse_list_limit, +}; +use serde::Deserialize; +use spacetime_client::{SpacetimeClientError, creator::CreatorListInput}; + +use crate::{ + api_response::json_success_body, auth::optional_access_token_from_headers, + http_error::AppError, request_context::RequestContext, state::AppState, +}; + +#[derive(Clone)] +struct CreatorViewer(Option); + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct ConnectionsQuery { + limit: Option, + cursor: Option, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/api/creators/{user_id}", get(profile)) + .route("/api/creators/{user_id}/following", get(following)) + .route("/api/creators/{user_id}/followers", get(followers)) + .route("/api/creators/{user_id}/relationship", get(relationship)) + .route( + "/api/creators/{user_id}/follow", + put(follow).delete(unfollow), + ) + .route( + "/api/creators/me/followers/{user_id}", + delete(remove_follower), + ) + .route_layer(middleware::from_fn_with_state(state, creator_auth)) + .route_layer(middleware::from_fn(no_store)) +} + +async fn no_store(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + response +} + +async fn creator_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let ctx = request + .extensions() + .get::() + .ok_or_else(|| AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR))?; + let authenticated = optional_access_token_from_headers( + &state, + request.uri().path().into(), + request.headers().clone(), + ctx.request_id().into(), + ) + .await?; + request.extensions_mut().insert(CreatorViewer( + authenticated.map(|auth| auth.claims().user_id().to_owned()), + )); + Ok(next.run(request).await) +} + +fn input_error(error: impl std::fmt::Display) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) +} +fn actor(viewer: CreatorViewer) -> Result { + viewer + .0 + .ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED)) +} +fn database_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Procedure(code) => match code.as_str() { + "invalid_input" => StatusCode::BAD_REQUEST, + "user_not_found" => StatusCode::NOT_FOUND, + "forbidden" => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + // 不把底层连接信息或内部身份送到公开响应。 + AppError::from_status(status) +} + +async fn profile( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result { + let id = normalize_user_id(&id).map_err(input_error)?; + let profile = state + .spacetime_client() + .creator_profile(id) + .await + .map_err(database_error)?; + Ok(json_success_body(Some(&ctx), profile).into_response()) +} + +async fn relationship( + State(state): State, + Extension(ctx): Extension, + Extension(viewer): Extension, + Path(id): Path, +) -> Result { + let actor_id = actor(viewer)?; + let id = normalize_user_id(&id).map_err(input_error)?; + let relation = state + .spacetime_client() + .creator_relationship(actor_id, id) + .await + .map_err(database_error)?; + Ok(json_success_body(Some(&ctx), relation).into_response()) +} + +fn list_input( + id: &str, + viewer: CreatorViewer, + query: Result, QueryRejection>, + kind: ConnectionKind, +) -> Result { + let id = normalize_user_id(id).map_err(input_error)?; + let Query(query) = query.map_err(|_| input_error("列表查询参数不合法"))?; + let limit = parse_list_limit(query.limit.as_deref()).map_err(input_error)? as u32; + if let Some(cursor) = &query.cursor { + ConnectionCursor::decode(cursor, &id, kind).map_err(input_error)?; + } + Ok(CreatorListInput { + owner_id: id, + viewer_id: viewer.0, + kind, + limit, + cursor: query.cursor, + }) +} + +async fn following( + State(state): State, + Extension(ctx): Extension, + Extension(viewer): Extension, + Path(id): Path, + query: Result, QueryRejection>, +) -> Result { + connections( + state, + ctx, + list_input(&id, viewer, query, ConnectionKind::Following)?, + ) + .await +} +async fn followers( + State(state): State, + Extension(ctx): Extension, + Extension(viewer): Extension, + Path(id): Path, + query: Result, QueryRejection>, +) -> Result { + connections( + state, + ctx, + list_input(&id, viewer, query, ConnectionKind::Followers)?, + ) + .await +} +async fn connections( + state: AppState, + ctx: RequestContext, + input: CreatorListInput, +) -> Result { + let list = state + .spacetime_client() + .creator_connections(input) + .await + .map_err(database_error)?; + Ok(json_success_body(Some(&ctx), list).into_response()) +} + +async fn mutate( + state: AppState, + ctx: RequestContext, + viewer: CreatorViewer, + id: String, + action: CreatorAction, +) -> Result { + let actor_id = actor(viewer)?; + let command = + module_auth::creator::prepare_mutation(&actor_id, &id, action).map_err(input_error)?; + let response = state + .spacetime_client() + .mutate_creator_relationship(command.actor_id, command.target_id, action) + .await + .map_err(database_error)?; + Ok(json_success_body(Some(&ctx), response).into_response()) +} +async fn follow( + State(state): State, + Extension(ctx): Extension, + Extension(viewer): Extension, + Path(id): Path, +) -> Result { + mutate(state, ctx, viewer, id, CreatorAction::Follow).await +} +async fn unfollow( + State(state): State, + Extension(ctx): Extension, + Extension(viewer): Extension, + Path(id): Path, +) -> Result { + mutate(state, ctx, viewer, id, CreatorAction::Unfollow).await +} +async fn remove_follower( + State(state): State, + Extension(ctx): Extension, + Extension(viewer): Extension, + Path(id): Path, +) -> Result { + mutate(state, ctx, viewer, id, CreatorAction::RemoveFollower).await +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + #[tokio::test] + async fn creator_private_routes_reject_anonymous_and_forged_actor_headers() { + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + for (method, path) in [ + ("GET", "/api/creators/b/relationship"), + ("PUT", "/api/creators/b/follow"), + ("DELETE", "/api/creators/b/follow"), + ("DELETE", "/api/creators/me/followers/b"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("x-genarrative-authenticated-user-id", "a") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} {path}" + ); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + } + } + + #[tokio::test] + async fn creator_public_routes_reject_invalid_bearer_instead_of_downgrading() { + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + for path in [ + "/api/creators/b", + "/api/creators/b/following", + "/api/creators/b/followers", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(path) + .header(header::AUTHORIZATION, "Bearer invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + } + + #[tokio::test] + async fn creator_lists_reject_invalid_limit_cursor_and_viewer_override_before_database() { + let app = + crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap()); + for query in [ + "limit=0", + "limit=51", + "limit=1.5", + "cursor=invalid", + "viewerId=b", + "limit=1&limit=2", + ] { + for kind in ["following", "followers"] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/creators/b/{kind}?{query}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{kind} {query}"); + } + } + } + + #[test] + fn creator_error_mapping_is_explicit_and_hides_internal_failures() { + for (code, status) in [ + ("user_not_found", StatusCode::NOT_FOUND), + ("forbidden", StatusCode::FORBIDDEN), + ("invalid_input", StatusCode::BAD_REQUEST), + ( + "secret connection details", + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let error = database_error(SpacetimeClientError::Procedure(code.into())); + assert_eq!(error.status_code(), status); + assert!(!error.message().contains("secret")); + } + } +} diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index bc7905ed6..0a4d36b32 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -173,6 +173,8 @@ struct GameListQuery { #[serde(alias = "keyword")] search: Option, category: Option, + #[serde(rename = "authorId")] + author_id: Option, } #[derive(Debug, Deserialize)] @@ -1009,12 +1011,21 @@ async fn list_games( Extension(ctx): Extension, Query(query): Query, ) -> Result, AppError> { + let author_id = query + .author_id + .as_deref() + .map(module_auth::creator::normalize_user_id) + .transpose() + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) + })?; let games = state .spacetime_client() .list_game_distribution_games(GameDistributionPublicGameListRecordInput { search: normalize_optional(query.search), category: normalize_optional(query.category), limit: MAX_LIST_LIMIT, + author_id, }) .await .map_err(map_spacetime_error)?; diff --git a/server-rs/crates/module-auth/Cargo.toml b/server-rs/crates/module-auth/Cargo.toml index 082ac2780..602e6da65 100644 --- a/server-rs/crates/module-auth/Cargo.toml +++ b/server-rs/crates/module-auth/Cargo.toml @@ -4,14 +4,19 @@ edition.workspace = true version.workspace = true license.workspace = true +[features] +default = ["services"] +services = ["dep:platform-auth", "dep:shared-kernel", "dep:sha2", "dep:time", "dep:tracing"] + [dependencies] -platform-auth = { workspace = true } -shared-kernel = { workspace = true } +base64 = { workspace = true } +platform-auth = { workspace = true, optional = true } +shared-kernel = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } -sha2 = { workspace = true } -time = { workspace = true, features = ["formatting", "parsing"] } -tracing = { workspace = true } +sha2 = { workspace = true, optional = true } +time = { workspace = true, features = ["formatting", "parsing"], optional = true } +tracing = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/server-rs/crates/module-auth/src/creator.rs b/server-rs/crates/module-auth/src/creator.rs new file mode 100644 index 000000000..80577324f --- /dev/null +++ b/server-rs/crates/module-auth/src/creator.rs @@ -0,0 +1,284 @@ +//! 创作者关系纯规则;不依赖账号服务、数据库或 HTTP。 +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::{Deserialize, Serialize}; + +pub const MAX_USER_ID_BYTES: usize = 256; +pub const MAX_CURSOR_BYTES: usize = 4096; +pub const DEFAULT_LIST_LIMIT: usize = 20; +pub const MAX_LIST_LIMIT: usize = 50; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CreatorInputError { + InvalidUserId, + SelfRelationship, + InvalidLimit, + InvalidCursor, +} + +impl std::fmt::Display for CreatorInputError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidUserId => "用户 ID 不合法", + Self::SelfRelationship => "不能对自己操作关注关系", + Self::InvalidLimit => "列表数量必须是 1 到 50 的整数", + Self::InvalidCursor => "分页游标不合法或不属于当前列表", + }) + } +} +impl std::error::Error for CreatorInputError {} + +pub fn normalize_user_id(raw: &str) -> Result { + let id = raw.trim(); + if id.is_empty() || id.len() > MAX_USER_ID_BYTES || id.chars().any(char::is_control) { + return Err(CreatorInputError::InvalidUserId); + } + Ok(id.to_owned()) +} + +/// 调用方先规范化 ID;字节长度前缀避免分隔符或多字节 ID 碰撞。 +pub fn relationship_id(follower: &str, followee: &str) -> String { + format!("{}:{follower}{followee}", follower.len()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CreatorAction { + Follow, + Unfollow, + RemoveFollower, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatorMutation { + pub actor_id: String, + pub target_id: String, + pub follower_id: String, + pub followee_id: String, + pub relationship_id: String, + pub insert: bool, +} + +pub fn prepare_mutation( + actor: &str, + target: &str, + action: CreatorAction, +) -> Result { + let actor_id = normalize_user_id(actor)?; + let target_id = normalize_user_id(target)?; + if actor_id == target_id { + return Err(CreatorInputError::SelfRelationship); + } + let (follower_id, followee_id) = match action { + CreatorAction::RemoveFollower => (target_id.clone(), actor_id.clone()), + _ => (actor_id.clone(), target_id.clone()), + }; + Ok(CreatorMutation { + relationship_id: relationship_id(&follower_id, &followee_id), + actor_id, + target_id, + follower_id, + followee_id, + insert: action == CreatorAction::Follow, + }) +} + +pub fn parse_list_limit(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(DEFAULT_LIST_LIMIT); + }; + if raw.is_empty() || raw.len() > 2 || !raw.bytes().all(|b| b.is_ascii_digit()) { + return Err(CreatorInputError::InvalidLimit); + } + let limit: usize = raw.parse().map_err(|_| CreatorInputError::InvalidLimit)?; + if !(1..=MAX_LIST_LIMIT).contains(&limit) { + return Err(CreatorInputError::InvalidLimit); + } + Ok(limit) +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ConnectionKind { + Following, + Followers, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConnectionCursor { + version: u8, + owner_id: String, + kind: ConnectionKind, + created_at_micros: String, + relationship_id: String, +} + +impl ConnectionCursor { + pub fn new( + owner_id: String, + kind: ConnectionKind, + micros: i64, + relationship_id: String, + ) -> Self { + Self { + version: 1, + owner_id, + kind, + created_at_micros: micros.to_string(), + relationship_id, + } + } + + pub fn encode(&self) -> String { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(self).expect("固定结构的游标可序列化")) + } + + pub fn decode(raw: &str, owner: &str, kind: ConnectionKind) -> Result { + let invalid = CreatorInputError::InvalidCursor; + if raw.len() > MAX_CURSOR_BYTES { + return Err(invalid); + } + let bytes = URL_SAFE_NO_PAD.decode(raw).map_err(|_| invalid)?; + let cursor: Self = serde_json::from_slice(&bytes).map_err(|_| invalid)?; + let micros: i64 = cursor.created_at_micros.parse().map_err(|_| invalid)?; + if cursor.version != 1 + || cursor.owner_id != owner + || cursor.kind != kind + || micros.to_string() != cursor.created_at_micros + || cursor.relationship_id.is_empty() + || cursor.relationship_id.len() > 2 * MAX_USER_ID_BYTES + 4 + { + return Err(invalid); + } + let (length, pair) = cursor.relationship_id.split_once(':').ok_or(invalid)?; + let length: usize = length.parse().map_err(|_| invalid)?; + let follower = pair.get(..length).ok_or(invalid)?; + let followee = pair.get(length..).ok_or(invalid)?; + if normalize_user_id(follower).map_err(|_| invalid)? != follower + || normalize_user_id(followee).map_err(|_| invalid)? != followee + || follower == followee + || relationship_id(follower, followee) != cursor.relationship_id + || match kind { + ConnectionKind::Following => follower != owner, + ConnectionKind::Followers => followee != owner, + } + { + return Err(invalid); + } + Ok(cursor) + } + + pub fn is_after(&self, micros: i64, key: &str) -> bool { + // 私有字段只可由构造函数或严格 decode 生成。 + let at: i64 = self.created_at_micros.parse().expect("已校验的游标时间"); + (micros, key) < (at, self.relationship_id.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creator_keys_do_not_collide_and_preserve_direction() { + assert_ne!(relationship_id("a", "bc"), relationship_id("ab", "c")); + assert_ne!(relationship_id("a:", "b"), relationship_id("a", ":b")); + assert_ne!(relationship_id("甲", "乙"), relationship_id("乙", "甲")); + assert_eq!(relationship_id("甲", "乙"), "3:甲乙"); + } + + #[test] + fn creator_removing_follower_only_targets_incoming_edge() { + let follow = prepare_mutation("a", "b", CreatorAction::Follow).unwrap(); + let unfollow = prepare_mutation("a", "b", CreatorAction::Unfollow).unwrap(); + let remove = prepare_mutation("a", "b", CreatorAction::RemoveFollower).unwrap(); + assert_eq!(follow.relationship_id, unfollow.relationship_id); + assert_eq!(remove.relationship_id, relationship_id("b", "a")); + assert_eq!(remove.target_id, "b"); + assert!(follow.insert); + assert!(!unfollow.insert); + assert!(!remove.insert); + for action in [ + CreatorAction::Follow, + CreatorAction::Unfollow, + CreatorAction::RemoveFollower, + ] { + assert_eq!( + prepare_mutation(" a ", "a", action), + Err(CreatorInputError::SelfRelationship) + ); + } + } + + #[test] + fn creator_input_limits_reject_invalid_and_unbounded_values() { + for id in ["", " ", "a\0b", &"甲".repeat(86)] { + assert!(normalize_user_id(id).is_err()); + } + assert_eq!(normalize_user_id(" 甲 ").unwrap(), "甲"); + assert_eq!(parse_list_limit(None).unwrap(), 20); + assert_eq!(parse_list_limit(Some("50")).unwrap(), 50); + for limit in ["", "0", "51", "-1", "+1", "1.5", " 2", "1000000000000"] { + assert!(parse_list_limit(Some(limit)).is_err()); + } + } + + #[test] + fn creator_cursor_binds_owner_kind_and_orders_equal_timestamps() { + let cursor = ConnectionCursor::new( + "a".into(), + ConnectionKind::Following, + 9007199254740993, + "1:ac".into(), + ); + let encoded = cursor.encode(); + let restored = ConnectionCursor::decode(&encoded, "a", ConnectionKind::Following).unwrap(); + assert_eq!(restored, cursor); + assert!(restored.is_after(9007199254740993, "1:ab")); + assert!(!restored.is_after(9007199254740993, "1:ac")); + assert!(!restored.is_after(9007199254740994, "1:ab")); + assert!(ConnectionCursor::decode(&encoded, "b", ConnectionKind::Following).is_err()); + assert!(ConnectionCursor::decode(&encoded, "a", ConnectionKind::Followers).is_err()); + } + + #[test] + fn creator_cursor_rejects_malformed_unknown_and_oversized_payloads() { + let cursor = ConnectionCursor::new("a".into(), ConnectionKind::Followers, 3, "1:ba".into()); + let mut json = serde_json::to_value(cursor).unwrap(); + for bad in [ + serde_json::json!(3), + serde_json::json!("+3"), + serde_json::json!("03"), + serde_json::json!("9223372036854775808"), + ] { + json["createdAtMicros"] = bad; + assert!( + ConnectionCursor::decode( + &URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()), + "a", + ConnectionKind::Followers + ) + .is_err() + ); + } + json["createdAtMicros"] = serde_json::json!("3"); + json["extra"] = serde_json::json!(true); + assert!( + ConnectionCursor::decode( + &URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap()), + "a", + ConnectionKind::Followers + ) + .is_err() + ); + for raw in ["!", "", &"a".repeat(MAX_CURSOR_BYTES + 1)] { + assert!(ConnectionCursor::decode(raw, "a", ConnectionKind::Followers).is_err()); + } + for key in ["broken", "999:ba", "01:ba", "1:aa", "1:bc", "1:甲a"] { + let cursor = + ConnectionCursor::new("a".into(), ConnectionKind::Followers, 3, key.into()); + assert!( + ConnectionCursor::decode(&cursor.encode(), "a", ConnectionKind::Followers).is_err() + ); + } + } +} diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index c69c9cc7d..9d56bd3ad 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1,5098 +1,28 @@ +#[cfg(feature = "services")] mod application; +#[cfg(feature = "services")] mod commands; +#[cfg(feature = "services")] mod domain; +#[cfg(feature = "services")] mod errors; +#[cfg(feature = "services")] mod events; +#[cfg(feature = "services")] pub use application::*; +#[cfg(feature = "services")] pub use commands::*; +#[cfg(feature = "services")] pub use domain::*; +#[cfg(feature = "services")] pub use errors::*; +#[cfg(feature = "services")] pub use events::*; -use std::{ - collections::{HashMap, HashSet}, - sync::{ - Arc, Mutex, - atomic::{AtomicU64, Ordering}, - }, -}; +pub mod creator; -use platform_auth::{ - SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password, - verify_password, -}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use shared_kernel::{ - build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, - normalize_optional_string, normalize_required_string, parse_rfc3339, -}; -use time::{Duration, OffsetDateTime}; -use tracing::{info, warn}; - -const DEFAULT_PHONE_VERIFY_CODE_SALT: &str = "genarrative-phone-verify-code-v1"; -const PHONE_CODE_RESERVATION_MARKER: &str = "__genarrative_phone_code_reservation__"; -const MAX_ACTIVE_WECHAT_AUTH_STATES: usize = 1024; -const REFRESH_SESSION_STALE_RETENTION: Duration = Duration::days(1); -const MAX_REFRESH_SESSIONS: usize = 8_192; -const MAX_PHONE_CODES: usize = 4_096; - -#[derive(Clone, Debug)] -pub struct InMemoryAuthStore { - inner: Arc>, - revision: Arc, -} - -#[derive(Debug)] -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, Serialize, Deserialize)] -struct StoredPasswordUser { - user: AuthUser, - password_hash: String, - #[serde(default)] - password_login_enabled: bool, - phone_number: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct StoredRefreshSession { - session: RefreshSessionRecord, -} - -#[derive(Clone, Debug)] -struct StoredPhoneCode { - phone_number: String, - scene: PhoneAuthScene, - verify_code_hash: String, - expires_at: String, - last_sent_at: String, - failed_attempts: u32, - provider_out_id: Option, -} - -#[derive(Clone, Debug)] -struct StoredWechatAuthState { - state: WechatAuthStateRecord, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct StoredWechatIdentity { - user_id: String, - provider_uid: String, - provider_union_id: Option, - display_name: Option, - avatar_url: Option, - session_key: Option, -} - -fn hydrate_private_auth_fields( - state: &InMemoryAuthStoreState, - stored_user: &StoredPasswordUser, -) -> StoredPasswordUser { - let mut hydrated = stored_user.clone(); - if hydrated.user.phone_number.is_none() { - hydrated.user.phone_number = hydrated.phone_number.clone(); - } - let hydrated_wechat_identity = state - .wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == hydrated.user.id); - if hydrated.user.wechat_display_name.is_none() { - hydrated.user.wechat_display_name = hydrated_wechat_identity - .and_then(|identity| normalize_optional_string(identity.display_name.clone())); - } - if hydrated.user.wechat_account.is_none() { - hydrated.user.wechat_account = - hydrated_wechat_identity.map(|identity| identity.provider_uid.clone()); - } - hydrated -} - -fn stored_user_phone_number(stored_user: &StoredPasswordUser) -> Option<&str> { - stored_user - .phone_number - .as_deref() - .or(stored_user.user.phone_number.as_deref()) -} - -fn parse_auth_login_method(value: &str) -> AuthLoginMethod { - match value { - "password" | "Password" => AuthLoginMethod::Password, - "wechat" | "Wechat" => AuthLoginMethod::Wechat, - _ => AuthLoginMethod::Phone, - } -} - -fn parse_auth_binding_status(value: &str) -> AuthBindingStatus { - match value { - "pending_bind_phone" | "PendingBindPhone" => AuthBindingStatus::PendingBindPhone, - _ => AuthBindingStatus::Active, - } -} - -fn parse_phone_auth_scene(value: &str) -> Option { - match value.trim() { - "login" => Some(PhoneAuthScene::Login), - "bind_phone" => Some(PhoneAuthScene::BindPhone), - "change_phone" => Some(PhoneAuthScene::ChangePhone), - "reset_password" => Some(PhoneAuthScene::ResetPassword), - _ => None, - } -} - -fn parse_wechat_auth_scene(value: &str) -> Option { - match value.trim() { - "desktop" => Some(WechatAuthScene::Desktop), - "wechat_in_app" => Some(WechatAuthScene::WechatInApp), - _ => None, - } -} - -fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { - public_user_code - .trim() - .strip_prefix("SY-") - .and_then(|value| value.parse::().ok()) - .map(|sequence| sequence.saturating_add(1)) - .unwrap_or(1) -} - -fn auth_identity_id(provider: &str, provider_uid: &str) -> String { - format!( - "authi_{}_{}", - sanitize_identity_component(provider), - sanitize_identity_component(provider_uid) - ) -} - -fn sanitize_identity_component(value: &str) -> String { - value - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '_' - } - }) - .collect::() - .trim_matches('_') - .to_string() -} - -#[derive(Clone, Debug)] -pub struct PasswordEntryService { - store: InMemoryAuthStore, -} - -#[derive(Clone, Debug)] -pub struct RefreshSessionService { - store: InMemoryAuthStore, - refresh_session_ttl_days: u32, -} - -#[derive(Clone, Debug)] -pub struct AuthUserService { - store: InMemoryAuthStore, -} - -#[derive(Clone, Debug)] -pub struct PhoneAuthService { - store: InMemoryAuthStore, - sms_provider: SmsAuthProvider, - verify_code_salt: String, -} - -#[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 } - } - - pub async fn execute( - &self, - input: PasswordEntryInput, - ) -> Result { - validate_password(&input.password)?; - validate_mainland_china_country_code(input.country_code.as_deref()) - .map_err(map_phone_number_error_to_password_error)?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) - .map_err(map_phone_number_error_to_password_error)?; - let Some(existing_user) = self - .store - .find_by_phone_number_for_password(&normalized_phone.e164)? - else { - return Err(PasswordEntryError::InvalidCredentials); - }; - - verify_stored_password_user(existing_user, &input.password).await - } - - pub async fn execute_with_dev_registration( - &self, - input: PasswordEntryInput, - ) -> Result { - validate_password(&input.password)?; - validate_mainland_china_country_code(input.country_code.as_deref()) - .map_err(map_phone_number_error_to_password_error)?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) - .map_err(map_phone_number_error_to_password_error)?; - if let Some(existing_user) = self - .store - .find_by_phone_number_for_password(&normalized_phone.e164)? - { - return verify_stored_password_user(existing_user, &input.password).await; - } - - let password_hash = hash_password(&input.password) - .await - .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; - let user = self.store.create_dev_password_phone_user( - normalized_phone.clone(), - normalized_phone.masked_national_number, - password_hash, - )?; - - Ok(PasswordEntryResult { - user: AuthUser { - login_method: AuthLoginMethod::Password, - ..user - }, - created: true, - }) - } - - pub fn get_user_by_id( - &self, - user_id: &str, - ) -> Result, PasswordEntryError> { - self.store - .find_by_user_id(user_id) - .map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user })) - } - - pub fn get_user_by_public_user_code( - &self, - public_user_code: &str, - ) -> Result, PasswordEntryError> { - let normalized_public_user_code = normalize_public_user_code(public_user_code)?; - self.store - .find_by_public_user_code(&normalized_public_user_code) - .map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user })) - } - - pub fn update_profile( - &self, - input: UpdateProfileInput, - ) -> Result { - let display_name = match input.display_name { - Some(value) => Some(normalize_profile_display_name(value.as_str())?), - None => None, - }; - let avatar_url = match input.avatar_url { - Some(value) => Some(normalize_profile_avatar_url(value.as_str())?), - None => None, - }; - if display_name.is_none() && avatar_url.is_none() { - return Err(PasswordEntryError::EmptyProfileUpdate); - } - - let user = self - .store - .update_user_profile(&input.user_id, display_name, avatar_url)? - .ok_or(PasswordEntryError::UserNotFound)?; - - Ok(UpdateProfileResult { user }) - } - - pub async fn change_password( - &self, - input: ChangePasswordInput, - ) -> Result { - self.change_password_internal(input, None).await - } - - pub async fn change_password_and_revoke_all_sessions( - &self, - input: ChangePasswordInput, - now: OffsetDateTime, - ) -> Result { - self.change_password_internal(input, Some(now)).await - } - - async fn change_password_internal( - &self, - input: ChangePasswordInput, - revoke_all_sessions_at: Option, - ) -> Result { - validate_password(&input.new_password)?; - let stored_user = self - .store - .find_by_user_id(&input.user_id)? - .ok_or(PasswordEntryError::UserNotFound)?; - - if stored_user.password_login_enabled { - let current_password = input - .current_password - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or(PasswordEntryError::InvalidCredentials)?; - let is_valid = verify_password(&stored_user.password_hash, current_password) - .await - .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; - if !is_valid { - return Err(PasswordEntryError::InvalidCredentials); - } - } - - let password_hash = hash_password(&input.new_password) - .await - .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; - let user = self - .store - .set_user_password_hash(&input.user_id, password_hash, revoke_all_sessions_at)? - .ok_or(PasswordEntryError::UserNotFound)?; - - Ok(ChangePasswordResult { user }) - } -} - -impl RefreshSessionService { - pub fn new(store: InMemoryAuthStore, refresh_session_ttl_days: u32) -> Self { - Self { - store, - refresh_session_ttl_days, - } - } - - pub fn create_session( - &self, - input: CreateRefreshSessionInput, - now: OffsetDateTime, - ) -> Result { - self.store.prune_stale_sessions(now)?; - self.store - .find_by_user_id(&input.user_id) - .map_err(map_password_store_error)? - .ok_or(RefreshSessionError::UserNotFound)?; - - let session_id = build_prefixed_uuid_id("usess_"); - let expires_at = now - .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) - .ok_or_else(|| { - RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) - })?; - let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?; - let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?; - let session = RefreshSessionRecord { - session_id, - user_id: input.user_id, - refresh_token_hash: input.refresh_token_hash, - issued_by_provider: input.issued_by_provider, - client_info: input.client_info, - expires_at: expires_at_iso, - revoked_at: None, - created_at: now_iso.clone(), - updated_at: now_iso.clone(), - last_seen_at: now_iso, - }; - - self.store.insert_session(session.clone())?; - - Ok(CreateRefreshSessionResult { session }) - } - - pub fn rotate_session( - &self, - input: RotateRefreshSessionInput, - now: OffsetDateTime, - ) -> Result { - self.store.prune_stale_sessions(now)?; - let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else { - return Err(RefreshSessionError::MissingToken); - }; - - let session = self - .store - .find_session_by_refresh_token_hash(&refresh_token_hash)? - .ok_or(RefreshSessionError::SessionNotFound)?; - - if session.session.revoked_at.is_some() { - return Err(RefreshSessionError::SessionNotFound); - } - - let expires_at = - parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?; - if expires_at <= now { - return Err(RefreshSessionError::SessionExpired); - } - - let user = self - .store - .find_by_user_id(&session.session.user_id) - .map_err(map_password_store_error)? - .ok_or(RefreshSessionError::UserNotFound)?; - - let next_expires_at = now - .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) - .ok_or_else(|| { - RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) - })?; - let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?; - let next_expires_at_iso = - format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?; - - let updated_session = self.store.rotate_session( - &session.session.session_id, - &session.session.refresh_token_hash, - input.next_refresh_token_hash, - next_expires_at_iso, - now_iso.clone(), - now_iso, - )?; - - Ok(RotateRefreshSessionResult { - session: updated_session.session, - user: user.user, - }) - } - - pub fn list_active_sessions_by_user( - &self, - user_id: &str, - now: OffsetDateTime, - ) -> Result { - self.store.prune_stale_sessions(now)?; - self.store - .find_by_user_id(user_id) - .map_err(map_password_store_error)? - .ok_or(RefreshSessionError::UserNotFound)?; - - let sessions = self.store.list_active_sessions_by_user(user_id, now)?; - Ok(ListActiveRefreshSessionsResult { sessions }) - } - - pub fn revoke_session_by_user_and_session( - &self, - input: RevokeRefreshSessionByUserInput, - now: OffsetDateTime, - ) -> Result { - self.store.prune_stale_sessions(now)?; - self.store - .find_by_user_id(&input.user_id) - .map_err(map_password_store_error)? - .ok_or(RefreshSessionError::UserNotFound)?; - - let Some(session_id) = normalize_required_string(&input.session_id) else { - return Err(RefreshSessionError::SessionNotFound); - }; - let revoked = - self.store - .revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?; - - Ok(RevokeRefreshSessionResult { - session_id, - revoked, - }) - } - - pub fn is_session_active_for_user( - &self, - user_id: &str, - session_id: &str, - now: OffsetDateTime, - ) -> Result { - self.store.prune_stale_sessions(now)?; - self.store - .is_session_active_for_user(user_id, session_id.trim(), now) - } -} - -impl PhoneAuthService { - pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self { - Self::new_with_verify_code_salt(store, sms_provider, DEFAULT_PHONE_VERIFY_CODE_SALT) - } - - /// 使用部署级稳定盐值构造服务,确保验证码投影恢复到另一节点后仍可校验。 - pub fn new_with_verify_code_salt( - store: InMemoryAuthStore, - sms_provider: SmsAuthProvider, - verify_code_salt: impl Into, - ) -> Self { - Self { - store, - sms_provider, - verify_code_salt: verify_code_salt.into(), - } - } - - pub async fn send_code( - &self, - input: SendPhoneCodeInput, - now: OffsetDateTime, - ) -> Result { - self.send_code_inner(input, now, true).await - } - - /// 在 provider 调用前由 api-server 先同步该占位记录,以便 SpacetimeDB 的 - /// projection CAS 原子占用跨节点冷却窗口。 - pub fn reserve_code_send( - &self, - input: &SendPhoneCodeInput, - now: OffsetDateTime, - ) -> Result<(), PhoneAuthError> { - self.store.prune_expired_phone_codes(now)?; - let scene = input.scene.clone(); - validate_mainland_china_country_code(input.country_code.as_deref())?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; - self.store - .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; - 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}")) - })?; - let last_sent_at = format_rfc3339(now).map_err(|message| { - PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) - })?; - let verify_code_hash = hash_phone_verify_code( - &self.verify_code_salt, - &normalized_phone.e164, - &scene, - PHONE_CODE_RESERVATION_MARKER, - ); - self.store.upsert_phone_code( - StoredPhoneCode { - phone_number: normalized_phone.e164, - scene, - verify_code_hash, - expires_at, - last_sent_at, - failed_attempts: 0, - provider_out_id: None, - }, - now, - ) - } - - /// 仅供完成权威占用后的 provider 调用使用;占用已由 projection CAS 校验。 - pub async fn send_code_after_authoritative_reservation( - &self, - input: SendPhoneCodeInput, - now: OffsetDateTime, - ) -> Result { - self.send_code_inner(input, now, false).await - } - - async fn send_code_inner( - &self, - input: SendPhoneCodeInput, - now: OffsetDateTime, - check_local_cooldown: bool, - ) -> Result { - self.store.prune_expired_phone_codes(now)?; - let scene = input.scene.clone(); - validate_mainland_china_country_code(input.country_code.as_deref())?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; - let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; - let verify_code = self.generate_phone_verify_code(); - info!( - scene = scene.as_str(), - provider = self.sms_provider.kind().as_str(), - phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(), - phone_national_masked = normalized_phone.masked_national_number.as_str(), - "手机号验证码发送准备调用 provider" - ); - if check_local_cooldown { - self.store - .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; - } - self.store - .ensure_phone_code_capacity(&normalized_phone.e164, &scene)?; - 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}")) - })?; - let verify_code_hash = hash_phone_verify_code( - &self.verify_code_salt, - &normalized_phone.e164, - &scene, - &verify_code, - ); - - let provider_result = self - .sms_provider - .send_code(SmsSendCodeRequest { - national_phone_number, - scene: input.scene.as_str().to_string(), - verify_code, - }) - .await - .map_err(map_sms_provider_error_to_phone_error)?; - info!( - scene = scene.as_str(), - provider = self.sms_provider.kind().as_str(), - phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(), - phone_national_masked = normalized_phone.masked_national_number.as_str(), - cooldown_seconds = provider_result.cooldown_seconds, - expires_in_seconds = provider_result.expires_in_seconds, - provider_request_id = provider_result - .provider_request_id - .as_deref() - .unwrap_or("unknown"), - provider_out_id = provider_result - .provider_out_id - .as_deref() - .unwrap_or("unknown"), - "手机号验证码 provider 调用成功,准备写入本地快照" - ); - - self.store.upsert_phone_code( - StoredPhoneCode { - phone_number: normalized_phone.e164.clone(), - scene, - verify_code_hash, - expires_at, - last_sent_at: format_rfc3339(now).map_err(|message| { - PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) - })?, - failed_attempts: 0, - provider_out_id: provider_result.provider_out_id.clone(), - }, - now, - )?; - - Ok(SendPhoneCodeResult { - cooldown_seconds: provider_result.cooldown_seconds, - expires_in_seconds: provider_result.expires_in_seconds, - provider_request_id: provider_result.provider_request_id, - provider_out_id: provider_result.provider_out_id, - provider: self.sms_provider.kind().as_str().to_string(), - scene: input.scene.as_str().to_string(), - phone_number_masked: normalized_phone.masked_national_number, - }) - } - - pub async fn login( - &self, - input: PhoneLoginInput, - now: OffsetDateTime, - ) -> Result { - validate_mainland_china_country_code(input.country_code.as_deref())?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; - verify_sms_code_format(&input.verify_code)?; - let provider_out_id = self.verify_phone_code( - &normalized_phone.e164, - &PhoneAuthScene::Login, - &input.verify_code, - 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, - provider: self.sms_provider.kind().as_str().to_string(), - provider_out_id, - phone_number_masked: normalized_phone.masked_national_number, - }); - } - - 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, - provider: self.sms_provider.kind().as_str().to_string(), - provider_out_id, - phone_number_masked: normalized_phone.masked_national_number, - }) - } - - pub async fn reset_password( - &self, - input: ResetPasswordInput, - now: OffsetDateTime, - ) -> Result { - validate_mainland_china_country_code(input.country_code.as_deref())?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; - verify_sms_code_format(&input.verify_code)?; - validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; - let provider_out_id = self.verify_phone_code( - &normalized_phone.e164, - &PhoneAuthScene::ResetPassword, - &input.verify_code, - now, - )?; - - self.store - .find_by_phone_number(&normalized_phone.e164)? - .ok_or(PhoneAuthError::UserNotFound)?; - let password_hash = hash_password(&input.new_password) - .await - .map_err(|error| PhoneAuthError::PasswordHash(error.to_string()))?; - let user = self - .store - .set_user_password_by_phone_number(&normalized_phone.e164, password_hash)?; - - Ok(ResetPasswordResult { - user, - provider: self.sms_provider.kind().as_str().to_string(), - provider_out_id, - phone_number_masked: normalized_phone.masked_national_number, - }) - } - - pub async fn bind_wechat_phone( - &self, - input: BindWechatPhoneInput, - now: OffsetDateTime, - ) -> Result { - validate_mainland_china_country_code(input.country_code.as_deref())?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; - verify_sms_code_format(&input.verify_code)?; - self.verify_phone_code( - &normalized_phone.e164, - &PhoneAuthScene::BindPhone, - &input.verify_code, - 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, activated_new_user) = self.store.bind_wechat_phone_to_user( - &input.user_id, - normalized_phone, - input.wechat_display_name, - )?; - - Ok(BindWechatPhoneResult { - user: merged_user, - activated_new_user, - }) - } - - fn verify_phone_code( - &self, - phone_number: &str, - scene: &PhoneAuthScene, - verify_code: &str, - now: OffsetDateTime, - ) -> Result, PhoneAuthError> { - let stored = self.store.get_active_phone_code(phone_number, scene, now)?; - let expected_hash = - hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code); - if stored.verify_code_hash != expected_hash { - self.store.consume_phone_code_failure(phone_number, scene)?; - return Err(PhoneAuthError::InvalidVerifyCode); - } - self.store.consume_phone_code_success(phone_number, scene)?; - Ok(stored.provider_out_id) - } - - fn generate_phone_verify_code(&self) -> String { - match self.sms_provider.kind() { - SmsAuthProviderKind::Mock => self - .sms_provider - .mock_verify_code() - .map(str::to_string) - .unwrap_or_else(|| "123456".to_string()), - SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(), - } - } - - pub async fn bind_wechat_verified_phone( - &self, - input: BindWechatVerifiedPhoneInput, - ) -> Result { - validate_mainland_china_country_code(Some(&input.country_code))?; - let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; - 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, activated_new_user) = self.store.bind_wechat_phone_to_user( - &input.user_id, - normalized_phone, - input.wechat_display_name, - )?; - - Ok(BindWechatPhoneResult { - user: merged_user, - activated_new_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 { - self.store.prune_wechat_states(now)?; - 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: build_prefixed_uuid_id("wxstate_"), - state_token: create_wechat_state_token(), - redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(), - scene: input.scene, - request_user_agent: normalize_optional_string(input.request_user_agent), - bind_user_id: normalize_optional_string(input.bind_user_id), - expires_at, - consumed_at: None, - created_at: created_at.clone(), - updated_at: created_at, - }; - self.store.insert_wechat_state(state.clone(), now)?; - 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, - }) - } - - pub fn bind_identity_to_user( - &self, - input: BindWechatIdentityInput, - ) -> Result { - if input.profile.provider_uid.trim().is_empty() { - return Err(WechatAuthError::MissingProfile); - } - self.store - .bind_wechat_identity_to_user(&input.user_id, input.profile) - } - - pub fn get_identity_by_user_id( - &self, - user_id: &str, - ) -> Result, WechatAuthError> { - self.store.get_wechat_identity_by_user_id(user_id) - } -} - -impl AuthUserService { - pub fn new(store: InMemoryAuthStore) -> Self { - Self { store } - } - - pub fn ensure_orphan_work_owner_user( - &self, - user_id: &str, - username: &str, - display_name: &str, - public_user_code: &str, - ) -> Result { - self.store - .ensure_orphan_work_owner_user(user_id, username, display_name, public_user_code) - } - - pub fn get_user_by_id(&self, user_id: &str) -> Result, LogoutError> { - self.store - .find_by_user_id(user_id) - .map(|maybe_user| maybe_user.map(|stored| stored.user)) - .map_err(map_password_error_to_logout_error) - } - - pub fn get_user_by_public_user_code( - &self, - public_user_code: &str, - ) -> Result, LogoutError> { - let normalized_public_user_code = normalize_public_user_code(public_user_code) - .map_err(map_password_error_to_logout_error)?; - self.store - .find_by_public_user_code(&normalized_public_user_code) - .map(|maybe_user| maybe_user.map(|stored| stored.user)) - .map_err(map_password_error_to_logout_error) - } - - pub fn logout_current_session( - &self, - input: LogoutCurrentSessionInput, - now: OffsetDateTime, - ) -> Result { - let user = self - .store - .find_by_user_id(&input.user_id) - .map_err(map_password_error_to_logout_error)? - .ok_or(LogoutError::UserNotFound)? - .user; - let revoked_by_hash = if let Some(refresh_token_hash) = input - .refresh_token_hash - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - self.store - .revoke_session_by_refresh_token_hash(refresh_token_hash, now) - .map_err(map_refresh_error_to_logout_error)?; - true - } else { - false - }; - - if !revoked_by_hash - && let Some(session_id) = input - .session_id - .as_ref() - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - { - self.store - .revoke_session_by_user_and_session_id(&input.user_id, session_id, now) - .map_err(map_refresh_error_to_logout_error)?; - } - - Ok(LogoutCurrentSessionResult { user }) - } - - // 全端登出需要先吊销该用户全部 refresh session,再统一提升 token_version, - // 让所有旧 access token 在下一次鉴权时立即失效。 - pub fn logout_all_sessions( - &self, - input: LogoutAllSessionsInput, - now: OffsetDateTime, - ) -> Result { - self.store - .revoke_all_sessions_by_user_id(&input.user_id, now) - .map_err(map_refresh_error_to_logout_error)?; - - let user = self - .store - .increment_user_token_version(&input.user_id) - .map_err(map_password_error_to_logout_error)? - .ok_or(LogoutError::UserNotFound)?; - - Ok(LogoutAllSessionsResult { user }) - } -} - -impl Default for InMemoryAuthStore { - fn default() -> Self { - Self { - inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), - revision: Arc::new(AtomicU64::new(0)), - } - } -} - -impl Default for InMemoryAuthStoreState { - fn default() -> Self { - Self { - 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(), - } - } -} - -impl InMemoryAuthStoreState { - fn from_projection_view(view: AuthStoreProjectionView) -> Result { - let existing_user_ids = view - .users - .iter() - .map(|user| user.user_id.clone()) - .collect::>(); - let mut users_by_username = HashMap::new(); - let mut phone_to_user_id = HashMap::new(); - let mut sessions_by_id = HashMap::new(); - let mut session_id_by_refresh_token_hash = HashMap::new(); - let mut wechat_identity_by_provider_uid = HashMap::new(); - let mut user_id_by_provider_union_id = HashMap::new(); - let mut phone_number_by_user_id = HashMap::new(); - let mut phone_codes_by_key = HashMap::new(); - let mut wechat_states_by_token = HashMap::new(); - - for user in &view.users { - if let Some(phone_number) = normalize_optional_string(user.phone_number_e164.clone()) { - phone_to_user_id.insert(phone_number.clone(), user.user_id.clone()); - phone_number_by_user_id.insert(user.user_id.clone(), phone_number); - } - } - - for identity in view.identities { - if !existing_user_ids.contains(&identity.user_id) { - continue; - } - match identity.provider.as_str() { - "phone" => { - if let Some(phone_number) = - normalize_optional_string(Some(identity.provider_uid.clone())) - { - phone_to_user_id.insert(phone_number.clone(), identity.user_id.clone()); - phone_number_by_user_id.insert(identity.user_id, phone_number); - } - } - "wechat" => { - if let Some(provider_union_id) = identity.provider_union_id.clone() { - user_id_by_provider_union_id - .insert(provider_union_id, identity.user_id.clone()); - } - wechat_identity_by_provider_uid.insert( - identity.provider_uid.clone(), - StoredWechatIdentity { - user_id: identity.user_id, - provider_uid: identity.provider_uid, - provider_union_id: identity.provider_union_id, - display_name: None, - avatar_url: None, - session_key: None, - }, - ); - } - _ => {} - } - } - - let now = OffsetDateTime::now_utc(); - let mut retained_refresh_session_count = 0_usize; - for session in view.refresh_sessions { - if !existing_user_ids.contains(&session.user_id) { - continue; - } - if should_prune_refresh_session_fields( - &session.expires_at, - session.revoked_at.as_deref(), - now, - ) { - continue; - } - retained_refresh_session_count += 1; - if retained_refresh_session_count > MAX_REFRESH_SESSIONS { - return Err(format!( - "认证投影中的 refresh session 数量超过内存上限(最多 {MAX_REFRESH_SESSIONS} 条)" - )); - } - let client_info = - serde_json::from_str::(&session.client_info_json) - .map_err(|error| format!("解析 refresh session 客户端信息失败:{error}"))?; - session_id_by_refresh_token_hash.insert( - session.refresh_token_hash.clone(), - session.session_id.clone(), - ); - sessions_by_id.insert( - session.session_id.clone(), - StoredRefreshSession { - session: RefreshSessionRecord { - session_id: session.session_id, - user_id: session.user_id, - refresh_token_hash: session.refresh_token_hash, - issued_by_provider: parse_auth_login_method(&session.issued_by_provider), - client_info, - expires_at: session.expires_at, - revoked_at: session.revoked_at, - created_at: session.created_at, - updated_at: session.updated_at, - last_seen_at: session.last_seen_at, - }, - }, - ); - } - - for phone_code in view.phone_codes { - let scene = parse_phone_auth_scene(&phone_code.scene) - .ok_or_else(|| format!("未知短信验证码场景:{}", phone_code.scene))?; - let key = build_phone_code_key(&phone_code.phone_number, &scene); - phone_codes_by_key.insert( - key, - StoredPhoneCode { - phone_number: phone_code.phone_number, - scene, - verify_code_hash: phone_code.verify_code_hash, - expires_at: phone_code.expires_at, - last_sent_at: phone_code.last_sent_at, - failed_attempts: phone_code.failed_attempts, - provider_out_id: phone_code.provider_out_id, - }, - ); - } - - for wechat_state in view.wechat_states { - let scene = parse_wechat_auth_scene(&wechat_state.scene) - .ok_or_else(|| format!("未知微信授权 state 场景:{}", wechat_state.scene))?; - wechat_states_by_token.insert( - wechat_state.state_token.clone(), - StoredWechatAuthState { - state: WechatAuthStateRecord { - wechat_state_id: wechat_state.wechat_state_id, - state_token: wechat_state.state_token, - redirect_path: wechat_state.redirect_path, - scene, - request_user_agent: wechat_state.request_user_agent, - bind_user_id: wechat_state.bind_user_id, - expires_at: wechat_state.expires_at, - consumed_at: wechat_state.consumed_at, - created_at: wechat_state.created_at, - updated_at: wechat_state.updated_at, - }, - }, - ); - } - - for user in view.users { - let wechat_identity = wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == user.user_id); - let wechat_account = wechat_identity.map(|identity| identity.provider_uid.clone()); - let phone_number = user - .phone_number_e164 - .clone() - .or_else(|| phone_number_by_user_id.remove(&user.user_id)); - users_by_username.insert( - user.username.clone(), - StoredPasswordUser { - user: AuthUser { - id: user.user_id, - public_user_code: user.public_user_code, - username: user.username, - display_name: user.display_name, - avatar_url: user.avatar_url, - phone_number: phone_number.clone(), - phone_number_masked: user.phone_number_masked, - login_method: parse_auth_login_method(&user.login_method), - binding_status: parse_auth_binding_status(&user.binding_status), - wechat_bound: user.wechat_bound, - wechat_display_name: None, - wechat_account, - token_version: user.token_version, - created_at: user.created_at, - }, - password_hash: user.password_hash, - password_login_enabled: user.password_login_enabled, - phone_number, - }, - ); - } - - let next_user_id = users_by_username - .values() - .map(|stored| next_sequence_from_public_user_code(&stored.user.public_user_code)) - .max() - .unwrap_or(1); - - Ok(Self { - next_user_id, - users_by_username, - phone_to_user_id, - sessions_by_id, - session_id_by_refresh_token_hash, - phone_codes_by_key, - wechat_states_by_token, - wechat_identity_by_provider_uid, - user_id_by_provider_union_id, - }) - } - - fn apply_persistent_state(&mut self, next_state: Self) { - self.next_user_id = next_state.next_user_id; - self.users_by_username = next_state.users_by_username; - self.phone_to_user_id = next_state.phone_to_user_id; - self.sessions_by_id = next_state.sessions_by_id; - self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash; - self.phone_codes_by_key = next_state.phone_codes_by_key; - self.wechat_states_by_token = next_state.wechat_states_by_token; - self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid; - self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id; - } -} - -fn prune_expired_short_lived_state( - state: &mut InMemoryAuthStoreState, - now: OffsetDateTime, -) -> bool { - let phone_code_count = state.phone_codes_by_key.len(); - state.phone_codes_by_key.retain(|_, code| { - parse_rfc3339(&code.expires_at) - .map(|expires_at| expires_at > now) - .unwrap_or(true) - }); - let wechat_state_count = state.wechat_states_by_token.len(); - state.wechat_states_by_token.retain(|_, stored| { - parse_rfc3339(&stored.state.expires_at) - .map(|expires_at| expires_at > now) - .unwrap_or(true) - }); - phone_code_count != state.phone_codes_by_key.len() - || wechat_state_count != state.wechat_states_by_token.len() -} - -impl InMemoryAuthStore { - pub fn from_projection_view(view: AuthStoreProjectionView) -> Result { - Ok(Self { - inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view( - view, - )?)), - revision: Arc::new(AtomicU64::new(0)), - }) - } - - pub fn revision(&self) -> u64 { - self.revision.load(Ordering::Acquire) - } - - pub fn refresh_from_projection_view( - &self, - view: AuthStoreProjectionView, - ) -> Result<(), String> { - let next_state = InMemoryAuthStoreState::from_projection_view(view)?; - let mut state = self - .inner - .lock() - .map_err(|_| "认证仓储锁已中毒".to_string())?; - state.apply_persistent_state(next_state); - self.revision.fetch_add(1, Ordering::Release); - - Ok(()) - } - - pub fn refresh_from_projection_view_if_revision( - &self, - view: AuthStoreProjectionView, - expected_revision: u64, - ) -> Result { - let next_state = InMemoryAuthStoreState::from_projection_view(view)?; - let mut state = self - .inner - .lock() - .map_err(|_| "认证仓储锁已中毒".to_string())?; - if self.revision.load(Ordering::Acquire) != expected_revision { - return Ok(false); - } - state.apply_persistent_state(next_state); - self.revision.fetch_add(1, Ordering::Release); - - Ok(true) - } - - pub fn export_projection_view( - &self, - updated_at_micros: i64, - ) -> Result { - self.prune_stale_sessions(OffsetDateTime::now_utc()) - .map_err(|error| error.to_string())?; - let mut state = self - .inner - .lock() - .map_err(|_| "认证仓储锁已中毒".to_string())?; - let pruned = prune_expired_short_lived_state(&mut state, OffsetDateTime::now_utc()); - if pruned { - self.revision.fetch_add(1, Ordering::Release); - } - let users = state - .users_by_username - .values() - .map(|stored| AuthStoreProjectionUser { - user_id: stored.user.id.clone(), - public_user_code: stored.user.public_user_code.clone(), - username: stored.user.username.clone(), - display_name: stored.user.display_name.clone(), - avatar_url: stored.user.avatar_url.clone(), - phone_number_masked: stored.user.phone_number_masked.clone(), - phone_number_e164: stored_user_phone_number(stored).map(str::to_string), - login_method: stored.user.login_method.as_str().to_string(), - binding_status: stored.user.binding_status.as_str().to_string(), - wechat_bound: stored.user.wechat_bound, - password_hash: stored.password_hash.clone(), - password_login_enabled: stored.password_login_enabled, - token_version: stored.user.token_version, - created_at: stored.user.created_at.clone(), - }) - .collect(); - let mut identities = Vec::new(); - for stored in state.users_by_username.values() { - if let Some(phone_number) = stored_user_phone_number(stored) { - identities.push(AuthStoreProjectionIdentity { - identity_id: auth_identity_id("phone", phone_number), - user_id: stored.user.id.clone(), - provider: "phone".to_string(), - provider_uid: phone_number.to_string(), - provider_union_id: None, - }); - } - } - identities.extend( - state - .wechat_identity_by_provider_uid - .values() - .map(|identity| AuthStoreProjectionIdentity { - identity_id: auth_identity_id("wechat", &identity.provider_uid), - user_id: identity.user_id.clone(), - provider: "wechat".to_string(), - provider_uid: identity.provider_uid.clone(), - provider_union_id: identity.provider_union_id.clone(), - }), - ); - let refresh_sessions = state - .sessions_by_id - .values() - .map(|stored| { - let client_info_json = serde_json::to_string(&stored.session.client_info) - .map_err(|error| format!("序列化 refresh session 客户端信息失败:{error}"))?; - Ok(AuthStoreProjectionRefreshSession { - session_id: stored.session.session_id.clone(), - user_id: stored.session.user_id.clone(), - refresh_token_hash: stored.session.refresh_token_hash.clone(), - issued_by_provider: stored.session.issued_by_provider.as_str().to_string(), - client_info_json, - expires_at: stored.session.expires_at.clone(), - revoked_at: stored.session.revoked_at.clone(), - created_at: stored.session.created_at.clone(), - updated_at: stored.session.updated_at.clone(), - last_seen_at: stored.session.last_seen_at.clone(), - }) - }) - .collect::, String>>()?; - let phone_codes = state - .phone_codes_by_key - .values() - .map(|stored| AuthStoreProjectionPhoneCode { - phone_number: stored.phone_number.clone(), - scene: stored.scene.as_str().to_string(), - verify_code_hash: stored.verify_code_hash.clone(), - expires_at: stored.expires_at.clone(), - last_sent_at: stored.last_sent_at.clone(), - failed_attempts: stored.failed_attempts, - provider_out_id: stored.provider_out_id.clone(), - }) - .collect(); - let wechat_states = state - .wechat_states_by_token - .values() - .map(|stored| AuthStoreProjectionWechatState { - wechat_state_id: stored.state.wechat_state_id.clone(), - state_token: stored.state.state_token.clone(), - redirect_path: stored.state.redirect_path.clone(), - scene: stored.state.scene.as_str().to_string(), - request_user_agent: stored.state.request_user_agent.clone(), - bind_user_id: stored.state.bind_user_id.clone(), - expires_at: stored.state.expires_at.clone(), - consumed_at: stored.state.consumed_at.clone(), - created_at: stored.state.created_at.clone(), - updated_at: stored.state.updated_at.clone(), - }) - .collect(); - - Ok(AuthStoreProjectionView { - base_updated_at_micros: 0, - updated_at_micros, - users, - identities, - refresh_sessions, - phone_codes, - wechat_states, - }) - } - - pub fn export_projection_view_with_revision( - &self, - updated_at_micros: i64, - ) -> Result<(AuthStoreProjectionView, u64), String> { - for _ in 0..3 { - let before = self.revision.load(Ordering::Acquire); - let view = self.export_projection_view(updated_at_micros)?; - let after = self.revision.load(Ordering::Acquire); - if before == after { - return Ok((view, after)); - } - } - Err("认证工作集在导出期间持续发生变化".to_string()) - } - - fn prune_stale_sessions(&self, now: OffsetDateTime) -> Result<(), RefreshSessionError> { - let mut state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let stale_session_ids = state - .sessions_by_id - .iter() - .filter(|(_, stored)| should_prune_refresh_session(&stored.session, now)) - .map(|(session_id, _)| session_id.clone()) - .collect::>(); - if stale_session_ids.is_empty() { - return Ok(()); - } - - for session_id in stale_session_ids { - let Some(stored) = state.sessions_by_id.remove(&session_id) else { - continue; - }; - if state - .session_id_by_refresh_token_hash - .get(&stored.session.refresh_token_hash) - .is_some_and(|mapped_id| mapped_id == &session_id) - { - state - .session_id_by_refresh_token_hash - .remove(&stored.session.refresh_token_hash); - } - } - self.persist_refresh_state(&state) - } - - fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { - let _ = state; - self.revision.fetch_add(1, Ordering::Release); - Ok(()) - } - - fn persist_password_state( - &self, - state: &InMemoryAuthStoreState, - ) -> Result<(), PasswordEntryError> { - self.persist_state(state).map_err(PasswordEntryError::Store) - } - - fn persist_phone_state(&self, state: &InMemoryAuthStoreState) -> Result<(), PhoneAuthError> { - self.persist_state(state).map_err(PhoneAuthError::Store) - } - - fn persist_wechat_state(&self, state: &InMemoryAuthStoreState) -> Result<(), WechatAuthError> { - self.persist_state(state).map_err(WechatAuthError::Store) - } - - fn persist_refresh_state( - &self, - state: &InMemoryAuthStoreState, - ) -> Result<(), RefreshSessionError> { - self.persist_state(state) - .map_err(RefreshSessionError::Store) - } - - fn resolve_phone_user_locked( - state: &mut InMemoryAuthStoreState, - phone_number: &str, - ) -> Option { - if let Some(user_id) = state.phone_to_user_id.get(phone_number).cloned() { - if let Some(stored_user) = state - .users_by_username - .values() - .find(|stored_user| stored_user.user.id == user_id) - .cloned() - { - return Some(stored_user); - } - state.phone_to_user_id.remove(phone_number); - } - - let Some(stored_user) = state - .users_by_username - .values() - .find(|stored_user| stored_user_phone_number(stored_user) == Some(phone_number)) - .cloned() - else { - return None; - }; - state - .phone_to_user_id - .insert(phone_number.to_string(), stored_user.user.id.clone()); - Some(stored_user) - } - - fn find_by_user_id( - &self, - user_id: &str, - ) -> Result, PasswordEntryError> { - let state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - - Ok(state - .users_by_username - .values() - .find(|stored_user| stored_user.user.id == user_id) - .map(|stored_user| hydrate_private_auth_fields(&state, stored_user))) - } - - fn ensure_orphan_work_owner_user( - &self, - user_id: &str, - username: &str, - display_name: &str, - public_user_code: &str, - ) -> Result { - let user_id = normalize_required_string(user_id) - .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string()))?; - let username = normalize_required_string(username) - .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string()))?; - let display_name = normalize_required_string(display_name) - .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string()))?; - let public_user_code = normalize_required_string(public_user_code) - .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string()))?; - - let mut state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - if let Some(stored) = state - .users_by_username - .values() - .find(|stored_user| stored_user.user.id == user_id) - { - return Ok(stored.user.clone()); - } - - let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { - PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) - })?; - let user = AuthUser { - id: user_id, - public_user_code, - username: username.clone(), - display_name, - avatar_url: None, - phone_number: None, - phone_number_masked: None, - login_method: AuthLoginMethod::Password, - binding_status: AuthBindingStatus::Active, - wechat_bound: false, - wechat_display_name: None, - wechat_account: None, - token_version: 1, - created_at, - }; - state.users_by_username.insert( - username, - StoredPasswordUser { - user: user.clone(), - password_hash: String::new(), - password_login_enabled: false, - phone_number: None, - }, - ); - self.persist_password_state(&state)?; - - Ok(user) - } - - fn find_by_public_user_code( - &self, - public_user_code: &str, - ) -> Result, PasswordEntryError> { - let state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - - Ok(state - .users_by_username - .values() - .find(|stored_user| stored_user.user.public_user_code == public_user_code) - .map(|stored_user| hydrate_private_auth_fields(&state, stored_user))) - } - - fn find_by_phone_number( - &self, - phone_number: &str, - ) -> Result, PhoneAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; - Ok(Self::resolve_phone_user_locked(&mut state, phone_number) - .map(|stored_user| hydrate_private_auth_fields(&state, &stored_user))) - } - - fn find_by_phone_number_for_password( - &self, - phone_number: &str, - ) -> Result, PasswordEntryError> { - let mut state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - Ok(Self::resolve_phone_user_locked(&mut state, phone_number) - .map(|stored_user| hydrate_private_auth_fields(&state, &stored_user))) - } - - fn update_user_profile( - &self, - user_id: &str, - display_name: Option, - avatar_url: Option, - ) -> Result, PasswordEntryError> { - let mut state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - - for stored_user in state.users_by_username.values_mut() { - if stored_user.user.id != user_id { - continue; - } - - if let Some(display_name) = display_name { - stored_user.user.display_name = display_name; - } - if let Some(avatar_url) = avatar_url { - stored_user.user.avatar_url = Some(avatar_url); - } - let next_user = stored_user.user.clone(); - self.persist_password_state(&state)?; - return Ok(Some(next_user)); - } - - Ok(None) - } - - 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 Self::resolve_phone_user_locked(&mut state, &phone_number.e164).is_some() { - return Err(PhoneAuthError::Store( - "手机号已存在,无法重复创建账号".to_string(), - )); - } - - let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { - PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}")) - })?; - let sequence = state.next_user_id; - let user_id = build_prefixed_uuid_id("user_"); - let public_user_code = build_public_user_code(sequence); - state.next_user_id += 1; - let username = build_system_username("phone", state.next_user_id); - let user = AuthUser { - id: user_id.clone(), - public_user_code, - username: username.clone(), - display_name, - avatar_url: None, - phone_number: Some(phone_number.e164.clone()), - phone_number_masked: Some(phone_number.masked_national_number.clone()), - login_method: AuthLoginMethod::Phone, - binding_status: AuthBindingStatus::Active, - wechat_bound: false, - wechat_display_name: None, - wechat_account: None, - token_version: 1, - created_at, - }; - state - .phone_to_user_id - .insert(phone_number.e164.clone(), user_id); - state.users_by_username.insert( - username, - StoredPasswordUser { - user: user.clone(), - password_hash, - password_login_enabled: false, - phone_number: Some(phone_number.e164), - }, - ); - self.persist_phone_state(&state)?; - - Ok(user) - } - - fn create_dev_password_phone_user( - &self, - phone_number: PhoneNumberSnapshot, - display_name: String, - password_hash: String, - ) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - if Self::resolve_phone_user_locked(&mut state, &phone_number.e164).is_some() { - return Err(PasswordEntryError::InvalidCredentials); - } - - let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { - PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) - })?; - let sequence = state.next_user_id; - let user_id = build_prefixed_uuid_id("user_"); - let public_user_code = build_public_user_code(sequence); - state.next_user_id += 1; - let username = build_system_username("phone", state.next_user_id); - let user = AuthUser { - id: user_id.clone(), - public_user_code, - username: username.clone(), - display_name, - avatar_url: None, - phone_number: Some(phone_number.e164.clone()), - phone_number_masked: Some(phone_number.masked_national_number.clone()), - login_method: AuthLoginMethod::Password, - binding_status: AuthBindingStatus::Active, - wechat_bound: false, - wechat_display_name: None, - wechat_account: None, - token_version: 1, - created_at, - }; - state - .phone_to_user_id - .insert(phone_number.e164.clone(), user_id); - state.users_by_username.insert( - username, - StoredPasswordUser { - user: user.clone(), - password_hash, - password_login_enabled: true, - phone_number: Some(phone_number.e164), - }, - ); - self.persist_password_state(&state)?; - - 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 created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { - WechatAuthError::Store(format!("用户创建时间格式化失败:{message}")) - })?; - let sequence = state.next_user_id; - let user_id = build_prefixed_uuid_id("user_"); - let public_user_code = build_public_user_code(sequence); - state.next_user_id += 1; - let avatar_url = normalize_optional_string(profile.avatar_url.clone()); - let display_name = profile - .display_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("微信旅人") - .to_string(); - let wechat_display_name = normalize_optional_string(profile.display_name.clone()); - let username = build_wechat_username(&display_name, &profile.provider_uid); - let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default(); - let user = AuthUser { - id: user_id.clone(), - public_user_code, - username: username.clone(), - display_name, - avatar_url: avatar_url.clone(), - phone_number: None, - phone_number_masked: None, - login_method: AuthLoginMethod::Wechat, - binding_status: AuthBindingStatus::PendingBindPhone, - wechat_bound: true, - wechat_display_name, - wechat_account: Some(provider_uid.clone()), - token_version: 1, - created_at, - }; - state.users_by_username.insert( - username, - StoredPasswordUser { - user: user.clone(), - password_hash, - password_login_enabled: false, - phone_number: None, - }, - ); - let identity = StoredWechatIdentity { - user_id: user_id.clone(), - provider_uid, - provider_union_id: normalize_optional_string(profile.provider_union_id), - display_name: normalize_optional_string(profile.display_name), - avatar_url, - session_key: normalize_optional_string(profile.session_key), - }; - 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); - self.persist_wechat_state(&state)?; - - 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(hydrate_private_auth_fields(&state, stored).user)); - } - - 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| hydrate_private_auth_fields(&state, stored).user)) - } - - fn get_wechat_identity_by_user_id( - &self, - user_id: &str, - ) -> Result, WechatAuthError> { - let state = self - .inner - .lock() - .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; - let Some(identity) = state - .wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == user_id.trim()) - else { - return Ok(None); - }; - - Ok(Some(WechatIdentityRecord { - user_id: identity.user_id.clone(), - provider_uid: identity.provider_uid.clone(), - provider_union_id: identity.provider_union_id.clone(), - session_key: identity.session_key.clone(), - })) - } - - fn bind_wechat_identity_to_user( - &self, - user_id: &str, - profile: WechatIdentityProfile, - ) -> Result { - let user_id = normalize_required_string(user_id).ok_or(WechatAuthError::UserNotFound)?; - let provider_uid = normalize_required_string(&profile.provider_uid) - .ok_or(WechatAuthError::MissingProfile)?; - let provider_union_id = normalize_optional_string(profile.provider_union_id); - let display_name = normalize_optional_string(profile.display_name); - let avatar_url = normalize_optional_string(profile.avatar_url); - let session_key = normalize_optional_string(profile.session_key); - - let mut state = self - .inner - .lock() - .map_err(|_| WechatAuthError::Store("鐢ㄦ埛浠撳偍閿佸凡涓瘨".to_string()))?; - - if !state - .users_by_username - .values() - .any(|stored_user| stored_user.user.id == user_id) - { - return Err(WechatAuthError::UserNotFound); - } - - if let Some(existing) = state.wechat_identity_by_provider_uid.get(&provider_uid) - && existing.user_id != user_id - { - return Err(WechatAuthError::WechatIdentityAlreadyBound); - } - if let Some(provider_union_id) = provider_union_id.as_deref() - && let Some(existing_user_id) = - state.user_id_by_provider_union_id.get(provider_union_id) - && existing_user_id != &user_id - { - return Err(WechatAuthError::WechatIdentityAlreadyBound); - } - - if let Some(existing_identity) = state - .wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == user_id) - .cloned() - { - state - .wechat_identity_by_provider_uid - .remove(&existing_identity.provider_uid); - if let Some(existing_union_id) = existing_identity.provider_union_id - && provider_union_id.as_deref() != Some(existing_union_id.as_str()) - { - state - .user_id_by_provider_union_id - .remove(&existing_union_id); - } - } - - state.wechat_identity_by_provider_uid.insert( - provider_uid.clone(), - StoredWechatIdentity { - user_id: user_id.clone(), - provider_uid: provider_uid.clone(), - provider_union_id: provider_union_id.clone(), - display_name: display_name.clone(), - avatar_url: avatar_url.clone(), - session_key, - }, - ); - if let Some(provider_union_id) = provider_union_id { - state - .user_id_by_provider_union_id - .insert(provider_union_id, user_id.clone()); - } - - let next_user = { - let stored_user = state - .users_by_username - .values_mut() - .find(|stored_user| stored_user.user.id == user_id) - .ok_or(WechatAuthError::UserNotFound)?; - stored_user.user.wechat_bound = true; - stored_user.user.wechat_account = Some(provider_uid); - if let Some(display_name) = display_name { - stored_user.user.wechat_display_name = Some(display_name); - } - if stored_user.user.avatar_url.is_none() - && let Some(avatar_url) = avatar_url - { - stored_user.user.avatar_url = Some(avatar_url); - } - stored_user.user.clone() - }; - self.persist_wechat_state(&state)?; - - Ok(next_user) - } - - 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_session_key = normalize_optional_string(profile.session_key); - let next_provider_uid = - normalize_required_string(&profile.provider_uid).unwrap_or_default(); - { - 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.clone(); - identity.provider_union_id = next_provider_union_id.clone(); - if next_session_key.is_some() { - identity.session_key = next_session_key.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 next_user = { - 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() { - stored_user.user.display_name = display_name.to_string(); - } - stored_user.user.wechat_account = Some(next_provider_uid.clone()); - if let Some(display_name) = next_display_name.clone() { - stored_user.user.wechat_display_name = Some(display_name); - } - if let Some(avatar_url) = next_avatar_url.clone() { - stored_user.user.avatar_url = Some(avatar_url); - } - stored_user.user.clone() - }; - self.persist_wechat_state(&state)?; - - Ok(next_user) - } - - fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> { - let mut state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - - if state - .session_id_by_refresh_token_hash - .contains_key(&session.refresh_token_hash) - { - return Err(RefreshSessionError::Store( - "refresh token hash 已存在,无法重复创建会话".to_string(), - )); - } - if state.sessions_by_id.len() >= MAX_REFRESH_SESSIONS { - return Err(RefreshSessionError::Store( - "refresh session 内存容量已达到上限".to_string(), - )); - } - - state.session_id_by_refresh_token_hash.insert( - session.refresh_token_hash.clone(), - session.session_id.clone(), - ); - state - .sessions_by_id - .insert(session.session_id.clone(), StoredRefreshSession { session }); - self.persist_refresh_state(&state)?; - - 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 !state.phone_codes_by_key.contains_key(&key) - && state.phone_codes_by_key.len() >= MAX_PHONE_CODES - { - return Err(PhoneAuthError::Store( - "短信验证码内存容量已达到上限,请稍后重试".to_string(), - )); - } - state.phone_codes_by_key.insert(key, code); - self.persist_phone_state(&state)?; - Ok(()) - } - - fn prune_expired_phone_codes(&self, now: OffsetDateTime) -> Result<(), PhoneAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; - let expired_keys = state - .phone_codes_by_key - .iter() - .filter_map(|(key, stored)| { - OffsetDateTime::parse( - &stored.expires_at, - &time::format_description::well_known::Rfc3339, - ) - .ok() - .filter(|expires_at| *expires_at <= now) - .map(|_| key.clone()) - }) - .collect::>(); - for key in expired_keys { - state.phone_codes_by_key.remove(&key); - } - Ok(()) - } - - fn ensure_phone_code_not_cooling_down( - &self, - phone_number: &str, - scene: &PhoneAuthScene, - now: OffsetDateTime, - ) -> Result<(), PhoneAuthError> { - let state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; - let key = build_phone_code_key(phone_number, scene); - let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else { - return Ok(()); - }; - drop(state); - - let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?; - if expires_at <= now { - return Ok(()); - } - 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 Ok(()); - } - let retry_after_seconds = seconds_until(now, cooling_until); - warn!( - scene = scene.as_str(), - phone_masked = mask_phone_number(phone_number).as_str(), - retry_after_seconds, - "手机号验证码发送命中本地冷却限制" - ); - Err(PhoneAuthError::SendCoolingDown { - retry_after_seconds, - }) - } - - fn ensure_phone_code_capacity( - &self, - phone_number: &str, - scene: &PhoneAuthScene, - ) -> Result<(), PhoneAuthError> { - let state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; - let key = build_phone_code_key(phone_number, scene); - if state.phone_codes_by_key.contains_key(&key) - || state.phone_codes_by_key.len() < MAX_PHONE_CODES - { - return Ok(()); - } - Err(PhoneAuthError::Store( - "短信验证码内存容量已达到上限,请稍后重试".to_string(), - )) - } - - fn get_active_phone_code( - &self, - phone_number: &str, - scene: &PhoneAuthScene, - now: OffsetDateTime, - ) -> Result { - 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); - } - Ok(stored) - } - - fn consume_phone_code_success( - &self, - phone_number: &str, - scene: &PhoneAuthScene, - ) -> Result<(), PhoneAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; - let key = build_phone_code_key(phone_number, scene); - state.phone_codes_by_key.remove(&key); - self.persist_phone_state(&state)?; - Ok(()) - } - - fn consume_phone_code_failure( - &self, - phone_number: &str, - scene: &PhoneAuthScene, - ) -> Result<(), PhoneAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; - let key = build_phone_code_key(phone_number, scene); - let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else { - return Err(PhoneAuthError::VerifyCodeNotFound); - }; - 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); - self.persist_phone_state(&state)?; - return Err(PhoneAuthError::VerifyAttemptsExceeded); - } - if let Some(current) = state.phone_codes_by_key.get_mut(&key) { - current.failed_attempts = next_failed_attempts; - } - self.persist_phone_state(&state)?; - Err(PhoneAuthError::InvalidVerifyCode) - } - - fn insert_wechat_state( - &self, - state_record: WechatAuthStateRecord, - now: OffsetDateTime, - ) -> Result<(), WechatAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; - prune_expired_short_lived_state(&mut state, now); - if state - .wechat_states_by_token - .contains_key(&state_record.state_token) - { - return Err(WechatAuthError::Store("微信 state 已存在".to_string())); - } - if state.wechat_states_by_token.len() >= MAX_ACTIVE_WECHAT_AUTH_STATES { - return Err(WechatAuthError::Store( - "微信登录请求过多,请稍后重试".to_string(), - )); - } - state.wechat_states_by_token.insert( - state_record.state_token.clone(), - StoredWechatAuthState { - state: state_record, - }, - ); - self.persist_wechat_state(&state)?; - 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; - let consumed = current.clone(); - state.wechat_states_by_token.remove(state_token.trim()); - self.persist_wechat_state(&state)?; - Ok(consumed) - } - - fn bind_wechat_phone_to_user( - &self, - pending_user_id: &str, - phone_number: PhoneNumberSnapshot, - wechat_display_name: Option, - ) -> Result<(AuthUser, bool), PhoneAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; - let submitted_wechat_display_name = normalize_optional_string(wechat_display_name); - - let existing_phone_user_id = - Self::resolve_phone_user_locked(&mut state, &phone_number.e164) - .map(|stored_user| stored_user.user.id); - 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_wechat_account = pending_wechat_identity.provider_uid.clone(); - let pending_user = state - .users_by_username - .values() - .find(|stored| stored.user.id == pending_user_id) - .cloned() - .ok_or(PhoneAuthError::UserNotFound)?; - let pending_username = pending_user.user.username.clone(); - let pending_wechat_display_name = submitted_wechat_display_name - .clone() - .or_else(|| normalize_optional_string(pending_wechat_identity.display_name.clone())) - .or_else(|| { - normalize_optional_string(pending_user.user.wechat_display_name.clone()) - }); - let pending_wechat_avatar_url = - normalize_optional_string(pending_wechat_identity.avatar_url.clone()) - .or_else(|| normalize_optional_string(pending_user.user.avatar_url.clone())); - 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(), - display_name: pending_wechat_display_name.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; - target_user.user.wechat_account = Some(pending_wechat_account); - target_user.user.wechat_display_name = pending_wechat_display_name.clone(); - if let Some(display_name) = pending_wechat_display_name { - target_user.user.display_name = display_name; - } - if target_user.user.avatar_url.is_none() { - target_user.user.avatar_url = pending_wechat_avatar_url; - } - if target_user.user.phone_number.is_none() { - target_user.user.phone_number = target_user.phone_number.clone(); - } - let next_user = target_user.user.clone(); - self.persist_phone_state(&state)?; - - return Ok((next_user, false)); - } - - state - .phone_to_user_id - .insert(phone_number.e164.clone(), pending_user_id.to_string()); - let bound_wechat_account = state - .wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == pending_user_id) - .map(|identity| identity.provider_uid.clone()); - let bound_wechat_display_name = submitted_wechat_display_name.clone().or_else(|| { - state - .wechat_identity_by_provider_uid - .values() - .find(|identity| identity.user_id == pending_user_id) - .and_then(|identity| normalize_optional_string(identity.display_name.clone())) - .or_else(|| { - state - .users_by_username - .values() - .find(|stored| stored.user.id == pending_user_id) - .and_then(|stored| { - normalize_optional_string(stored.user.wechat_display_name.clone()) - }) - }) - }); - - if let Some(display_name) = bound_wechat_display_name.clone() - && let Some(identity) = state - .wechat_identity_by_provider_uid - .values_mut() - .find(|identity| identity.user_id == pending_user_id) - { - identity.display_name = Some(display_name); - } - - 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 = Some(phone_number.e164.clone()); - 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; - if stored_user.user.wechat_account.is_none() { - stored_user.user.wechat_account = bound_wechat_account; - } - if let Some(display_name) = bound_wechat_display_name { - stored_user.user.display_name = display_name.clone(); - stored_user.user.wechat_display_name = Some(display_name); - } - stored_user.phone_number = Some(phone_number.e164); - let next_user = stored_user.user.clone(); - self.persist_phone_state(&state)?; - - Ok((next_user, true)) - } - - fn find_session_by_refresh_token_hash( - &self, - refresh_token_hash: &str, - ) -> Result, RefreshSessionError> { - let state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let Some(session_id) = state - .session_id_by_refresh_token_hash - .get(refresh_token_hash) - else { - return Ok(None); - }; - - Ok(state.sessions_by_id.get(session_id).cloned()) - } - - fn list_active_sessions_by_user( - &self, - user_id: &str, - now: OffsetDateTime, - ) -> Result, RefreshSessionError> { - let state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let now_unix = now.unix_timestamp(); - - let mut sessions = state - .sessions_by_id - .values() - .filter_map(|stored| { - if stored.session.user_id != user_id { - return None; - } - if stored.session.revoked_at.is_some() { - return None; - } - let expires_at = OffsetDateTime::parse( - &stored.session.expires_at, - &time::format_description::well_known::Rfc3339, - ) - .ok()?; - if expires_at.unix_timestamp() <= now_unix { - return None; - } - - Some(stored.session.clone()) - }) - .collect::>(); - - sessions.sort_by(|left, right| { - right - .last_seen_at - .cmp(&left.last_seen_at) - .then_with(|| right.created_at.cmp(&left.created_at)) - }); - - Ok(sessions) - } - - fn is_session_active_for_user( - &self, - user_id: &str, - session_id: &str, - now: OffsetDateTime, - ) -> Result { - if session_id.trim().is_empty() { - return Ok(false); - } - - let state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let Some(stored) = state.sessions_by_id.get(session_id) else { - return Ok(false); - }; - if stored.session.user_id != user_id || stored.session.revoked_at.is_some() { - return Ok(false); - } - - let expires_at = OffsetDateTime::parse( - &stored.session.expires_at, - &time::format_description::well_known::Rfc3339, - ) - .map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?; - - Ok(expires_at > now) - } - - fn rotate_session( - &self, - session_id: &str, - previous_refresh_token_hash: &str, - next_refresh_token_hash: String, - next_expires_at: String, - updated_at: String, - last_seen_at: String, - ) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - - if state - .session_id_by_refresh_token_hash - .contains_key(&next_refresh_token_hash) - { - return Err(RefreshSessionError::Store( - "新 refresh token hash 已存在,无法轮换".to_string(), - )); - } - - let current_refresh_token_hash = state - .sessions_by_id - .get(session_id) - .ok_or(RefreshSessionError::SessionNotFound)? - .session - .refresh_token_hash - .clone(); - if current_refresh_token_hash != previous_refresh_token_hash { - return Err(RefreshSessionError::SessionNotFound); - } - - state - .session_id_by_refresh_token_hash - .remove(previous_refresh_token_hash); - let stored = state - .sessions_by_id - .get_mut(session_id) - .ok_or(RefreshSessionError::SessionNotFound)?; - stored.session.refresh_token_hash = next_refresh_token_hash.clone(); - stored.session.expires_at = next_expires_at; - stored.session.updated_at = updated_at; - stored.session.last_seen_at = last_seen_at; - let updated_session = stored.clone(); - state.session_id_by_refresh_token_hash.insert( - next_refresh_token_hash, - updated_session.session.session_id.clone(), - ); - self.persist_refresh_state(&state)?; - - Ok(updated_session) - } - - fn revoke_session_by_refresh_token_hash( - &self, - refresh_token_hash: &str, - now: OffsetDateTime, - ) -> Result<(), RefreshSessionError> { - let mut state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let Some(session_id) = state - .session_id_by_refresh_token_hash - .get(refresh_token_hash) - .cloned() - else { - return Ok(()); - }; - let Some(stored) = state.sessions_by_id.get_mut(&session_id) else { - return Ok(()); - }; - if stored.session.revoked_at.is_some() { - return Ok(()); - } - let now_iso = now - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) - })?; - stored.session.revoked_at = Some(now_iso.clone()); - stored.session.updated_at = now_iso; - self.persist_refresh_state(&state)?; - - Ok(()) - } - - fn prune_wechat_states(&self, now: OffsetDateTime) -> Result<(), WechatAuthError> { - let mut state = self - .inner - .lock() - .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; - let stale_tokens = state - .wechat_states_by_token - .iter() - .filter_map(|(token, stored)| { - if stored.state.consumed_at.is_some() { - return Some(token.clone()); - } - OffsetDateTime::parse( - &stored.state.expires_at, - &time::format_description::well_known::Rfc3339, - ) - .ok() - .filter(|expires_at| *expires_at <= now) - .map(|_| token.clone()) - }) - .collect::>(); - for token in stale_tokens { - state.wechat_states_by_token.remove(&token); - } - Ok(()) - } - - fn revoke_session_by_user_and_session_id( - &self, - user_id: &str, - session_id: &str, - now: OffsetDateTime, - ) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let Some(stored) = state.sessions_by_id.get_mut(session_id) else { - return Ok(false); - }; - if stored.session.user_id != user_id { - return Ok(false); - } - if stored.session.revoked_at.is_some() { - return Ok(false); - } - let now_iso = now - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) - })?; - stored.session.revoked_at = Some(now_iso.clone()); - stored.session.updated_at = now_iso; - self.persist_refresh_state(&state)?; - - Ok(true) - } - - fn revoke_all_sessions_by_user_id( - &self, - user_id: &str, - now: OffsetDateTime, - ) -> Result<(), RefreshSessionError> { - let mut state = self - .inner - .lock() - .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; - let now_iso = now - .format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) - })?; - - for stored in state.sessions_by_id.values_mut() { - if stored.session.user_id != user_id { - continue; - } - if stored.session.revoked_at.is_some() { - continue; - } - - stored.session.revoked_at = Some(now_iso.clone()); - stored.session.updated_at = now_iso.clone(); - } - self.persist_refresh_state(&state)?; - - Ok(()) - } - - fn increment_user_token_version( - &self, - user_id: &str, - ) -> Result, PasswordEntryError> { - let mut state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - - for stored_user in state.users_by_username.values_mut() { - if stored_user.user.id != user_id { - continue; - } - - stored_user.user.token_version += 1; - let next_user = stored_user.user.clone(); - self.persist_password_state(&state)?; - return Ok(Some(next_user)); - } - - Ok(None) - } - - fn set_user_password_hash( - &self, - user_id: &str, - password_hash: String, - revoke_all_sessions_at: Option, - ) -> Result, PasswordEntryError> { - let mut state = self - .inner - .lock() - .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; - let revoke_all_sessions_at = match revoke_all_sessions_at { - Some(now) => Some( - now.format(&time::format_description::well_known::Rfc3339) - .map_err(|error| { - PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}")) - })?, - ), - None => None, - }; - - for stored_user in state.users_by_username.values_mut() { - if stored_user.user.id != user_id { - continue; - } - - stored_user.password_hash = password_hash; - stored_user.password_login_enabled = true; - stored_user.user.token_version += 1; - let next_user = stored_user.user.clone(); - if let Some(now_iso) = revoke_all_sessions_at.as_ref() { - for stored_session in state.sessions_by_id.values_mut() { - if stored_session.session.user_id != user_id - || stored_session.session.revoked_at.is_some() - { - continue; - } - - stored_session.session.revoked_at = Some(now_iso.clone()); - stored_session.session.updated_at = now_iso.clone(); - } - } - self.persist_password_state(&state)?; - return Ok(Some(next_user)); - } - - Ok(None) - } - - fn set_user_password_by_phone_number( - &self, - phone_number: &str, - password_hash: String, - ) -> Result { - let mut state = self - .inner - .lock() - .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; - let user_id = Self::resolve_phone_user_locked(&mut state, phone_number) - .map(|stored_user| stored_user.user.id) - .ok_or(PhoneAuthError::UserNotFound)?; - - for stored_user in state.users_by_username.values_mut() { - if stored_user.user.id != user_id { - continue; - } - - stored_user.password_hash = password_hash; - stored_user.password_login_enabled = true; - stored_user.user.token_version += 1; - let next_user = stored_user.user.clone(); - self.persist_phone_state(&state)?; - return Ok(next_user); - } - - Err(PhoneAuthError::UserNotFound) - } -} - -fn should_prune_refresh_session(session: &RefreshSessionRecord, now: OffsetDateTime) -> bool { - should_prune_refresh_session_fields(&session.expires_at, session.revoked_at.as_deref(), now) -} - -fn should_prune_refresh_session_fields( - expires_at: &str, - revoked_at: Option<&str>, - now: OffsetDateTime, -) -> bool { - let stale_before = now.saturating_sub(REFRESH_SESSION_STALE_RETENTION); - if let Some(revoked_at) = revoked_at { - return OffsetDateTime::parse(revoked_at, &time::format_description::well_known::Rfc3339) - .is_ok_and(|timestamp| timestamp <= stale_before); - } - - OffsetDateTime::parse(expires_at, &time::format_description::well_known::Rfc3339) - .is_ok_and(|timestamp| timestamp <= stale_before) -} - -fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { - match error { - SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, - SmsProviderError::InvalidConfig(message) => { - PhoneAuthError::SmsProviderInvalidConfig(message) - } - SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message), - } -} - -fn map_phone_number_error_to_password_error(error: PhoneAuthError) -> PasswordEntryError { - match error { - PhoneAuthError::UnsupportedPhoneCountryCode => { - PasswordEntryError::UnsupportedPhoneCountryCode - } - _ => PasswordEntryError::InvalidPhoneNumber, - } -} - -async fn verify_stored_password_user( - existing_user: StoredPasswordUser, - password: &str, -) -> Result { - if !existing_user.password_login_enabled { - return Err(PasswordEntryError::InvalidCredentials); - } - - let is_valid = verify_password(&existing_user.password_hash, password) - .await - .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; - if !is_valid { - return Err(PasswordEntryError::InvalidCredentials); - } - - Ok(PasswordEntryResult { - user: AuthUser { - login_method: AuthLoginMethod::Password, - ..existing_user.user - }, - created: false, - }) -} - -fn normalize_profile_display_name(value: &str) -> Result { - let Some(display_name) = normalize_required_string(value) else { - return Err(PasswordEntryError::InvalidDisplayName); - }; - let length = display_name.chars().count(); - if !(2..=20).contains(&length) { - return Err(PasswordEntryError::InvalidDisplayName); - } - if !display_name.chars().all(|character| { - character == '_' - || character.is_ascii_alphanumeric() - || is_common_chinese_character(character) - }) { - return Err(PasswordEntryError::InvalidDisplayName); - } - - Ok(display_name) -} - -fn normalize_profile_avatar_url(value: &str) -> Result { - let Some(avatar_url) = normalize_required_string(value) else { - return Err(PasswordEntryError::InvalidAvatarDataUrl); - }; - if !avatar_url.starts_with("data:image/") || !avatar_url.contains(";base64,") { - return Err(PasswordEntryError::InvalidAvatarDataUrl); - } - - Ok(avatar_url) -} - -fn is_common_chinese_character(character: char) -> bool { - ('\u{4e00}'..='\u{9fff}').contains(&character) -} - -fn build_random_password_seed() -> String { - format!( - "seed_{}_{}", - new_uuid_simple_string(), - new_uuid_simple_string() - ) -} - -fn generate_random_phone_verify_code() -> String { - let digest = Sha256::digest(new_uuid_simple_string().as_bytes()); - let mut digits = digest - .iter() - .take(SMS_CODE_LENGTH) - .map(|byte| char::from(b'0' + (*byte % 10))) - .collect::(); - while digits.len() < SMS_CODE_LENGTH { - digits.push('0'); - } - digits -} - -fn hash_phone_verify_code( - salt: &str, - phone_number: &str, - scene: &PhoneAuthScene, - verify_code: &str, -) -> String { - let content = format!( - "{}:{}:{}:{}", - salt, - phone_number.trim(), - scene.as_str(), - verify_code.trim() - ); - let digest = Sha256::digest(content.as_bytes()); - digest.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn format_rfc3339(value: OffsetDateTime) -> Result { - format_shared_rfc3339(value) -} - -#[allow(dead_code)] -fn current_auth_user_created_at() -> String { - format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| default_auth_user_created_at()) -} - -#[allow(dead_code)] -fn default_auth_user_created_at() -> String { - "1970-01-01T00:00:00Z".to_string() -} - -fn parse_phone_code_time(value: &str, field_label: &str) -> Result { - parse_rfc3339(value) - .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 create_wechat_state_token() -> String { - new_uuid_simple_string() -} - -fn format_rfc3339_with_context( - value: OffsetDateTime, - field_label: &str, -) -> Result { - format_shared_rfc3339(value) - .map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}"))) -} - -fn parse_rfc3339_with_context( - value: &str, - field_label: &str, -) -> Result { - parse_rfc3339(value) - .map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}"))) -} - -#[cfg(test)] -mod tests { - use platform_auth::{ - DEFAULT_SMS_CASE_AUTH_POLICY, DEFAULT_SMS_CODE_LENGTH, DEFAULT_SMS_CODE_TYPE, - DEFAULT_SMS_COUNTRY_CODE, DEFAULT_SMS_DUPLICATE_POLICY, DEFAULT_SMS_ENDPOINT, - DEFAULT_SMS_INTERVAL_SECONDS, DEFAULT_SMS_MOCK_VERIFY_CODE, DEFAULT_SMS_TEMPLATE_PARAM_KEY, - DEFAULT_SMS_VALID_TIME_SECONDS, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, - hash_refresh_session_token, - }; - - use super::*; - - #[test] - fn build_wechat_username_uses_display_name_and_provider_uid() { - assert_eq!( - build_wechat_username("小明", "wx-openid-123"), - "小明_wx-openid-123" - ); - assert_eq!( - build_wechat_username(" ", "wx-openid-123"), - "微信旅人_wx-openid-123" - ); - } - - #[test] - fn mainland_china_phone_normalization_accepts_pure_phone_number() { - let national = normalize_mainland_china_phone_number("13800138000") - .expect("national phone should normalize"); - - assert_eq!(national.e164, "+8613800138000"); - } - - #[test] - fn mainland_china_country_code_defaults_to_china_and_accepts_explicit_86() { - validate_mainland_china_country_code(None).expect("missing country code should default"); - validate_mainland_china_country_code(Some(" 86 ")) - .expect("explicit mainland China country code should pass"); - } - - #[test] - fn mainland_china_country_code_rejects_foreign_or_non_wechat_format() { - for country_code in ["1", "+86", ""] { - let error = validate_mainland_china_country_code(Some(country_code)) - .expect_err("unsupported country code should fail"); - - assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); - assert_eq!(error.to_string(), "仅支持中国大陆手机号(+86)"); - } - } - - fn build_store() -> InMemoryAuthStore { - InMemoryAuthStore::default() - } - - fn empty_projection_store() -> InMemoryAuthStore { - InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { - base_updated_at_micros: 0, - updated_at_micros: 0, - users: vec![], - identities: vec![], - refresh_sessions: vec![], - phone_codes: vec![], - wechat_states: vec![], - }) - .expect("projection should restore") - } - - fn projection_user( - user_id: &str, - username: &str, - phone_number: Option<&str>, - ) -> AuthStoreProjectionUser { - AuthStoreProjectionUser { - user_id: user_id.to_string(), - public_user_code: "SY-00000009".to_string(), - username: username.to_string(), - display_name: "138****8009".to_string(), - avatar_url: None, - phone_number_masked: Some("138****8009".to_string()), - phone_number_e164: phone_number.map(str::to_string), - login_method: "phone".to_string(), - binding_status: "active".to_string(), - wechat_bound: false, - password_hash: "hash-existing".to_string(), - password_login_enabled: false, - token_version: 1, - created_at: "2026-06-30T00:00:00Z".to_string(), - } - } - - fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService { - PasswordEntryService::new(store) - } - - fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService { - let sms_provider = SmsAuthProvider::new( - SmsAuthConfig::new( - SmsAuthProviderKind::Mock, - DEFAULT_SMS_ENDPOINT.to_string(), - None, - None, - String::new(), - String::new(), - DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(), - DEFAULT_SMS_COUNTRY_CODE.to_string(), - None, - DEFAULT_SMS_CODE_LENGTH, - DEFAULT_SMS_CODE_TYPE, - DEFAULT_SMS_VALID_TIME_SECONDS, - DEFAULT_SMS_INTERVAL_SECONDS, - DEFAULT_SMS_DUPLICATE_POLICY, - DEFAULT_SMS_CASE_AUTH_POLICY, - false, - DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), - ) - .expect("mock sms config should be valid"), - ) - .expect("mock sms provider should be valid"); - PhoneAuthService::new(store, sms_provider) - } - - fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService { - RefreshSessionService::new(store, 30) - } - - fn build_user_service(store: InMemoryAuthStore) -> AuthUserService { - AuthUserService::new(store) - } - - fn build_client_info() -> RefreshSessionClientInfo { - RefreshSessionClientInfo { - client_type: "web_browser".to_string(), - client_runtime: "chrome".to_string(), - client_platform: "windows".to_string(), - client_instance_id: Some("client-instance-001".to_string()), - device_fingerprint: Some("device-fingerprint-001".to_string()), - device_display_name: "Windows / Chrome".to_string(), - mini_program_app_id: None, - mini_program_env: None, - user_agent: Some("Mozilla/5.0".to_string()), - ip: Some("203.0.113.10".to_string()), - } - } - - async fn create_phone_login_user(store: InMemoryAuthStore, phone_number: &str) -> AuthUser { - let phone_service = build_phone_service(store); - let now = OffsetDateTime::now_utc(); - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: phone_number.to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send"); - phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: phone_number.to_string(), - verify_code: "123456".to_string(), - }, - now + Duration::seconds(1), - ) - .await - .expect("phone login should create user") - .user - } - - #[tokio::test] - async fn password_entry_rejects_unknown_user_without_registration() { - let service = build_password_service(build_store()); - - let error = service - .execute(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - password: "secret123".to_string(), - }) - .await - .expect_err("password login must not create user"); - - assert_eq!(error, PasswordEntryError::InvalidCredentials); - } - - #[tokio::test] - async fn password_entry_dev_registration_creates_unknown_phone_user() { - let service = build_password_service(build_store()); - - let created = service - .execute_with_dev_registration(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138009".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("dev registration should create user"); - let reused = service - .execute_with_dev_registration(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138009".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("same password should reuse created user"); - let wrong_password = service - .execute_with_dev_registration(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138009".to_string(), - password: "secret999".to_string(), - }) - .await - .expect_err("existing user still requires the right password"); - - assert!(created.created); - assert_eq!(created.user.login_method, AuthLoginMethod::Password); - assert!(!reused.created); - assert_eq!(created.user.id, reused.user.id); - assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); - } - - #[tokio::test] - async fn phone_user_can_set_password_then_login() { - let store = build_store(); - let user = create_phone_login_user(store.clone(), "13800138000").await; - let service = build_password_service(store); - - service - .change_password(ChangePasswordInput { - user_id: user.id.clone(), - current_password: None, - new_password: "secret123".to_string(), - }) - .await - .expect("phone user should set first password"); - let result = service - .execute(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("password login should succeed after setting password"); - - assert!(!result.created); - assert_eq!(result.user.id, user.id); - assert_eq!(result.user.login_method, AuthLoginMethod::Password); - } - - #[tokio::test] - async fn change_password_and_revoke_all_sessions_revokes_every_refresh_session() { - let store = build_store(); - let user = create_phone_login_user(store.clone(), "13800138030").await; - let password_service = build_password_service(store.clone()); - let refresh_service = build_refresh_service(store.clone()); - let now = OffsetDateTime::now_utc(); - - let first_password_user = password_service - .change_password(ChangePasswordInput { - user_id: user.id.clone(), - current_password: None, - new_password: "secret123".to_string(), - }) - .await - .expect("first password should set") - .user; - let first_token_hash = hash_refresh_session_token("change-password-token-01"); - let second_token_hash = hash_refresh_session_token("change-password-token-02"); - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: first_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("first session should create"); - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: second_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: RefreshSessionClientInfo { - client_runtime: "safari".to_string(), - device_display_name: "iOS / Safari".to_string(), - ..build_client_info() - }, - }, - now + Duration::seconds(1), - ) - .expect("second session should create"); - - let changed_user = password_service - .change_password_and_revoke_all_sessions( - ChangePasswordInput { - user_id: user.id.clone(), - current_password: Some("secret123".to_string()), - new_password: "secret456".to_string(), - }, - now + Duration::minutes(1), - ) - .await - .expect("password change should revoke all sessions") - .user; - - assert_eq!( - changed_user.token_version, - first_password_user.token_version + 1 - ); - assert!( - refresh_service - .list_active_sessions_by_user(&user.id, now + Duration::minutes(2)) - .expect("active sessions should list") - .sessions - .is_empty() - ); - for (token_hash, next_hash) in [ - ( - first_token_hash, - hash_refresh_session_token("change-password-token-01-next"), - ), - ( - second_token_hash, - hash_refresh_session_token("change-password-token-02-next"), - ), - ] { - let refresh_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: token_hash, - next_refresh_token_hash: next_hash, - }, - now + Duration::minutes(2), - ) - .expect_err("revoked session should not rotate"); - assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); - } - - assert_eq!( - password_service - .execute(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138030".to_string(), - password: "secret123".to_string(), - }) - .await - .expect_err("old password should fail"), - PasswordEntryError::InvalidCredentials - ); - let login = password_service - .execute(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138030".to_string(), - password: "secret456".to_string(), - }) - .await - .expect("new password should login"); - assert_eq!(login.user.id, user.id); - } - - #[tokio::test] - async fn password_entry_rejects_wrong_password_after_set() { - let store = build_store(); - let user = create_phone_login_user(store.clone(), "13800138001").await; - let service = build_password_service(store); - service - .change_password(ChangePasswordInput { - user_id: user.id.clone(), - current_password: None, - new_password: "secret123".to_string(), - }) - .await - .expect("password should set"); - - let error = service - .execute(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138001".to_string(), - password: "secret999".to_string(), - }) - .await - .expect_err("wrong password should fail"); - - assert_eq!(error, PasswordEntryError::InvalidCredentials); - } - - #[tokio::test] - async fn reset_password_requires_existing_phone_user() { - let store = build_store(); - let phone_service = build_phone_service(store.clone()); - let now = OffsetDateTime::now_utc(); - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138002".to_string(), - scene: PhoneAuthScene::ResetPassword, - }, - now, - ) - .await - .expect("reset code should send"); - - let error = phone_service - .reset_password( - ResetPasswordInput { - country_code: None, - pure_phone_number: "13800138002".to_string(), - verify_code: "123456".to_string(), - new_password: "secret123".to_string(), - }, - now + Duration::seconds(1), - ) - .await - .expect_err("unknown phone must not register by reset"); - - assert_eq!(error, PhoneAuthError::UserNotFound); - } - - #[tokio::test] - async fn dev_password_registration_after_empty_projection_restore() { - let service = build_password_service(empty_projection_store()); - - let created = service - .execute_with_dev_registration(PasswordEntryInput { - country_code: None, - pure_phone_number: "13800138004".to_string(), - password: "secret123".to_string(), - }) - .await - .expect("orphan phone index should not block dev registration"); - - assert!(created.created); - assert_eq!( - created.user.phone_number_masked.as_deref(), - Some("138****8004") - ); - } - - #[tokio::test] - async fn phone_login_after_empty_projection_restore() { - let phone_service = build_phone_service(empty_projection_store()); - let now = OffsetDateTime::now_utc(); - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138005".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send"); - - let created = phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: "13800138005".to_string(), - verify_code: "123456".to_string(), - }, - now + Duration::seconds(1), - ) - .await - .expect("orphan phone index should not turn login into duplicate create"); - - assert!(created.created); - assert_eq!( - created.user.phone_number_masked.as_deref(), - Some("138****8005") - ); - } - - #[tokio::test] - async fn phone_login_reuses_user_restored_from_projection() { - let phone_service = build_phone_service( - InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { - base_updated_at_micros: 0, - updated_at_micros: 1, - users: vec![projection_user( - "user_existing_phone", - "phone_existing", - Some("+8613800138009"), - )], - identities: vec![], - refresh_sessions: vec![], - phone_codes: vec![], - wechat_states: vec![], - }) - .expect("projection should restore"), - ); - let now = OffsetDateTime::now_utc(); - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138009".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send"); - - let reused = phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: "13800138009".to_string(), - verify_code: "123456".to_string(), - }, - now + Duration::seconds(1), - ) - .await - .expect("phone login should reuse existing user"); - - assert!(!reused.created); - assert_eq!(reused.user.id, "user_existing_phone"); - } - - #[tokio::test] - async fn projection_restores_user_and_refresh_session_after_roundtrip() { - let store = InMemoryAuthStore::default(); - let user = create_phone_login_user(store.clone(), "13800138003").await; - let password_service = build_password_service(store.clone()); - let refresh_service = build_refresh_service(store.clone()); - password_service - .change_password(ChangePasswordInput { - user_id: user.id.clone(), - current_password: None, - new_password: "secret123".to_string(), - }) - .await - .expect("password should set before persistence check"); - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("persist-token-01"), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - OffsetDateTime::now_utc(), - ) - .expect("refresh session should be persisted"); - - let projection = store - .export_projection_view(1) - .expect("projection export should succeed"); - let restored_store = - InMemoryAuthStore::from_projection_view(projection).expect("projection should restore"); - let restored_user = build_password_service(restored_store.clone()) - .get_user_by_id(&user.id) - .expect("restored user query should succeed") - .expect("restored user should exist") - .user; - assert_eq!(restored_user.username, user.username); - - let rotated = build_refresh_service(restored_store) - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: hash_refresh_session_token("persist-token-01"), - next_refresh_token_hash: hash_refresh_session_token("persist-token-02"), - }, - OffsetDateTime::now_utc(), - ) - .expect("restored refresh session should rotate"); - assert_eq!(rotated.user.id, user.id); - } - - #[tokio::test] - async fn projection_roundtrip_preserves_phone_code_and_wechat_state() { - let store = InMemoryAuthStore::default(); - let phone_service = build_phone_service(store.clone()); - let wechat_state_service = WechatAuthStateService::new(store.clone(), 5); - let now = OffsetDateTime::now_utc(); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138040".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send before projection export"); - let created_state = wechat_state_service - .create_state( - CreateWechatAuthStateInput { - redirect_path: "/studio".to_string(), - scene: WechatAuthScene::Desktop, - request_user_agent: Some("test-agent".to_string()), - bind_user_id: None, - }, - now, - ) - .expect("wechat state should be created before projection export"); - - let projection = store - .export_projection_view(1) - .expect("projection export should include short-lived auth state"); - assert_eq!(projection.phone_codes.len(), 1); - assert_eq!(projection.wechat_states.len(), 1); - - let restored_store = InMemoryAuthStore::from_projection_view(projection) - .expect("projection should restore short-lived auth state"); - let restored_phone_service = build_phone_service(restored_store.clone()); - let login = restored_phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: "13800138040".to_string(), - verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), - }, - now + Duration::seconds(1), - ) - .await - .expect("restored phone code should verify"); - assert!(login.created); - - let consumed_state = WechatAuthStateService::new(restored_store, 5) - .consume_state(&created_state.state.state_token, now + Duration::seconds(1)) - .expect("restored wechat state should be consumable"); - assert_eq!(consumed_state.state.redirect_path, "/studio"); - } - - #[test] - fn wechat_state_creation_is_bounded_before_projection_sync() { - let store = InMemoryAuthStore::default(); - let service = WechatAuthStateService::new(store, 5); - let now = OffsetDateTime::now_utc(); - - for index in 0..MAX_ACTIVE_WECHAT_AUTH_STATES { - service - .create_state( - CreateWechatAuthStateInput { - redirect_path: format!("/studio?attempt={index}"), - scene: WechatAuthScene::Desktop, - request_user_agent: None, - bind_user_id: None, - }, - now, - ) - .expect("active wechat state should fit within the projection budget"); - } - - let error = service - .create_state( - CreateWechatAuthStateInput { - redirect_path: "/studio".to_string(), - scene: WechatAuthScene::Desktop, - request_user_agent: None, - bind_user_id: None, - }, - now, - ) - .expect_err("wechat state creation must reject an unbounded projection"); - assert!(matches!(error, WechatAuthError::Store(message) if message.contains("请求过多"))); - } - - #[tokio::test] - async fn refresh_from_projection_view_merges_session_created_by_another_process() { - let source_store = InMemoryAuthStore::default(); - let user = create_phone_login_user(source_store.clone(), "13800138033").await; - let source_refresh_service = build_refresh_service(source_store.clone()); - let source_session = source_refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("remote-process-token"), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - OffsetDateTime::now_utc(), - ) - .expect("source session should create"); - let projection = source_store - .export_projection_view(1) - .expect("source projection should export"); - - let local_store = InMemoryAuthStore::default(); - let local_phone_service = build_phone_service(local_store.clone()); - let local_now = OffsetDateTime::now_utc(); - local_phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138034".to_string(), - scene: PhoneAuthScene::Login, - }, - local_now, - ) - .await - .expect("local transient phone code should send"); - local_store - .refresh_from_projection_view(projection) - .expect("local store should refresh"); - assert!( - build_refresh_service(local_store) - .is_session_active_for_user( - &user.id, - &source_session.session.session_id, - OffsetDateTime::now_utc() + Duration::minutes(1) - ) - .expect("refreshed session active check should succeed") - ); - // 刷新到数据库正式投影后,短期认证状态也以数据库快照为准;本地未同步的验证码 - // 不得继续留在工作集里,避免消费已被其他节点清理的验证码。 - local_phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138034".to_string(), - scene: PhoneAuthScene::Login, - }, - local_now + Duration::seconds(5), - ) - .await - .expect("phone code should be resendable after authoritative refresh"); - } - - #[test] - fn conditional_projection_refresh_rejects_stale_revision() { - let store = InMemoryAuthStore::default(); - let projection = AuthStoreProjectionView { - base_updated_at_micros: 0, - updated_at_micros: 1, - users: vec![], - identities: vec![], - refresh_sessions: vec![], - phone_codes: vec![], - wechat_states: vec![], - }; - - assert_eq!(store.revision(), 0); - store - .refresh_from_projection_view(projection.clone()) - .expect("initial projection refresh should succeed"); - assert_eq!(store.revision(), 1); - assert!( - !store - .refresh_from_projection_view_if_revision(projection, 0) - .expect("stale projection refresh should be checked without error") - ); - assert_eq!(store.revision(), 1); - } - - #[tokio::test] - async fn empty_projection_restore_does_not_block_phone_login() { - let phone_service = build_phone_service(empty_projection_store()); - let now = OffsetDateTime::now_utc(); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138032".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send"); - let result = phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: "13800138032".to_string(), - verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), - }, - now + Duration::seconds(1), - ) - .await - .expect("orphan phone index should not block phone login"); - - assert!(result.created); - assert_eq!( - result.user.phone_number_masked.as_deref(), - Some("138****8032") - ); - } - - #[tokio::test] - async fn password_entry_rejects_email_or_username_identifier() { - let service = build_password_service(build_store()); - - let error = service - .execute(PasswordEntryInput { - country_code: None, - pure_phone_number: "user@example.com".to_string(), - password: "secret123".to_string(), - }) - .await - .expect_err("email should fail"); - - assert_eq!(error, PasswordEntryError::InvalidPhoneNumber); - } - - #[tokio::test] - async fn password_entry_rejects_foreign_country_code() { - let service = build_password_service(build_store()); - - let error = service - .execute(PasswordEntryInput { - country_code: Some("1".to_string()), - pure_phone_number: "12025550123".to_string(), - password: "secret123".to_string(), - }) - .await - .expect_err("foreign phone should fail"); - - assert_eq!(error, PasswordEntryError::UnsupportedPhoneCountryCode); - } - - #[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 { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("first phone code should send"); - - let error = service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now + Duration::seconds(10), - ) - .await - .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 authoritative_phone_code_reservation_blocks_duplicate_provider_send() { - let service = build_phone_service(build_store()); - let input = SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138001".to_string(), - scene: PhoneAuthScene::Login, - }; - let now = OffsetDateTime::now_utc(); - - service - .reserve_code_send(&input, now) - .expect("authoritative reservation should be representable locally"); - let duplicate = service - .reserve_code_send(&input, now + Duration::seconds(1)) - .expect_err("a second reservation must observe the local cooldown"); - assert!(matches!(duplicate, PhoneAuthError::SendCoolingDown { .. })); - - service - .send_code_after_authoritative_reservation(input, now) - .await - .expect("provider send should replace the reservation with the real code"); - } - - #[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 { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("login scene code should send"); - let bind_result = service.send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::BindPhone, - }, - now + Duration::seconds(1), - ); - - assert!(bind_result.await.is_ok()); - } - - #[test] - fn random_phone_verify_code_is_six_digits() { - let code = generate_random_phone_verify_code(); - - assert_eq!(code.len(), SMS_CODE_LENGTH); - assert!(code.chars().all(|character| character.is_ascii_digit())); - } - - #[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 { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send"); - - for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS { - let error = service - .login( - PhoneLoginInput { - country_code: None, - pure_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 { - country_code: None, - pure_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 { - country_code: None, - pure_phone_number: "13800138000".to_string(), - verify_code: DEFAULT_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 { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)), - ) - .await - .expect("deleted snapshot should allow a new code"); - let login = service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - verify_code: DEFAULT_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(); - let user = create_phone_login_user(store.clone(), "13800138004").await; - let refresh_service = build_refresh_service(store); - let now = OffsetDateTime::now_utc(); - let first_token_hash = hash_refresh_session_token("refresh-token-01"); - let created = refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: first_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("session should create"); - - let rotated = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: first_token_hash, - next_refresh_token_hash: hash_refresh_session_token("refresh-token-02"), - }, - now + Duration::minutes(10), - ) - .expect("session should rotate"); - - assert_eq!(rotated.user.id, user.id); - assert_eq!(rotated.session.session_id, created.session.session_id); - assert_ne!( - rotated.session.refresh_token_hash, - created.session.refresh_token_hash - ); - } - - #[tokio::test] - async fn refresh_session_rejects_unknown_token_hash() { - let store = build_store(); - let refresh_service = build_refresh_service(store); - - let error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: hash_refresh_session_token("missing"), - next_refresh_token_hash: hash_refresh_session_token("next"), - }, - OffsetDateTime::now_utc(), - ) - .expect_err("unknown token should fail"); - - assert_eq!(error, RefreshSessionError::SessionNotFound); - } - - #[tokio::test] - async fn logout_current_session_revokes_only_current_session_without_token_version_bump() { - let store = build_store(); - let user = create_phone_login_user(store.clone(), "13800138005").await; - let refresh_service = build_refresh_service(store.clone()); - let user_service = build_user_service(store); - let refresh_token_hash = hash_refresh_session_token("logout-token"); - let other_refresh_token_hash = hash_refresh_session_token("logout-token-other"); - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: refresh_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - OffsetDateTime::now_utc(), - ) - .expect("session should create"); - let other_session = refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: other_refresh_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: RefreshSessionClientInfo { - client_runtime: "firefox".to_string(), - device_display_name: "Windows / Firefox".to_string(), - ..build_client_info() - }, - }, - OffsetDateTime::now_utc() + Duration::seconds(1), - ) - .expect("other session should create"); - - let result = user_service - .logout_current_session( - LogoutCurrentSessionInput { - user_id: user.id.clone(), - refresh_token_hash: Some(refresh_token_hash.clone()), - session_id: None, - }, - OffsetDateTime::now_utc(), - ) - .expect("logout should succeed"); - - assert_eq!(result.user.token_version, user.token_version); - - let refresh_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash, - next_refresh_token_hash: hash_refresh_session_token("logout-token-next"), - }, - OffsetDateTime::now_utc(), - ) - .expect_err("revoked session should fail"); - assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); - assert!( - refresh_service - .is_session_active_for_user( - &user.id, - &other_session.session.session_id, - OffsetDateTime::now_utc() + Duration::minutes(2) - ) - .expect("other session active check should succeed") - ); - let rotated_other = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: other_refresh_token_hash, - next_refresh_token_hash: hash_refresh_session_token("logout-token-other-next"), - }, - OffsetDateTime::now_utc() + Duration::minutes(2), - ) - .expect("other session should still rotate"); - assert_eq!(rotated_other.user.id, user.id); - } - - #[tokio::test] - async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() { - let store = build_store(); - let user = create_phone_login_user(store.clone(), "13800138028").await; - let refresh_service = build_refresh_service(store.clone()); - let now = OffsetDateTime::now_utc(); - let first_token_hash = hash_refresh_session_token("revoke-target-token"); - let second_token_hash = hash_refresh_session_token("revoke-current-token"); - - let target = refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: first_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("target session should create"); - let current = refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: second_token_hash, - issued_by_provider: AuthLoginMethod::Password, - client_info: RefreshSessionClientInfo { - client_runtime: "firefox".to_string(), - device_display_name: "Windows / Firefox".to_string(), - ..build_client_info() - }, - }, - now + Duration::seconds(1), - ) - .expect("current session should create"); - - let revoke = refresh_service - .revoke_session_by_user_and_session( - RevokeRefreshSessionByUserInput { - user_id: user.id.clone(), - session_id: target.session.session_id.clone(), - }, - now + Duration::minutes(1), - ) - .expect("target session should revoke"); - - assert!(revoke.revoked); - assert_eq!(revoke.session_id, target.session.session_id); - assert!( - !refresh_service - .is_session_active_for_user( - &user.id, - &target.session.session_id, - now + Duration::minutes(2) - ) - .expect("target active check should succeed") - ); - assert!( - refresh_service - .is_session_active_for_user( - &user.id, - ¤t.session.session_id, - now + Duration::minutes(2) - ) - .expect("current active check should succeed") - ); - assert_eq!( - store - .find_by_user_id(&user.id) - .expect("user lookup should succeed") - .expect("user should exist") - .user - .token_version, - user.token_version - ); - - let refresh_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: first_token_hash, - next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"), - }, - now + Duration::minutes(2), - ) - .expect_err("revoked target should not rotate"); - assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); - } - - #[tokio::test] - async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() { - let store = build_store(); - let user = create_phone_login_user(store.clone(), "13800138029").await; - let refresh_service = build_refresh_service(store.clone()); - let user_service = build_user_service(store); - let now = OffsetDateTime::now_utc(); - let refresh_token_hash = hash_refresh_session_token("logout-sid-token"); - let session = refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: refresh_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("session should create"); - - let result = user_service - .logout_current_session( - LogoutCurrentSessionInput { - user_id: user.id.clone(), - refresh_token_hash: None, - session_id: Some(session.session.session_id.clone()), - }, - now + Duration::minutes(1), - ) - .expect("logout should succeed"); - - assert_eq!(result.user.token_version, user.token_version); - assert!( - !refresh_service - .is_session_active_for_user( - &user.id, - &session.session.session_id, - now + Duration::minutes(2) - ) - .expect("session active check should succeed") - ); - - let refresh_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash, - next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"), - }, - now + Duration::minutes(2), - ) - .expect_err("sid-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(); - let user = create_phone_login_user(store.clone(), "13800138006").await; - let refresh_service = build_refresh_service(store.clone()); - let user_service = build_user_service(store); - let first_refresh_token_hash = hash_refresh_session_token("logout-all-token-01"); - let second_refresh_token_hash = hash_refresh_session_token("logout-all-token-02"); - let now = OffsetDateTime::now_utc(); - - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: first_refresh_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("first session should create"); - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: second_refresh_token_hash.clone(), - issued_by_provider: AuthLoginMethod::Password, - client_info: RefreshSessionClientInfo { - client_runtime: "firefox".to_string(), - device_display_name: "Windows / Firefox".to_string(), - ..build_client_info() - }, - }, - now + Duration::seconds(1), - ) - .expect("second session should create"); - - let result = user_service - .logout_all_sessions( - LogoutAllSessionsInput { - user_id: user.id.clone(), - }, - now + Duration::minutes(1), - ) - .expect("logout all should succeed"); - - assert_eq!(result.user.token_version, 2); - assert_eq!( - refresh_service - .list_active_sessions_by_user(&user.id, now + Duration::minutes(2)) - .expect("sessions should list") - .sessions - .len(), - 0 - ); - - let first_refresh_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: first_refresh_token_hash, - next_refresh_token_hash: hash_refresh_session_token("logout-all-token-03"), - }, - now + Duration::minutes(2), - ) - .expect_err("first revoked session should fail"); - assert_eq!(first_refresh_error, RefreshSessionError::SessionNotFound); - - let second_refresh_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: second_refresh_token_hash, - next_refresh_token_hash: hash_refresh_session_token("logout-all-token-04"), - }, - now + Duration::minutes(2), - ) - .expect_err("second revoked session should fail"); - assert_eq!(second_refresh_error, RefreshSessionError::SessionNotFound); - } - - #[tokio::test] - async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() { - let store = build_store(); - let refresh_service = build_refresh_service(store.clone()); - let user = create_phone_login_user(store.clone(), "13800138007").await; - let now = OffsetDateTime::now_utc(); - - let active_session = refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("sessions-active"), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("active session should create"); - - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("sessions-revoked"), - issued_by_provider: AuthLoginMethod::Password, - client_info: RefreshSessionClientInfo { - client_runtime: "edge".to_string(), - device_display_name: "Windows / Edge".to_string(), - ..build_client_info() - }, - }, - now - Duration::minutes(5), - ) - .expect("revoked session should create"); - store - .revoke_session_by_refresh_token_hash( - &hash_refresh_session_token("sessions-revoked"), - now - Duration::minutes(1), - ) - .expect("revoked session should revoke"); - - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("sessions-expired"), - issued_by_provider: AuthLoginMethod::Password, - client_info: RefreshSessionClientInfo { - client_runtime: "firefox".to_string(), - device_display_name: "Windows / Firefox".to_string(), - ..build_client_info() - }, - }, - now - Duration::days(40), - ) - .expect("expired session should create"); - - let listed = refresh_service - .list_active_sessions_by_user(&user.id, now) - .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].client_info.client_runtime, "chrome"); - assert_eq!( - listed.sessions[0].client_info.device_display_name, - "Windows / Chrome" - ); - } - - #[tokio::test] - async fn stale_refresh_sessions_are_pruned_from_both_indexes() { - let store = build_store(); - let refresh_service = build_refresh_service(store.clone()); - let user = create_phone_login_user(store.clone(), "13800138008").await; - let now = OffsetDateTime::now_utc(); - - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("stale-revoked"), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now - Duration::days(2), - ) - .expect("stale session should create"); - store - .revoke_session_by_refresh_token_hash( - &hash_refresh_session_token("stale-revoked"), - now - Duration::days(2), - ) - .expect("stale session should revoke"); - - refresh_service - .create_session( - CreateRefreshSessionInput { - user_id: user.id.clone(), - refresh_token_hash: hash_refresh_session_token("recent-revoked"), - issued_by_provider: AuthLoginMethod::Password, - client_info: build_client_info(), - }, - now, - ) - .expect("recent session should create"); - store - .revoke_session_by_refresh_token_hash( - &hash_refresh_session_token("recent-revoked"), - now, - ) - .expect("recent session should revoke"); - - let projection = store - .export_projection_view(now.unix_timestamp()) - .expect("projection export should prune stale sessions"); - assert_eq!(projection.refresh_sessions.len(), 1); - assert_eq!( - projection.refresh_sessions[0].refresh_token_hash, - hash_refresh_session_token("recent-revoked") - ); - - let stale_error = refresh_service - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash: hash_refresh_session_token("stale-revoked"), - next_refresh_token_hash: hash_refresh_session_token("stale-next"), - }, - now, - ) - .expect_err("pruned session should no longer be indexed"); - assert_eq!(stale_error, RefreshSessionError::SessionNotFound); - } - - #[test] - fn projection_restore_rejects_too_many_retained_refresh_sessions() { - let client_info_json = - serde_json::to_string(&build_client_info()).expect("client info should serialize"); - let refresh_sessions = (0..=MAX_REFRESH_SESSIONS) - .map(|index| AuthStoreProjectionRefreshSession { - session_id: format!("session-{index}"), - user_id: "user_projection_cap".to_string(), - refresh_token_hash: format!("hash-{index}"), - issued_by_provider: "password".to_string(), - client_info_json: client_info_json.clone(), - expires_at: "2999-01-01T00:00:00Z".to_string(), - revoked_at: None, - created_at: "2026-01-01T00:00:00Z".to_string(), - updated_at: "2026-01-01T00:00:00Z".to_string(), - last_seen_at: "2026-01-01T00:00:00Z".to_string(), - }) - .collect(); - - let error = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { - base_updated_at_micros: 0, - updated_at_micros: 1, - users: vec![projection_user( - "user_projection_cap", - "projection_cap", - None, - )], - identities: vec![], - refresh_sessions, - phone_codes: vec![], - wechat_states: vec![], - }) - .expect_err("projection restore must enforce the refresh session cap"); - - assert!(error.contains("refresh session")); - assert!(error.contains(&MAX_REFRESH_SESSIONS.to_string())); - } - - #[tokio::test] - async fn wechat_login_hits_existing_user_by_union_id_before_openid() { - let store = build_store(); - let phone_service = build_phone_service(store.clone()); - let wechat_service = WechatAuthService::new(store); - let now = OffsetDateTime::now_utc(); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone code should send"); - let phone_user = phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_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, - session_key: None, - }, - }) - .await - .expect("first wechat login should succeed"); - - assert!(first_wechat.created); - assert_eq!( - first_wechat.user.binding_status, - AuthBindingStatus::PendingBindPhone - ); - assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first"); - assert_eq!( - first_wechat.user.wechat_display_name.as_deref(), - Some("微信旅人甲") - ); - assert!(first_wechat.user.id.starts_with("user_")); - assert!(!first_wechat.user.id.ends_with("00000001")); - - 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, - session_key: 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); - assert_eq!(second_wechat.user.username, first_wechat.user.username); - assert_eq!( - second_wechat.user.wechat_display_name.as_deref(), - Some("微信旅人乙") - ); - } - - #[tokio::test] - async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() { - let store = build_store(); - let phone_service = build_phone_service(store.clone()); - let wechat_service = WechatAuthService::new(store.clone()); - let now = OffsetDateTime::now_utc(); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone login code should send"); - let phone_user = phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_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, - session_key: None, - }, - }) - .await - .expect("wechat login should succeed") - .user; - - assert_eq!( - wechat_user.binding_status, - AuthBindingStatus::PendingBindPhone - ); - assert_eq!( - wechat_user.wechat_display_name.as_deref(), - Some("待绑定微信用户") - ); - assert_ne!(wechat_user.id, phone_user.id); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138000".to_string(), - scene: PhoneAuthScene::BindPhone, - }, - now + Duration::seconds(2), - ) - .await - .expect("bind phone code should send"); - let merged = phone_service - .bind_wechat_phone( - BindWechatPhoneInput { - user_id: wechat_user.id.clone(), - country_code: None, - pure_phone_number: "13800138000".to_string(), - verify_code: "123456".to_string(), - wechat_display_name: None, - }, - 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); - assert_eq!( - merged.user.wechat_display_name.as_deref(), - Some("待绑定微信用户") - ); - assert_eq!(merged.user.display_name, "待绑定微信用户"); - - 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, - session_key: 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); - assert_eq!( - reused_wechat_user.user.wechat_display_name.as_deref(), - Some("已归并微信用户") - ); - assert_eq!(reused_wechat_user.user.display_name, "已归并微信用户"); - } - - #[tokio::test] - async fn bind_wechat_verified_phone_rejects_foreign_country_before_account_merge() { - let store = build_store(); - let phone_service = build_phone_service(store.clone()); - let wechat_user = WechatAuthService::new(store.clone()) - .resolve_login(ResolveWechatLoginInput { - profile: WechatIdentityProfile { - provider_uid: "wx-openid-foreign-phone".to_string(), - provider_union_id: None, - display_name: Some("境外手机号用户".to_string()), - avatar_url: None, - session_key: None, - }, - }) - .await - .expect("wechat login should succeed") - .user; - - let error = phone_service - .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { - user_id: wechat_user.id.clone(), - country_code: "1".to_string(), - pure_phone_number: "12025550123".to_string(), - wechat_display_name: None, - }) - .await - .expect_err("foreign country code must fail before account merge"); - - assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); - let unchanged_user = store - .find_by_user_id(&wechat_user.id) - .expect("user lookup should succeed") - .expect("pending wechat user should remain"); - assert_eq!( - unchanged_user.user.binding_status, - AuthBindingStatus::PendingBindPhone - ); - assert!(unchanged_user.user.phone_number.is_none()); - } - - #[tokio::test] - async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { - let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { - base_updated_at_micros: 0, - updated_at_micros: 1, - users: vec![projection_user( - "user_existing_phone_bind", - "phone_existing_bind", - Some("+8613800138032"), - )], - identities: vec![], - refresh_sessions: vec![], - phone_codes: vec![], - wechat_states: vec![], - }) - .expect("projection should restore"); - let phone_service = build_phone_service(store.clone()); - let wechat_service = WechatAuthService::new(store); - let now = OffsetDateTime::now_utc(); - let wechat_user = wechat_service - .resolve_login(ResolveWechatLoginInput { - profile: WechatIdentityProfile { - provider_uid: "wx-openid-existing-phone-bind".to_string(), - provider_union_id: Some("wx-union-existing-phone-bind".to_string()), - display_name: Some("待绑定微信用户".to_string()), - avatar_url: None, - session_key: None, - }, - }) - .await - .expect("wechat login should succeed") - .user; - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138032".to_string(), - scene: PhoneAuthScene::BindPhone, - }, - now, - ) - .await - .expect("bind phone code should send"); - let merged = phone_service - .bind_wechat_phone( - BindWechatPhoneInput { - user_id: wechat_user.id, - country_code: None, - pure_phone_number: "13800138032".to_string(), - verify_code: "123456".to_string(), - wechat_display_name: None, - }, - now + Duration::seconds(1), - ) - .await - .expect("bind phone should merge existing phone user"); - - assert_eq!(merged.user.id, "user_existing_phone_bind"); - assert!(merged.user.wechat_bound); - assert_eq!( - wechat_service - .resolve_login(ResolveWechatLoginInput { - profile: WechatIdentityProfile { - provider_uid: "wx-openid-existing-phone-bind".to_string(), - provider_union_id: Some("wx-union-existing-phone-bind".to_string()), - display_name: Some("已归并微信用户".to_string()), - avatar_url: None, - session_key: None, - }, - }) - .await - .expect("wechat login should reuse merged user") - .user - .id, - "user_existing_phone_bind" - ); - } - - #[tokio::test] - async fn bind_wechat_phone_uses_submitted_display_name_when_identity_has_no_display_name() { - let store = build_store(); - let phone_service = build_phone_service(store.clone()); - let wechat_service = WechatAuthService::new(store.clone()); - let now = OffsetDateTime::now_utc(); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138031".to_string(), - scene: PhoneAuthScene::Login, - }, - now, - ) - .await - .expect("phone login code should send"); - let phone_user = phone_service - .login( - PhoneLoginInput { - country_code: None, - pure_phone_number: "13800138031".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-mini-bind".to_string(), - provider_union_id: Some("wx-union-mini-bind".to_string()), - display_name: None, - avatar_url: None, - session_key: Some("mini-session-key".to_string()), - }, - }) - .await - .expect("mini program wechat login should succeed") - .user; - - assert_eq!(wechat_user.wechat_display_name, None); - assert_eq!( - wechat_user.wechat_account.as_deref(), - Some("wx-openid-mini-bind") - ); - assert_ne!(wechat_user.id, phone_user.id); - - phone_service - .send_code( - SendPhoneCodeInput { - country_code: None, - pure_phone_number: "13800138031".to_string(), - scene: PhoneAuthScene::BindPhone, - }, - now + Duration::seconds(2), - ) - .await - .expect("bind phone code should send"); - let merged = phone_service - .bind_wechat_phone( - BindWechatPhoneInput { - user_id: wechat_user.id.clone(), - country_code: None, - pure_phone_number: "13800138031".to_string(), - verify_code: "123456".to_string(), - wechat_display_name: Some("补填微信昵称".to_string()), - }, - now + Duration::seconds(3), - ) - .await - .expect("bind phone should succeed"); - - assert_eq!(merged.user.id, phone_user.id); - assert!(merged.user.wechat_bound); - assert_eq!(merged.user.display_name, "补填微信昵称"); - assert_eq!( - merged.user.wechat_display_name.as_deref(), - Some("补填微信昵称") - ); - assert_eq!( - merged.user.wechat_account.as_deref(), - Some("wx-openid-mini-bind") - ); - - let restored_user = build_password_service(store) - .get_user_by_id(&phone_user.id) - .expect("user lookup should succeed") - .expect("merged user should exist") - .user; - assert_eq!(restored_user.display_name, "补填微信昵称"); - assert_eq!( - restored_user.wechat_display_name.as_deref(), - Some("补填微信昵称") - ); - assert_eq!( - restored_user.wechat_account.as_deref(), - Some("wx-openid-mini-bind") - ); - } -} +#[cfg(feature = "services")] +mod services; +#[cfg(feature = "services")] +pub use services::*; diff --git a/server-rs/crates/module-auth/src/services.rs b/server-rs/crates/module-auth/src/services.rs new file mode 100644 index 000000000..6b67035b5 --- /dev/null +++ b/server-rs/crates/module-auth/src/services.rs @@ -0,0 +1,5088 @@ +use crate::*; + +use std::{ + collections::{HashMap, HashSet}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +use platform_auth::{ + SmsAuthProvider, SmsAuthProviderKind, SmsProviderError, SmsSendCodeRequest, hash_password, + verify_password, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use shared_kernel::{ + build_prefixed_uuid_id, format_rfc3339 as format_shared_rfc3339, new_uuid_simple_string, + normalize_optional_string, normalize_required_string, parse_rfc3339, +}; +use time::{Duration, OffsetDateTime}; +use tracing::{info, warn}; + +const DEFAULT_PHONE_VERIFY_CODE_SALT: &str = "genarrative-phone-verify-code-v1"; +const PHONE_CODE_RESERVATION_MARKER: &str = "__genarrative_phone_code_reservation__"; +const MAX_ACTIVE_WECHAT_AUTH_STATES: usize = 1024; +const REFRESH_SESSION_STALE_RETENTION: Duration = Duration::days(1); +const MAX_REFRESH_SESSIONS: usize = 8_192; +const MAX_PHONE_CODES: usize = 4_096; + +#[derive(Clone, Debug)] +pub struct InMemoryAuthStore { + inner: Arc>, + revision: Arc, +} + +#[derive(Debug)] +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, Serialize, Deserialize)] +struct StoredPasswordUser { + user: AuthUser, + password_hash: String, + #[serde(default)] + password_login_enabled: bool, + phone_number: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct StoredRefreshSession { + session: RefreshSessionRecord, +} + +#[derive(Clone, Debug)] +struct StoredPhoneCode { + phone_number: String, + scene: PhoneAuthScene, + verify_code_hash: String, + expires_at: String, + last_sent_at: String, + failed_attempts: u32, + provider_out_id: Option, +} + +#[derive(Clone, Debug)] +struct StoredWechatAuthState { + state: WechatAuthStateRecord, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct StoredWechatIdentity { + user_id: String, + provider_uid: String, + provider_union_id: Option, + display_name: Option, + avatar_url: Option, + session_key: Option, +} + +fn hydrate_private_auth_fields( + state: &InMemoryAuthStoreState, + stored_user: &StoredPasswordUser, +) -> StoredPasswordUser { + let mut hydrated = stored_user.clone(); + if hydrated.user.phone_number.is_none() { + hydrated.user.phone_number = hydrated.phone_number.clone(); + } + let hydrated_wechat_identity = state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == hydrated.user.id); + if hydrated.user.wechat_display_name.is_none() { + hydrated.user.wechat_display_name = hydrated_wechat_identity + .and_then(|identity| normalize_optional_string(identity.display_name.clone())); + } + if hydrated.user.wechat_account.is_none() { + hydrated.user.wechat_account = + hydrated_wechat_identity.map(|identity| identity.provider_uid.clone()); + } + hydrated +} + +fn stored_user_phone_number(stored_user: &StoredPasswordUser) -> Option<&str> { + stored_user + .phone_number + .as_deref() + .or(stored_user.user.phone_number.as_deref()) +} + +fn parse_auth_login_method(value: &str) -> AuthLoginMethod { + match value { + "password" | "Password" => AuthLoginMethod::Password, + "wechat" | "Wechat" => AuthLoginMethod::Wechat, + _ => AuthLoginMethod::Phone, + } +} + +fn parse_auth_binding_status(value: &str) -> AuthBindingStatus { + match value { + "pending_bind_phone" | "PendingBindPhone" => AuthBindingStatus::PendingBindPhone, + _ => AuthBindingStatus::Active, + } +} + +fn parse_phone_auth_scene(value: &str) -> Option { + match value.trim() { + "login" => Some(PhoneAuthScene::Login), + "bind_phone" => Some(PhoneAuthScene::BindPhone), + "change_phone" => Some(PhoneAuthScene::ChangePhone), + "reset_password" => Some(PhoneAuthScene::ResetPassword), + _ => None, + } +} + +fn parse_wechat_auth_scene(value: &str) -> Option { + match value.trim() { + "desktop" => Some(WechatAuthScene::Desktop), + "wechat_in_app" => Some(WechatAuthScene::WechatInApp), + _ => None, + } +} + +fn next_sequence_from_public_user_code(public_user_code: &str) -> u64 { + public_user_code + .trim() + .strip_prefix("SY-") + .and_then(|value| value.parse::().ok()) + .map(|sequence| sequence.saturating_add(1)) + .unwrap_or(1) +} + +fn auth_identity_id(provider: &str, provider_uid: &str) -> String { + format!( + "authi_{}_{}", + sanitize_identity_component(provider), + sanitize_identity_component(provider_uid) + ) +} + +fn sanitize_identity_component(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string() +} + +#[derive(Clone, Debug)] +pub struct PasswordEntryService { + store: InMemoryAuthStore, +} + +#[derive(Clone, Debug)] +pub struct RefreshSessionService { + store: InMemoryAuthStore, + refresh_session_ttl_days: u32, +} + +#[derive(Clone, Debug)] +pub struct AuthUserService { + store: InMemoryAuthStore, +} + +#[derive(Clone, Debug)] +pub struct PhoneAuthService { + store: InMemoryAuthStore, + sms_provider: SmsAuthProvider, + verify_code_salt: String, +} + +#[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 } + } + + pub async fn execute( + &self, + input: PasswordEntryInput, + ) -> Result { + validate_password(&input.password)?; + validate_mainland_china_country_code(input.country_code.as_deref()) + .map_err(map_phone_number_error_to_password_error)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) + .map_err(map_phone_number_error_to_password_error)?; + let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + else { + return Err(PasswordEntryError::InvalidCredentials); + }; + + verify_stored_password_user(existing_user, &input.password).await + } + + pub async fn execute_with_dev_registration( + &self, + input: PasswordEntryInput, + ) -> Result { + validate_password(&input.password)?; + validate_mainland_china_country_code(input.country_code.as_deref()) + .map_err(map_phone_number_error_to_password_error)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) + .map_err(map_phone_number_error_to_password_error)?; + if let Some(existing_user) = self + .store + .find_by_phone_number_for_password(&normalized_phone.e164)? + { + return verify_stored_password_user(existing_user, &input.password).await; + } + + let password_hash = hash_password(&input.password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self.store.create_dev_password_phone_user( + normalized_phone.clone(), + normalized_phone.masked_national_number, + password_hash, + )?; + + Ok(PasswordEntryResult { + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..user + }, + created: true, + }) + } + + pub fn get_user_by_id( + &self, + user_id: &str, + ) -> Result, PasswordEntryError> { + self.store + .find_by_user_id(user_id) + .map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user })) + } + + pub fn get_user_by_public_user_code( + &self, + public_user_code: &str, + ) -> Result, PasswordEntryError> { + let normalized_public_user_code = normalize_public_user_code(public_user_code)?; + self.store + .find_by_public_user_code(&normalized_public_user_code) + .map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user })) + } + + pub fn update_profile( + &self, + input: UpdateProfileInput, + ) -> Result { + let display_name = match input.display_name { + Some(value) => Some(normalize_profile_display_name(value.as_str())?), + None => None, + }; + let avatar_url = match input.avatar_url { + Some(value) => Some(normalize_profile_avatar_url(value.as_str())?), + None => None, + }; + if display_name.is_none() && avatar_url.is_none() { + return Err(PasswordEntryError::EmptyProfileUpdate); + } + + let user = self + .store + .update_user_profile(&input.user_id, display_name, avatar_url)? + .ok_or(PasswordEntryError::UserNotFound)?; + + Ok(UpdateProfileResult { user }) + } + + pub async fn change_password( + &self, + input: ChangePasswordInput, + ) -> Result { + self.change_password_internal(input, None).await + } + + pub async fn change_password_and_revoke_all_sessions( + &self, + input: ChangePasswordInput, + now: OffsetDateTime, + ) -> Result { + self.change_password_internal(input, Some(now)).await + } + + async fn change_password_internal( + &self, + input: ChangePasswordInput, + revoke_all_sessions_at: Option, + ) -> Result { + validate_password(&input.new_password)?; + let stored_user = self + .store + .find_by_user_id(&input.user_id)? + .ok_or(PasswordEntryError::UserNotFound)?; + + if stored_user.password_login_enabled { + let current_password = input + .current_password + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(PasswordEntryError::InvalidCredentials)?; + let is_valid = verify_password(&stored_user.password_hash, current_password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + if !is_valid { + return Err(PasswordEntryError::InvalidCredentials); + } + } + + let password_hash = hash_password(&input.new_password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + let user = self + .store + .set_user_password_hash(&input.user_id, password_hash, revoke_all_sessions_at)? + .ok_or(PasswordEntryError::UserNotFound)?; + + Ok(ChangePasswordResult { user }) + } +} + +impl RefreshSessionService { + pub fn new(store: InMemoryAuthStore, refresh_session_ttl_days: u32) -> Self { + Self { + store, + refresh_session_ttl_days, + } + } + + pub fn create_session( + &self, + input: CreateRefreshSessionInput, + now: OffsetDateTime, + ) -> Result { + self.store.prune_stale_sessions(now)?; + self.store + .find_by_user_id(&input.user_id) + .map_err(map_password_store_error)? + .ok_or(RefreshSessionError::UserNotFound)?; + + let session_id = build_prefixed_uuid_id("usess_"); + let expires_at = now + .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) + .ok_or_else(|| { + RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) + })?; + let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?; + let expires_at_iso = format_rfc3339_with_context(expires_at, "refresh session 过期时间")?; + let session = RefreshSessionRecord { + session_id, + user_id: input.user_id, + refresh_token_hash: input.refresh_token_hash, + issued_by_provider: input.issued_by_provider, + client_info: input.client_info, + expires_at: expires_at_iso, + revoked_at: None, + created_at: now_iso.clone(), + updated_at: now_iso.clone(), + last_seen_at: now_iso, + }; + + self.store.insert_session(session.clone())?; + + Ok(CreateRefreshSessionResult { session }) + } + + pub fn rotate_session( + &self, + input: RotateRefreshSessionInput, + now: OffsetDateTime, + ) -> Result { + self.store.prune_stale_sessions(now)?; + let Some(refresh_token_hash) = normalize_required_string(&input.refresh_token_hash) else { + return Err(RefreshSessionError::MissingToken); + }; + + let session = self + .store + .find_session_by_refresh_token_hash(&refresh_token_hash)? + .ok_or(RefreshSessionError::SessionNotFound)?; + + if session.session.revoked_at.is_some() { + return Err(RefreshSessionError::SessionNotFound); + } + + let expires_at = + parse_rfc3339_with_context(&session.session.expires_at, "refresh session 过期时间")?; + if expires_at <= now { + return Err(RefreshSessionError::SessionExpired); + } + + let user = self + .store + .find_by_user_id(&session.session.user_id) + .map_err(map_password_store_error)? + .ok_or(RefreshSessionError::UserNotFound)?; + + let next_expires_at = now + .checked_add(Duration::days(i64::from(self.refresh_session_ttl_days))) + .ok_or_else(|| { + RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()) + })?; + let now_iso = format_rfc3339_with_context(now, "refresh session 时间")?; + let next_expires_at_iso = + format_rfc3339_with_context(next_expires_at, "refresh session 过期时间")?; + + let updated_session = self.store.rotate_session( + &session.session.session_id, + &session.session.refresh_token_hash, + input.next_refresh_token_hash, + next_expires_at_iso, + now_iso.clone(), + now_iso, + )?; + + Ok(RotateRefreshSessionResult { + session: updated_session.session, + user: user.user, + }) + } + + pub fn list_active_sessions_by_user( + &self, + user_id: &str, + now: OffsetDateTime, + ) -> Result { + self.store.prune_stale_sessions(now)?; + self.store + .find_by_user_id(user_id) + .map_err(map_password_store_error)? + .ok_or(RefreshSessionError::UserNotFound)?; + + let sessions = self.store.list_active_sessions_by_user(user_id, now)?; + Ok(ListActiveRefreshSessionsResult { sessions }) + } + + pub fn revoke_session_by_user_and_session( + &self, + input: RevokeRefreshSessionByUserInput, + now: OffsetDateTime, + ) -> Result { + self.store.prune_stale_sessions(now)?; + self.store + .find_by_user_id(&input.user_id) + .map_err(map_password_store_error)? + .ok_or(RefreshSessionError::UserNotFound)?; + + let Some(session_id) = normalize_required_string(&input.session_id) else { + return Err(RefreshSessionError::SessionNotFound); + }; + let revoked = + self.store + .revoke_session_by_user_and_session_id(&input.user_id, &session_id, now)?; + + Ok(RevokeRefreshSessionResult { + session_id, + revoked, + }) + } + + pub fn is_session_active_for_user( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + self.store.prune_stale_sessions(now)?; + self.store + .is_session_active_for_user(user_id, session_id.trim(), now) + } +} + +impl PhoneAuthService { + pub fn new(store: InMemoryAuthStore, sms_provider: SmsAuthProvider) -> Self { + Self::new_with_verify_code_salt(store, sms_provider, DEFAULT_PHONE_VERIFY_CODE_SALT) + } + + /// 使用部署级稳定盐值构造服务,确保验证码投影恢复到另一节点后仍可校验。 + pub fn new_with_verify_code_salt( + store: InMemoryAuthStore, + sms_provider: SmsAuthProvider, + verify_code_salt: impl Into, + ) -> Self { + Self { + store, + sms_provider, + verify_code_salt: verify_code_salt.into(), + } + } + + pub async fn send_code( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result { + self.send_code_inner(input, now, true).await + } + + /// 在 provider 调用前由 api-server 先同步该占位记录,以便 SpacetimeDB 的 + /// projection CAS 原子占用跨节点冷却窗口。 + pub fn reserve_code_send( + &self, + input: &SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result<(), PhoneAuthError> { + self.store.prune_expired_phone_codes(now)?; + let scene = input.scene.clone(); + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + self.store + .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + 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}")) + })?; + let last_sent_at = format_rfc3339(now).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) + })?; + let verify_code_hash = hash_phone_verify_code( + &self.verify_code_salt, + &normalized_phone.e164, + &scene, + PHONE_CODE_RESERVATION_MARKER, + ); + self.store.upsert_phone_code( + StoredPhoneCode { + phone_number: normalized_phone.e164, + scene, + verify_code_hash, + expires_at, + last_sent_at, + failed_attempts: 0, + provider_out_id: None, + }, + now, + ) + } + + /// 仅供完成权威占用后的 provider 调用使用;占用已由 projection CAS 校验。 + pub async fn send_code_after_authoritative_reservation( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + ) -> Result { + self.send_code_inner(input, now, false).await + } + + async fn send_code_inner( + &self, + input: SendPhoneCodeInput, + now: OffsetDateTime, + check_local_cooldown: bool, + ) -> Result { + self.store.prune_expired_phone_codes(now)?; + let scene = input.scene.clone(); + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; + let verify_code = self.generate_phone_verify_code(); + info!( + scene = scene.as_str(), + provider = self.sms_provider.kind().as_str(), + phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(), + phone_national_masked = normalized_phone.masked_national_number.as_str(), + "手机号验证码发送准备调用 provider" + ); + if check_local_cooldown { + self.store + .ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?; + } + self.store + .ensure_phone_code_capacity(&normalized_phone.e164, &scene)?; + 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}")) + })?; + let verify_code_hash = hash_phone_verify_code( + &self.verify_code_salt, + &normalized_phone.e164, + &scene, + &verify_code, + ); + + let provider_result = self + .sms_provider + .send_code(SmsSendCodeRequest { + national_phone_number, + scene: input.scene.as_str().to_string(), + verify_code, + }) + .await + .map_err(map_sms_provider_error_to_phone_error)?; + info!( + scene = scene.as_str(), + provider = self.sms_provider.kind().as_str(), + phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(), + phone_national_masked = normalized_phone.masked_national_number.as_str(), + cooldown_seconds = provider_result.cooldown_seconds, + expires_in_seconds = provider_result.expires_in_seconds, + provider_request_id = provider_result + .provider_request_id + .as_deref() + .unwrap_or("unknown"), + provider_out_id = provider_result + .provider_out_id + .as_deref() + .unwrap_or("unknown"), + "手机号验证码 provider 调用成功,准备写入本地快照" + ); + + self.store.upsert_phone_code( + StoredPhoneCode { + phone_number: normalized_phone.e164.clone(), + scene, + verify_code_hash, + expires_at, + last_sent_at: format_rfc3339(now).map_err(|message| { + PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}")) + })?, + failed_attempts: 0, + provider_out_id: provider_result.provider_out_id.clone(), + }, + now, + )?; + + Ok(SendPhoneCodeResult { + cooldown_seconds: provider_result.cooldown_seconds, + expires_in_seconds: provider_result.expires_in_seconds, + provider_request_id: provider_result.provider_request_id, + provider_out_id: provider_result.provider_out_id, + provider: self.sms_provider.kind().as_str().to_string(), + scene: input.scene.as_str().to_string(), + phone_number_masked: normalized_phone.masked_national_number, + }) + } + + pub async fn login( + &self, + input: PhoneLoginInput, + now: OffsetDateTime, + ) -> Result { + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + verify_sms_code_format(&input.verify_code)?; + let provider_out_id = self.verify_phone_code( + &normalized_phone.e164, + &PhoneAuthScene::Login, + &input.verify_code, + 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, + provider: self.sms_provider.kind().as_str().to_string(), + provider_out_id, + phone_number_masked: normalized_phone.masked_national_number, + }); + } + + 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, + provider: self.sms_provider.kind().as_str().to_string(), + provider_out_id, + phone_number_masked: normalized_phone.masked_national_number, + }) + } + + pub async fn reset_password( + &self, + input: ResetPasswordInput, + now: OffsetDateTime, + ) -> Result { + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + verify_sms_code_format(&input.verify_code)?; + validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; + let provider_out_id = self.verify_phone_code( + &normalized_phone.e164, + &PhoneAuthScene::ResetPassword, + &input.verify_code, + now, + )?; + + self.store + .find_by_phone_number(&normalized_phone.e164)? + .ok_or(PhoneAuthError::UserNotFound)?; + let password_hash = hash_password(&input.new_password) + .await + .map_err(|error| PhoneAuthError::PasswordHash(error.to_string()))?; + let user = self + .store + .set_user_password_by_phone_number(&normalized_phone.e164, password_hash)?; + + Ok(ResetPasswordResult { + user, + provider: self.sms_provider.kind().as_str().to_string(), + provider_out_id, + phone_number_masked: normalized_phone.masked_national_number, + }) + } + + pub async fn bind_wechat_phone( + &self, + input: BindWechatPhoneInput, + now: OffsetDateTime, + ) -> Result { + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + verify_sms_code_format(&input.verify_code)?; + self.verify_phone_code( + &normalized_phone.e164, + &PhoneAuthScene::BindPhone, + &input.verify_code, + 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, activated_new_user) = self.store.bind_wechat_phone_to_user( + &input.user_id, + normalized_phone, + input.wechat_display_name, + )?; + + Ok(BindWechatPhoneResult { + user: merged_user, + activated_new_user, + }) + } + + fn verify_phone_code( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, + now: OffsetDateTime, + ) -> Result, PhoneAuthError> { + let stored = self.store.get_active_phone_code(phone_number, scene, now)?; + let expected_hash = + hash_phone_verify_code(&self.verify_code_salt, phone_number, scene, verify_code); + if stored.verify_code_hash != expected_hash { + self.store.consume_phone_code_failure(phone_number, scene)?; + return Err(PhoneAuthError::InvalidVerifyCode); + } + self.store.consume_phone_code_success(phone_number, scene)?; + Ok(stored.provider_out_id) + } + + fn generate_phone_verify_code(&self) -> String { + match self.sms_provider.kind() { + SmsAuthProviderKind::Mock => self + .sms_provider + .mock_verify_code() + .map(str::to_string) + .unwrap_or_else(|| "123456".to_string()), + SmsAuthProviderKind::Aliyun => generate_random_phone_verify_code(), + } + } + + pub async fn bind_wechat_verified_phone( + &self, + input: BindWechatVerifiedPhoneInput, + ) -> Result { + validate_mainland_china_country_code(Some(&input.country_code))?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; + 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, activated_new_user) = self.store.bind_wechat_phone_to_user( + &input.user_id, + normalized_phone, + input.wechat_display_name, + )?; + + Ok(BindWechatPhoneResult { + user: merged_user, + activated_new_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 { + self.store.prune_wechat_states(now)?; + 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: build_prefixed_uuid_id("wxstate_"), + state_token: create_wechat_state_token(), + redirect_path: normalize_required_string(&input.redirect_path).unwrap_or_default(), + scene: input.scene, + request_user_agent: normalize_optional_string(input.request_user_agent), + bind_user_id: normalize_optional_string(input.bind_user_id), + expires_at, + consumed_at: None, + created_at: created_at.clone(), + updated_at: created_at, + }; + self.store.insert_wechat_state(state.clone(), now)?; + 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, + }) + } + + pub fn bind_identity_to_user( + &self, + input: BindWechatIdentityInput, + ) -> Result { + if input.profile.provider_uid.trim().is_empty() { + return Err(WechatAuthError::MissingProfile); + } + self.store + .bind_wechat_identity_to_user(&input.user_id, input.profile) + } + + pub fn get_identity_by_user_id( + &self, + user_id: &str, + ) -> Result, WechatAuthError> { + self.store.get_wechat_identity_by_user_id(user_id) + } +} + +impl AuthUserService { + pub fn new(store: InMemoryAuthStore) -> Self { + Self { store } + } + + pub fn ensure_orphan_work_owner_user( + &self, + user_id: &str, + username: &str, + display_name: &str, + public_user_code: &str, + ) -> Result { + self.store + .ensure_orphan_work_owner_user(user_id, username, display_name, public_user_code) + } + + pub fn get_user_by_id(&self, user_id: &str) -> Result, LogoutError> { + self.store + .find_by_user_id(user_id) + .map(|maybe_user| maybe_user.map(|stored| stored.user)) + .map_err(map_password_error_to_logout_error) + } + + pub fn get_user_by_public_user_code( + &self, + public_user_code: &str, + ) -> Result, LogoutError> { + let normalized_public_user_code = normalize_public_user_code(public_user_code) + .map_err(map_password_error_to_logout_error)?; + self.store + .find_by_public_user_code(&normalized_public_user_code) + .map(|maybe_user| maybe_user.map(|stored| stored.user)) + .map_err(map_password_error_to_logout_error) + } + + pub fn logout_current_session( + &self, + input: LogoutCurrentSessionInput, + now: OffsetDateTime, + ) -> Result { + let user = self + .store + .find_by_user_id(&input.user_id) + .map_err(map_password_error_to_logout_error)? + .ok_or(LogoutError::UserNotFound)? + .user; + let revoked_by_hash = if let Some(refresh_token_hash) = input + .refresh_token_hash + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + self.store + .revoke_session_by_refresh_token_hash(refresh_token_hash, now) + .map_err(map_refresh_error_to_logout_error)?; + true + } else { + false + }; + + if !revoked_by_hash + && let Some(session_id) = input + .session_id + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + self.store + .revoke_session_by_user_and_session_id(&input.user_id, session_id, now) + .map_err(map_refresh_error_to_logout_error)?; + } + + Ok(LogoutCurrentSessionResult { user }) + } + + // 全端登出需要先吊销该用户全部 refresh session,再统一提升 token_version, + // 让所有旧 access token 在下一次鉴权时立即失效。 + pub fn logout_all_sessions( + &self, + input: LogoutAllSessionsInput, + now: OffsetDateTime, + ) -> Result { + self.store + .revoke_all_sessions_by_user_id(&input.user_id, now) + .map_err(map_refresh_error_to_logout_error)?; + + let user = self + .store + .increment_user_token_version(&input.user_id) + .map_err(map_password_error_to_logout_error)? + .ok_or(LogoutError::UserNotFound)?; + + Ok(LogoutAllSessionsResult { user }) + } +} + +impl Default for InMemoryAuthStore { + fn default() -> Self { + Self { + inner: Arc::new(Mutex::new(InMemoryAuthStoreState::default())), + revision: Arc::new(AtomicU64::new(0)), + } + } +} + +impl Default for InMemoryAuthStoreState { + fn default() -> Self { + Self { + 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(), + } + } +} + +impl InMemoryAuthStoreState { + fn from_projection_view(view: AuthStoreProjectionView) -> Result { + let existing_user_ids = view + .users + .iter() + .map(|user| user.user_id.clone()) + .collect::>(); + let mut users_by_username = HashMap::new(); + let mut phone_to_user_id = HashMap::new(); + let mut sessions_by_id = HashMap::new(); + let mut session_id_by_refresh_token_hash = HashMap::new(); + let mut wechat_identity_by_provider_uid = HashMap::new(); + let mut user_id_by_provider_union_id = HashMap::new(); + let mut phone_number_by_user_id = HashMap::new(); + let mut phone_codes_by_key = HashMap::new(); + let mut wechat_states_by_token = HashMap::new(); + + for user in &view.users { + if let Some(phone_number) = normalize_optional_string(user.phone_number_e164.clone()) { + phone_to_user_id.insert(phone_number.clone(), user.user_id.clone()); + phone_number_by_user_id.insert(user.user_id.clone(), phone_number); + } + } + + for identity in view.identities { + if !existing_user_ids.contains(&identity.user_id) { + continue; + } + match identity.provider.as_str() { + "phone" => { + if let Some(phone_number) = + normalize_optional_string(Some(identity.provider_uid.clone())) + { + phone_to_user_id.insert(phone_number.clone(), identity.user_id.clone()); + phone_number_by_user_id.insert(identity.user_id, phone_number); + } + } + "wechat" => { + if let Some(provider_union_id) = identity.provider_union_id.clone() { + user_id_by_provider_union_id + .insert(provider_union_id, identity.user_id.clone()); + } + wechat_identity_by_provider_uid.insert( + identity.provider_uid.clone(), + StoredWechatIdentity { + user_id: identity.user_id, + provider_uid: identity.provider_uid, + provider_union_id: identity.provider_union_id, + display_name: None, + avatar_url: None, + session_key: None, + }, + ); + } + _ => {} + } + } + + let now = OffsetDateTime::now_utc(); + let mut retained_refresh_session_count = 0_usize; + for session in view.refresh_sessions { + if !existing_user_ids.contains(&session.user_id) { + continue; + } + if should_prune_refresh_session_fields( + &session.expires_at, + session.revoked_at.as_deref(), + now, + ) { + continue; + } + retained_refresh_session_count += 1; + if retained_refresh_session_count > MAX_REFRESH_SESSIONS { + return Err(format!( + "认证投影中的 refresh session 数量超过内存上限(最多 {MAX_REFRESH_SESSIONS} 条)" + )); + } + let client_info = + serde_json::from_str::(&session.client_info_json) + .map_err(|error| format!("解析 refresh session 客户端信息失败:{error}"))?; + session_id_by_refresh_token_hash.insert( + session.refresh_token_hash.clone(), + session.session_id.clone(), + ); + sessions_by_id.insert( + session.session_id.clone(), + StoredRefreshSession { + session: RefreshSessionRecord { + session_id: session.session_id, + user_id: session.user_id, + refresh_token_hash: session.refresh_token_hash, + issued_by_provider: parse_auth_login_method(&session.issued_by_provider), + client_info, + expires_at: session.expires_at, + revoked_at: session.revoked_at, + created_at: session.created_at, + updated_at: session.updated_at, + last_seen_at: session.last_seen_at, + }, + }, + ); + } + + for phone_code in view.phone_codes { + let scene = parse_phone_auth_scene(&phone_code.scene) + .ok_or_else(|| format!("未知短信验证码场景:{}", phone_code.scene))?; + let key = build_phone_code_key(&phone_code.phone_number, &scene); + phone_codes_by_key.insert( + key, + StoredPhoneCode { + phone_number: phone_code.phone_number, + scene, + verify_code_hash: phone_code.verify_code_hash, + expires_at: phone_code.expires_at, + last_sent_at: phone_code.last_sent_at, + failed_attempts: phone_code.failed_attempts, + provider_out_id: phone_code.provider_out_id, + }, + ); + } + + for wechat_state in view.wechat_states { + let scene = parse_wechat_auth_scene(&wechat_state.scene) + .ok_or_else(|| format!("未知微信授权 state 场景:{}", wechat_state.scene))?; + wechat_states_by_token.insert( + wechat_state.state_token.clone(), + StoredWechatAuthState { + state: WechatAuthStateRecord { + wechat_state_id: wechat_state.wechat_state_id, + state_token: wechat_state.state_token, + redirect_path: wechat_state.redirect_path, + scene, + request_user_agent: wechat_state.request_user_agent, + bind_user_id: wechat_state.bind_user_id, + expires_at: wechat_state.expires_at, + consumed_at: wechat_state.consumed_at, + created_at: wechat_state.created_at, + updated_at: wechat_state.updated_at, + }, + }, + ); + } + + for user in view.users { + let wechat_identity = wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == user.user_id); + let wechat_account = wechat_identity.map(|identity| identity.provider_uid.clone()); + let phone_number = user + .phone_number_e164 + .clone() + .or_else(|| phone_number_by_user_id.remove(&user.user_id)); + users_by_username.insert( + user.username.clone(), + StoredPasswordUser { + user: AuthUser { + id: user.user_id, + public_user_code: user.public_user_code, + username: user.username, + display_name: user.display_name, + avatar_url: user.avatar_url, + phone_number: phone_number.clone(), + phone_number_masked: user.phone_number_masked, + login_method: parse_auth_login_method(&user.login_method), + binding_status: parse_auth_binding_status(&user.binding_status), + wechat_bound: user.wechat_bound, + wechat_display_name: None, + wechat_account, + token_version: user.token_version, + created_at: user.created_at, + }, + password_hash: user.password_hash, + password_login_enabled: user.password_login_enabled, + phone_number, + }, + ); + } + + let next_user_id = users_by_username + .values() + .map(|stored| next_sequence_from_public_user_code(&stored.user.public_user_code)) + .max() + .unwrap_or(1); + + Ok(Self { + next_user_id, + users_by_username, + phone_to_user_id, + sessions_by_id, + session_id_by_refresh_token_hash, + phone_codes_by_key, + wechat_states_by_token, + wechat_identity_by_provider_uid, + user_id_by_provider_union_id, + }) + } + + fn apply_persistent_state(&mut self, next_state: Self) { + self.next_user_id = next_state.next_user_id; + self.users_by_username = next_state.users_by_username; + self.phone_to_user_id = next_state.phone_to_user_id; + self.sessions_by_id = next_state.sessions_by_id; + self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash; + self.phone_codes_by_key = next_state.phone_codes_by_key; + self.wechat_states_by_token = next_state.wechat_states_by_token; + self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid; + self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id; + } +} + +fn prune_expired_short_lived_state( + state: &mut InMemoryAuthStoreState, + now: OffsetDateTime, +) -> bool { + let phone_code_count = state.phone_codes_by_key.len(); + state.phone_codes_by_key.retain(|_, code| { + parse_rfc3339(&code.expires_at) + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + let wechat_state_count = state.wechat_states_by_token.len(); + state.wechat_states_by_token.retain(|_, stored| { + parse_rfc3339(&stored.state.expires_at) + .map(|expires_at| expires_at > now) + .unwrap_or(true) + }); + phone_code_count != state.phone_codes_by_key.len() + || wechat_state_count != state.wechat_states_by_token.len() +} + +impl InMemoryAuthStore { + pub fn from_projection_view(view: AuthStoreProjectionView) -> Result { + Ok(Self { + inner: Arc::new(Mutex::new(InMemoryAuthStoreState::from_projection_view( + view, + )?)), + revision: Arc::new(AtomicU64::new(0)), + }) + } + + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Acquire) + } + + pub fn refresh_from_projection_view( + &self, + view: AuthStoreProjectionView, + ) -> Result<(), String> { + let next_state = InMemoryAuthStoreState::from_projection_view(view)?; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + state.apply_persistent_state(next_state); + self.revision.fetch_add(1, Ordering::Release); + + Ok(()) + } + + pub fn refresh_from_projection_view_if_revision( + &self, + view: AuthStoreProjectionView, + expected_revision: u64, + ) -> Result { + let next_state = InMemoryAuthStoreState::from_projection_view(view)?; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + if self.revision.load(Ordering::Acquire) != expected_revision { + return Ok(false); + } + state.apply_persistent_state(next_state); + self.revision.fetch_add(1, Ordering::Release); + + Ok(true) + } + + pub fn export_projection_view( + &self, + updated_at_micros: i64, + ) -> Result { + self.prune_stale_sessions(OffsetDateTime::now_utc()) + .map_err(|error| error.to_string())?; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + let pruned = prune_expired_short_lived_state(&mut state, OffsetDateTime::now_utc()); + if pruned { + self.revision.fetch_add(1, Ordering::Release); + } + let users = state + .users_by_username + .values() + .map(|stored| AuthStoreProjectionUser { + user_id: stored.user.id.clone(), + public_user_code: stored.user.public_user_code.clone(), + username: stored.user.username.clone(), + display_name: stored.user.display_name.clone(), + avatar_url: stored.user.avatar_url.clone(), + phone_number_masked: stored.user.phone_number_masked.clone(), + phone_number_e164: stored_user_phone_number(stored).map(str::to_string), + login_method: stored.user.login_method.as_str().to_string(), + binding_status: stored.user.binding_status.as_str().to_string(), + wechat_bound: stored.user.wechat_bound, + password_hash: stored.password_hash.clone(), + password_login_enabled: stored.password_login_enabled, + token_version: stored.user.token_version, + created_at: stored.user.created_at.clone(), + }) + .collect(); + let mut identities = Vec::new(); + for stored in state.users_by_username.values() { + if let Some(phone_number) = stored_user_phone_number(stored) { + identities.push(AuthStoreProjectionIdentity { + identity_id: auth_identity_id("phone", phone_number), + user_id: stored.user.id.clone(), + provider: "phone".to_string(), + provider_uid: phone_number.to_string(), + provider_union_id: None, + }); + } + } + identities.extend( + state + .wechat_identity_by_provider_uid + .values() + .map(|identity| AuthStoreProjectionIdentity { + identity_id: auth_identity_id("wechat", &identity.provider_uid), + user_id: identity.user_id.clone(), + provider: "wechat".to_string(), + provider_uid: identity.provider_uid.clone(), + provider_union_id: identity.provider_union_id.clone(), + }), + ); + let refresh_sessions = state + .sessions_by_id + .values() + .map(|stored| { + let client_info_json = serde_json::to_string(&stored.session.client_info) + .map_err(|error| format!("序列化 refresh session 客户端信息失败:{error}"))?; + Ok(AuthStoreProjectionRefreshSession { + session_id: stored.session.session_id.clone(), + user_id: stored.session.user_id.clone(), + refresh_token_hash: stored.session.refresh_token_hash.clone(), + issued_by_provider: stored.session.issued_by_provider.as_str().to_string(), + client_info_json, + expires_at: stored.session.expires_at.clone(), + revoked_at: stored.session.revoked_at.clone(), + created_at: stored.session.created_at.clone(), + updated_at: stored.session.updated_at.clone(), + last_seen_at: stored.session.last_seen_at.clone(), + }) + }) + .collect::, String>>()?; + let phone_codes = state + .phone_codes_by_key + .values() + .map(|stored| AuthStoreProjectionPhoneCode { + phone_number: stored.phone_number.clone(), + scene: stored.scene.as_str().to_string(), + verify_code_hash: stored.verify_code_hash.clone(), + expires_at: stored.expires_at.clone(), + last_sent_at: stored.last_sent_at.clone(), + failed_attempts: stored.failed_attempts, + provider_out_id: stored.provider_out_id.clone(), + }) + .collect(); + let wechat_states = state + .wechat_states_by_token + .values() + .map(|stored| AuthStoreProjectionWechatState { + wechat_state_id: stored.state.wechat_state_id.clone(), + state_token: stored.state.state_token.clone(), + redirect_path: stored.state.redirect_path.clone(), + scene: stored.state.scene.as_str().to_string(), + request_user_agent: stored.state.request_user_agent.clone(), + bind_user_id: stored.state.bind_user_id.clone(), + expires_at: stored.state.expires_at.clone(), + consumed_at: stored.state.consumed_at.clone(), + created_at: stored.state.created_at.clone(), + updated_at: stored.state.updated_at.clone(), + }) + .collect(); + + Ok(AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros, + users, + identities, + refresh_sessions, + phone_codes, + wechat_states, + }) + } + + pub fn export_projection_view_with_revision( + &self, + updated_at_micros: i64, + ) -> Result<(AuthStoreProjectionView, u64), String> { + for _ in 0..3 { + let before = self.revision.load(Ordering::Acquire); + let view = self.export_projection_view(updated_at_micros)?; + let after = self.revision.load(Ordering::Acquire); + if before == after { + return Ok((view, after)); + } + } + Err("认证工作集在导出期间持续发生变化".to_string()) + } + + fn prune_stale_sessions(&self, now: OffsetDateTime) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let stale_session_ids = state + .sessions_by_id + .iter() + .filter(|(_, stored)| should_prune_refresh_session(&stored.session, now)) + .map(|(session_id, _)| session_id.clone()) + .collect::>(); + if stale_session_ids.is_empty() { + return Ok(()); + } + + for session_id in stale_session_ids { + let Some(stored) = state.sessions_by_id.remove(&session_id) else { + continue; + }; + if state + .session_id_by_refresh_token_hash + .get(&stored.session.refresh_token_hash) + .is_some_and(|mapped_id| mapped_id == &session_id) + { + state + .session_id_by_refresh_token_hash + .remove(&stored.session.refresh_token_hash); + } + } + self.persist_refresh_state(&state) + } + + fn persist_state(&self, state: &InMemoryAuthStoreState) -> Result<(), String> { + let _ = state; + self.revision.fetch_add(1, Ordering::Release); + Ok(()) + } + + fn persist_password_state( + &self, + state: &InMemoryAuthStoreState, + ) -> Result<(), PasswordEntryError> { + self.persist_state(state).map_err(PasswordEntryError::Store) + } + + fn persist_phone_state(&self, state: &InMemoryAuthStoreState) -> Result<(), PhoneAuthError> { + self.persist_state(state).map_err(PhoneAuthError::Store) + } + + fn persist_wechat_state(&self, state: &InMemoryAuthStoreState) -> Result<(), WechatAuthError> { + self.persist_state(state).map_err(WechatAuthError::Store) + } + + fn persist_refresh_state( + &self, + state: &InMemoryAuthStoreState, + ) -> Result<(), RefreshSessionError> { + self.persist_state(state) + .map_err(RefreshSessionError::Store) + } + + fn resolve_phone_user_locked( + state: &mut InMemoryAuthStoreState, + phone_number: &str, + ) -> Option { + if let Some(user_id) = state.phone_to_user_id.get(phone_number).cloned() { + if let Some(stored_user) = state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == user_id) + .cloned() + { + return Some(stored_user); + } + state.phone_to_user_id.remove(phone_number); + } + + let Some(stored_user) = state + .users_by_username + .values() + .find(|stored_user| stored_user_phone_number(stored_user) == Some(phone_number)) + .cloned() + else { + return None; + }; + state + .phone_to_user_id + .insert(phone_number.to_string(), stored_user.user.id.clone()); + Some(stored_user) + } + + fn find_by_user_id( + &self, + user_id: &str, + ) -> Result, PasswordEntryError> { + let state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + Ok(state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == user_id) + .map(|stored_user| hydrate_private_auth_fields(&state, stored_user))) + } + + fn ensure_orphan_work_owner_user( + &self, + user_id: &str, + username: &str, + display_name: &str, + public_user_code: &str, + ) -> Result { + let user_id = normalize_required_string(user_id) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户 id 不能为空".to_string()))?; + let username = normalize_required_string(username) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位用户名不能为空".to_string()))?; + let display_name = normalize_required_string(display_name) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位展示名不能为空".to_string()))?; + let public_user_code = normalize_required_string(public_user_code) + .ok_or_else(|| PasswordEntryError::Store("孤儿作品占位陶泥号不能为空".to_string()))?; + + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if let Some(stored) = state + .users_by_username + .values() + .find(|stored_user| stored_user.user.id == user_id) + { + return Ok(stored.user.clone()); + } + + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) + })?; + let user = AuthUser { + id: user_id, + public_user_code, + username: username.clone(), + display_name, + avatar_url: None, + phone_number: None, + phone_number_masked: None, + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + wechat_display_name: None, + wechat_account: None, + token_version: 1, + created_at, + }; + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash: String::new(), + password_login_enabled: false, + phone_number: None, + }, + ); + self.persist_password_state(&state)?; + + Ok(user) + } + + fn find_by_public_user_code( + &self, + public_user_code: &str, + ) -> Result, PasswordEntryError> { + let state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + Ok(state + .users_by_username + .values() + .find(|stored_user| stored_user.user.public_user_code == public_user_code) + .map(|stored_user| hydrate_private_auth_fields(&state, stored_user))) + } + + fn find_by_phone_number( + &self, + phone_number: &str, + ) -> Result, PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + Ok(Self::resolve_phone_user_locked(&mut state, phone_number) + .map(|stored_user| hydrate_private_auth_fields(&state, &stored_user))) + } + + fn find_by_phone_number_for_password( + &self, + phone_number: &str, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + Ok(Self::resolve_phone_user_locked(&mut state, phone_number) + .map(|stored_user| hydrate_private_auth_fields(&state, &stored_user))) + } + + fn update_user_profile( + &self, + user_id: &str, + display_name: Option, + avatar_url: Option, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + if let Some(display_name) = display_name { + stored_user.user.display_name = display_name; + } + if let Some(avatar_url) = avatar_url { + stored_user.user.avatar_url = Some(avatar_url); + } + let next_user = stored_user.user.clone(); + self.persist_password_state(&state)?; + return Ok(Some(next_user)); + } + + Ok(None) + } + + 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 Self::resolve_phone_user_locked(&mut state, &phone_number.e164).is_some() { + return Err(PhoneAuthError::Store( + "手机号已存在,无法重复创建账号".to_string(), + )); + } + + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + PhoneAuthError::Store(format!("用户创建时间格式化失败:{message}")) + })?; + let sequence = state.next_user_id; + let user_id = build_prefixed_uuid_id("user_"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + avatar_url: None, + phone_number: Some(phone_number.e164.clone()), + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Phone, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + wechat_display_name: None, + wechat_account: None, + token_version: 1, + created_at, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: false, + phone_number: Some(phone_number.e164), + }, + ); + self.persist_phone_state(&state)?; + + Ok(user) + } + + fn create_dev_password_phone_user( + &self, + phone_number: PhoneNumberSnapshot, + display_name: String, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + if Self::resolve_phone_user_locked(&mut state, &phone_number.e164).is_some() { + return Err(PasswordEntryError::InvalidCredentials); + } + + let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + PasswordEntryError::Store(format!("用户创建时间格式化失败:{message}")) + })?; + let sequence = state.next_user_id; + let user_id = build_prefixed_uuid_id("user_"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let username = build_system_username("phone", state.next_user_id); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + avatar_url: None, + phone_number: Some(phone_number.e164.clone()), + phone_number_masked: Some(phone_number.masked_national_number.clone()), + login_method: AuthLoginMethod::Password, + binding_status: AuthBindingStatus::Active, + wechat_bound: false, + wechat_display_name: None, + wechat_account: None, + token_version: 1, + created_at, + }; + state + .phone_to_user_id + .insert(phone_number.e164.clone(), user_id); + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: true, + phone_number: Some(phone_number.e164), + }, + ); + self.persist_password_state(&state)?; + + 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 created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { + WechatAuthError::Store(format!("用户创建时间格式化失败:{message}")) + })?; + let sequence = state.next_user_id; + let user_id = build_prefixed_uuid_id("user_"); + let public_user_code = build_public_user_code(sequence); + state.next_user_id += 1; + let avatar_url = normalize_optional_string(profile.avatar_url.clone()); + let display_name = profile + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("微信旅人") + .to_string(); + let wechat_display_name = normalize_optional_string(profile.display_name.clone()); + let username = build_wechat_username(&display_name, &profile.provider_uid); + let provider_uid = normalize_required_string(&profile.provider_uid).unwrap_or_default(); + let user = AuthUser { + id: user_id.clone(), + public_user_code, + username: username.clone(), + display_name, + avatar_url: avatar_url.clone(), + phone_number: None, + phone_number_masked: None, + login_method: AuthLoginMethod::Wechat, + binding_status: AuthBindingStatus::PendingBindPhone, + wechat_bound: true, + wechat_display_name, + wechat_account: Some(provider_uid.clone()), + token_version: 1, + created_at, + }; + state.users_by_username.insert( + username, + StoredPasswordUser { + user: user.clone(), + password_hash, + password_login_enabled: false, + phone_number: None, + }, + ); + let identity = StoredWechatIdentity { + user_id: user_id.clone(), + provider_uid, + provider_union_id: normalize_optional_string(profile.provider_union_id), + display_name: normalize_optional_string(profile.display_name), + avatar_url, + session_key: normalize_optional_string(profile.session_key), + }; + 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); + self.persist_wechat_state(&state)?; + + 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(hydrate_private_auth_fields(&state, stored).user)); + } + + 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| hydrate_private_auth_fields(&state, stored).user)) + } + + fn get_wechat_identity_by_user_id( + &self, + user_id: &str, + ) -> Result, WechatAuthError> { + let state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?; + let Some(identity) = state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == user_id.trim()) + else { + return Ok(None); + }; + + Ok(Some(WechatIdentityRecord { + user_id: identity.user_id.clone(), + provider_uid: identity.provider_uid.clone(), + provider_union_id: identity.provider_union_id.clone(), + session_key: identity.session_key.clone(), + })) + } + + fn bind_wechat_identity_to_user( + &self, + user_id: &str, + profile: WechatIdentityProfile, + ) -> Result { + let user_id = normalize_required_string(user_id).ok_or(WechatAuthError::UserNotFound)?; + let provider_uid = normalize_required_string(&profile.provider_uid) + .ok_or(WechatAuthError::MissingProfile)?; + let provider_union_id = normalize_optional_string(profile.provider_union_id); + let display_name = normalize_optional_string(profile.display_name); + let avatar_url = normalize_optional_string(profile.avatar_url); + let session_key = normalize_optional_string(profile.session_key); + + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("鐢ㄦ埛浠撳偍閿佸凡涓瘨".to_string()))?; + + if !state + .users_by_username + .values() + .any(|stored_user| stored_user.user.id == user_id) + { + return Err(WechatAuthError::UserNotFound); + } + + if let Some(existing) = state.wechat_identity_by_provider_uid.get(&provider_uid) + && existing.user_id != user_id + { + return Err(WechatAuthError::WechatIdentityAlreadyBound); + } + if let Some(provider_union_id) = provider_union_id.as_deref() + && let Some(existing_user_id) = + state.user_id_by_provider_union_id.get(provider_union_id) + && existing_user_id != &user_id + { + return Err(WechatAuthError::WechatIdentityAlreadyBound); + } + + if let Some(existing_identity) = state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == user_id) + .cloned() + { + state + .wechat_identity_by_provider_uid + .remove(&existing_identity.provider_uid); + if let Some(existing_union_id) = existing_identity.provider_union_id + && provider_union_id.as_deref() != Some(existing_union_id.as_str()) + { + state + .user_id_by_provider_union_id + .remove(&existing_union_id); + } + } + + state.wechat_identity_by_provider_uid.insert( + provider_uid.clone(), + StoredWechatIdentity { + user_id: user_id.clone(), + provider_uid: provider_uid.clone(), + provider_union_id: provider_union_id.clone(), + display_name: display_name.clone(), + avatar_url: avatar_url.clone(), + session_key, + }, + ); + if let Some(provider_union_id) = provider_union_id { + state + .user_id_by_provider_union_id + .insert(provider_union_id, user_id.clone()); + } + + let next_user = { + let stored_user = state + .users_by_username + .values_mut() + .find(|stored_user| stored_user.user.id == user_id) + .ok_or(WechatAuthError::UserNotFound)?; + stored_user.user.wechat_bound = true; + stored_user.user.wechat_account = Some(provider_uid); + if let Some(display_name) = display_name { + stored_user.user.wechat_display_name = Some(display_name); + } + if stored_user.user.avatar_url.is_none() + && let Some(avatar_url) = avatar_url + { + stored_user.user.avatar_url = Some(avatar_url); + } + stored_user.user.clone() + }; + self.persist_wechat_state(&state)?; + + Ok(next_user) + } + + 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_session_key = normalize_optional_string(profile.session_key); + let next_provider_uid = + normalize_required_string(&profile.provider_uid).unwrap_or_default(); + { + 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.clone(); + identity.provider_union_id = next_provider_union_id.clone(); + if next_session_key.is_some() { + identity.session_key = next_session_key.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 next_user = { + 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() { + stored_user.user.display_name = display_name.to_string(); + } + stored_user.user.wechat_account = Some(next_provider_uid.clone()); + if let Some(display_name) = next_display_name.clone() { + stored_user.user.wechat_display_name = Some(display_name); + } + if let Some(avatar_url) = next_avatar_url.clone() { + stored_user.user.avatar_url = Some(avatar_url); + } + stored_user.user.clone() + }; + self.persist_wechat_state(&state)?; + + Ok(next_user) + } + + fn insert_session(&self, session: RefreshSessionRecord) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + + if state + .session_id_by_refresh_token_hash + .contains_key(&session.refresh_token_hash) + { + return Err(RefreshSessionError::Store( + "refresh token hash 已存在,无法重复创建会话".to_string(), + )); + } + if state.sessions_by_id.len() >= MAX_REFRESH_SESSIONS { + return Err(RefreshSessionError::Store( + "refresh session 内存容量已达到上限".to_string(), + )); + } + + state.session_id_by_refresh_token_hash.insert( + session.refresh_token_hash.clone(), + session.session_id.clone(), + ); + state + .sessions_by_id + .insert(session.session_id.clone(), StoredRefreshSession { session }); + self.persist_refresh_state(&state)?; + + 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 !state.phone_codes_by_key.contains_key(&key) + && state.phone_codes_by_key.len() >= MAX_PHONE_CODES + { + return Err(PhoneAuthError::Store( + "短信验证码内存容量已达到上限,请稍后重试".to_string(), + )); + } + state.phone_codes_by_key.insert(key, code); + self.persist_phone_state(&state)?; + Ok(()) + } + + fn prune_expired_phone_codes(&self, now: OffsetDateTime) -> Result<(), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let expired_keys = state + .phone_codes_by_key + .iter() + .filter_map(|(key, stored)| { + OffsetDateTime::parse( + &stored.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .ok() + .filter(|expires_at| *expires_at <= now) + .map(|_| key.clone()) + }) + .collect::>(); + for key in expired_keys { + state.phone_codes_by_key.remove(&key); + } + Ok(()) + } + + fn ensure_phone_code_not_cooling_down( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + now: OffsetDateTime, + ) -> Result<(), PhoneAuthError> { + let state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let key = build_phone_code_key(phone_number, scene); + let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else { + return Ok(()); + }; + drop(state); + + let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?; + if expires_at <= now { + return Ok(()); + } + 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 Ok(()); + } + let retry_after_seconds = seconds_until(now, cooling_until); + warn!( + scene = scene.as_str(), + phone_masked = mask_phone_number(phone_number).as_str(), + retry_after_seconds, + "手机号验证码发送命中本地冷却限制" + ); + Err(PhoneAuthError::SendCoolingDown { + retry_after_seconds, + }) + } + + fn ensure_phone_code_capacity( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + ) -> Result<(), PhoneAuthError> { + let state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let key = build_phone_code_key(phone_number, scene); + if state.phone_codes_by_key.contains_key(&key) + || state.phone_codes_by_key.len() < MAX_PHONE_CODES + { + return Ok(()); + } + Err(PhoneAuthError::Store( + "短信验证码内存容量已达到上限,请稍后重试".to_string(), + )) + } + + fn get_active_phone_code( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + now: OffsetDateTime, + ) -> Result { + 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); + } + Ok(stored) + } + + fn consume_phone_code_success( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + ) -> Result<(), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let key = build_phone_code_key(phone_number, scene); + state.phone_codes_by_key.remove(&key); + self.persist_phone_state(&state)?; + Ok(()) + } + + fn consume_phone_code_failure( + &self, + phone_number: &str, + scene: &PhoneAuthScene, + ) -> Result<(), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?; + let key = build_phone_code_key(phone_number, scene); + let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else { + return Err(PhoneAuthError::VerifyCodeNotFound); + }; + 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); + self.persist_phone_state(&state)?; + return Err(PhoneAuthError::VerifyAttemptsExceeded); + } + if let Some(current) = state.phone_codes_by_key.get_mut(&key) { + current.failed_attempts = next_failed_attempts; + } + self.persist_phone_state(&state)?; + Err(PhoneAuthError::InvalidVerifyCode) + } + + fn insert_wechat_state( + &self, + state_record: WechatAuthStateRecord, + now: OffsetDateTime, + ) -> Result<(), WechatAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + prune_expired_short_lived_state(&mut state, now); + if state + .wechat_states_by_token + .contains_key(&state_record.state_token) + { + return Err(WechatAuthError::Store("微信 state 已存在".to_string())); + } + if state.wechat_states_by_token.len() >= MAX_ACTIVE_WECHAT_AUTH_STATES { + return Err(WechatAuthError::Store( + "微信登录请求过多,请稍后重试".to_string(), + )); + } + state.wechat_states_by_token.insert( + state_record.state_token.clone(), + StoredWechatAuthState { + state: state_record, + }, + ); + self.persist_wechat_state(&state)?; + 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; + let consumed = current.clone(); + state.wechat_states_by_token.remove(state_token.trim()); + self.persist_wechat_state(&state)?; + Ok(consumed) + } + + fn bind_wechat_phone_to_user( + &self, + pending_user_id: &str, + phone_number: PhoneNumberSnapshot, + wechat_display_name: Option, + ) -> Result<(AuthUser, bool), PhoneAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + let submitted_wechat_display_name = normalize_optional_string(wechat_display_name); + + let existing_phone_user_id = + Self::resolve_phone_user_locked(&mut state, &phone_number.e164) + .map(|stored_user| stored_user.user.id); + 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_wechat_account = pending_wechat_identity.provider_uid.clone(); + let pending_user = state + .users_by_username + .values() + .find(|stored| stored.user.id == pending_user_id) + .cloned() + .ok_or(PhoneAuthError::UserNotFound)?; + let pending_username = pending_user.user.username.clone(); + let pending_wechat_display_name = submitted_wechat_display_name + .clone() + .or_else(|| normalize_optional_string(pending_wechat_identity.display_name.clone())) + .or_else(|| { + normalize_optional_string(pending_user.user.wechat_display_name.clone()) + }); + let pending_wechat_avatar_url = + normalize_optional_string(pending_wechat_identity.avatar_url.clone()) + .or_else(|| normalize_optional_string(pending_user.user.avatar_url.clone())); + 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(), + display_name: pending_wechat_display_name.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; + target_user.user.wechat_account = Some(pending_wechat_account); + target_user.user.wechat_display_name = pending_wechat_display_name.clone(); + if let Some(display_name) = pending_wechat_display_name { + target_user.user.display_name = display_name; + } + if target_user.user.avatar_url.is_none() { + target_user.user.avatar_url = pending_wechat_avatar_url; + } + if target_user.user.phone_number.is_none() { + target_user.user.phone_number = target_user.phone_number.clone(); + } + let next_user = target_user.user.clone(); + self.persist_phone_state(&state)?; + + return Ok((next_user, false)); + } + + state + .phone_to_user_id + .insert(phone_number.e164.clone(), pending_user_id.to_string()); + let bound_wechat_account = state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == pending_user_id) + .map(|identity| identity.provider_uid.clone()); + let bound_wechat_display_name = submitted_wechat_display_name.clone().or_else(|| { + state + .wechat_identity_by_provider_uid + .values() + .find(|identity| identity.user_id == pending_user_id) + .and_then(|identity| normalize_optional_string(identity.display_name.clone())) + .or_else(|| { + state + .users_by_username + .values() + .find(|stored| stored.user.id == pending_user_id) + .and_then(|stored| { + normalize_optional_string(stored.user.wechat_display_name.clone()) + }) + }) + }); + + if let Some(display_name) = bound_wechat_display_name.clone() + && let Some(identity) = state + .wechat_identity_by_provider_uid + .values_mut() + .find(|identity| identity.user_id == pending_user_id) + { + identity.display_name = Some(display_name); + } + + 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 = Some(phone_number.e164.clone()); + 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; + if stored_user.user.wechat_account.is_none() { + stored_user.user.wechat_account = bound_wechat_account; + } + if let Some(display_name) = bound_wechat_display_name { + stored_user.user.display_name = display_name.clone(); + stored_user.user.wechat_display_name = Some(display_name); + } + stored_user.phone_number = Some(phone_number.e164); + let next_user = stored_user.user.clone(); + self.persist_phone_state(&state)?; + + Ok((next_user, true)) + } + + fn find_session_by_refresh_token_hash( + &self, + refresh_token_hash: &str, + ) -> Result, RefreshSessionError> { + let state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(session_id) = state + .session_id_by_refresh_token_hash + .get(refresh_token_hash) + else { + return Ok(None); + }; + + Ok(state.sessions_by_id.get(session_id).cloned()) + } + + fn list_active_sessions_by_user( + &self, + user_id: &str, + now: OffsetDateTime, + ) -> Result, RefreshSessionError> { + let state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let now_unix = now.unix_timestamp(); + + let mut sessions = state + .sessions_by_id + .values() + .filter_map(|stored| { + if stored.session.user_id != user_id { + return None; + } + if stored.session.revoked_at.is_some() { + return None; + } + let expires_at = OffsetDateTime::parse( + &stored.session.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .ok()?; + if expires_at.unix_timestamp() <= now_unix { + return None; + } + + Some(stored.session.clone()) + }) + .collect::>(); + + sessions.sort_by(|left, right| { + right + .last_seen_at + .cmp(&left.last_seen_at) + .then_with(|| right.created_at.cmp(&left.created_at)) + }); + + Ok(sessions) + } + + fn is_session_active_for_user( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + if session_id.trim().is_empty() { + return Ok(false); + } + + let state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(stored) = state.sessions_by_id.get(session_id) else { + return Ok(false); + }; + if stored.session.user_id != user_id || stored.session.revoked_at.is_some() { + return Ok(false); + } + + let expires_at = OffsetDateTime::parse( + &stored.session.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|error| RefreshSessionError::Store(format!("会话过期时间解析失败:{error}")))?; + + Ok(expires_at > now) + } + + fn rotate_session( + &self, + session_id: &str, + previous_refresh_token_hash: &str, + next_refresh_token_hash: String, + next_expires_at: String, + updated_at: String, + last_seen_at: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + + if state + .session_id_by_refresh_token_hash + .contains_key(&next_refresh_token_hash) + { + return Err(RefreshSessionError::Store( + "新 refresh token hash 已存在,无法轮换".to_string(), + )); + } + + let current_refresh_token_hash = state + .sessions_by_id + .get(session_id) + .ok_or(RefreshSessionError::SessionNotFound)? + .session + .refresh_token_hash + .clone(); + if current_refresh_token_hash != previous_refresh_token_hash { + return Err(RefreshSessionError::SessionNotFound); + } + + state + .session_id_by_refresh_token_hash + .remove(previous_refresh_token_hash); + let stored = state + .sessions_by_id + .get_mut(session_id) + .ok_or(RefreshSessionError::SessionNotFound)?; + stored.session.refresh_token_hash = next_refresh_token_hash.clone(); + stored.session.expires_at = next_expires_at; + stored.session.updated_at = updated_at; + stored.session.last_seen_at = last_seen_at; + let updated_session = stored.clone(); + state.session_id_by_refresh_token_hash.insert( + next_refresh_token_hash, + updated_session.session.session_id.clone(), + ); + self.persist_refresh_state(&state)?; + + Ok(updated_session) + } + + fn revoke_session_by_refresh_token_hash( + &self, + refresh_token_hash: &str, + now: OffsetDateTime, + ) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(session_id) = state + .session_id_by_refresh_token_hash + .get(refresh_token_hash) + .cloned() + else { + return Ok(()); + }; + let Some(stored) = state.sessions_by_id.get_mut(&session_id) else { + return Ok(()); + }; + if stored.session.revoked_at.is_some() { + return Ok(()); + } + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) + })?; + stored.session.revoked_at = Some(now_iso.clone()); + stored.session.updated_at = now_iso; + self.persist_refresh_state(&state)?; + + Ok(()) + } + + fn prune_wechat_states(&self, now: OffsetDateTime) -> Result<(), WechatAuthError> { + let mut state = self + .inner + .lock() + .map_err(|_| WechatAuthError::Store("微信 state 仓储锁已中毒".to_string()))?; + let stale_tokens = state + .wechat_states_by_token + .iter() + .filter_map(|(token, stored)| { + if stored.state.consumed_at.is_some() { + return Some(token.clone()); + } + OffsetDateTime::parse( + &stored.state.expires_at, + &time::format_description::well_known::Rfc3339, + ) + .ok() + .filter(|expires_at| *expires_at <= now) + .map(|_| token.clone()) + }) + .collect::>(); + for token in stale_tokens { + state.wechat_states_by_token.remove(&token); + } + Ok(()) + } + + fn revoke_session_by_user_and_session_id( + &self, + user_id: &str, + session_id: &str, + now: OffsetDateTime, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let Some(stored) = state.sessions_by_id.get_mut(session_id) else { + return Ok(false); + }; + if stored.session.user_id != user_id { + return Ok(false); + } + if stored.session.revoked_at.is_some() { + return Ok(false); + } + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) + })?; + stored.session.revoked_at = Some(now_iso.clone()); + stored.session.updated_at = now_iso; + self.persist_refresh_state(&state)?; + + Ok(true) + } + + fn revoke_all_sessions_by_user_id( + &self, + user_id: &str, + now: OffsetDateTime, + ) -> Result<(), RefreshSessionError> { + let mut state = self + .inner + .lock() + .map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?; + let now_iso = now + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + RefreshSessionError::Store(format!("会话吊销时间格式化失败:{error}")) + })?; + + for stored in state.sessions_by_id.values_mut() { + if stored.session.user_id != user_id { + continue; + } + if stored.session.revoked_at.is_some() { + continue; + } + + stored.session.revoked_at = Some(now_iso.clone()); + stored.session.updated_at = now_iso.clone(); + } + self.persist_refresh_state(&state)?; + + Ok(()) + } + + fn increment_user_token_version( + &self, + user_id: &str, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + stored_user.user.token_version += 1; + let next_user = stored_user.user.clone(); + self.persist_password_state(&state)?; + return Ok(Some(next_user)); + } + + Ok(None) + } + + fn set_user_password_hash( + &self, + user_id: &str, + password_hash: String, + revoke_all_sessions_at: Option, + ) -> Result, PasswordEntryError> { + let mut state = self + .inner + .lock() + .map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?; + let revoke_all_sessions_at = match revoke_all_sessions_at { + Some(now) => Some( + now.format(&time::format_description::well_known::Rfc3339) + .map_err(|error| { + PasswordEntryError::Store(format!("会话吊销时间格式化失败:{error}")) + })?, + ), + None => None, + }; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + stored_user.password_hash = password_hash; + stored_user.password_login_enabled = true; + stored_user.user.token_version += 1; + let next_user = stored_user.user.clone(); + if let Some(now_iso) = revoke_all_sessions_at.as_ref() { + for stored_session in state.sessions_by_id.values_mut() { + if stored_session.session.user_id != user_id + || stored_session.session.revoked_at.is_some() + { + continue; + } + + stored_session.session.revoked_at = Some(now_iso.clone()); + stored_session.session.updated_at = now_iso.clone(); + } + } + self.persist_password_state(&state)?; + return Ok(Some(next_user)); + } + + Ok(None) + } + + fn set_user_password_by_phone_number( + &self, + phone_number: &str, + password_hash: String, + ) -> Result { + let mut state = self + .inner + .lock() + .map_err(|_| PhoneAuthError::Store("用户仓储锁已中毒".to_string()))?; + let user_id = Self::resolve_phone_user_locked(&mut state, phone_number) + .map(|stored_user| stored_user.user.id) + .ok_or(PhoneAuthError::UserNotFound)?; + + for stored_user in state.users_by_username.values_mut() { + if stored_user.user.id != user_id { + continue; + } + + stored_user.password_hash = password_hash; + stored_user.password_login_enabled = true; + stored_user.user.token_version += 1; + let next_user = stored_user.user.clone(); + self.persist_phone_state(&state)?; + return Ok(next_user); + } + + Err(PhoneAuthError::UserNotFound) + } +} + +fn should_prune_refresh_session(session: &RefreshSessionRecord, now: OffsetDateTime) -> bool { + should_prune_refresh_session_fields(&session.expires_at, session.revoked_at.as_deref(), now) +} + +fn should_prune_refresh_session_fields( + expires_at: &str, + revoked_at: Option<&str>, + now: OffsetDateTime, +) -> bool { + let stale_before = now.saturating_sub(REFRESH_SESSION_STALE_RETENTION); + if let Some(revoked_at) = revoked_at { + return OffsetDateTime::parse(revoked_at, &time::format_description::well_known::Rfc3339) + .is_ok_and(|timestamp| timestamp <= stale_before); + } + + OffsetDateTime::parse(expires_at, &time::format_description::well_known::Rfc3339) + .is_ok_and(|timestamp| timestamp <= stale_before) +} + +fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { + match error { + SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, + SmsProviderError::InvalidConfig(message) => { + PhoneAuthError::SmsProviderInvalidConfig(message) + } + SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message), + } +} + +fn map_phone_number_error_to_password_error(error: PhoneAuthError) -> PasswordEntryError { + match error { + PhoneAuthError::UnsupportedPhoneCountryCode => { + PasswordEntryError::UnsupportedPhoneCountryCode + } + _ => PasswordEntryError::InvalidPhoneNumber, + } +} + +async fn verify_stored_password_user( + existing_user: StoredPasswordUser, + password: &str, +) -> Result { + if !existing_user.password_login_enabled { + return Err(PasswordEntryError::InvalidCredentials); + } + + let is_valid = verify_password(&existing_user.password_hash, password) + .await + .map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?; + if !is_valid { + return Err(PasswordEntryError::InvalidCredentials); + } + + Ok(PasswordEntryResult { + user: AuthUser { + login_method: AuthLoginMethod::Password, + ..existing_user.user + }, + created: false, + }) +} + +fn normalize_profile_display_name(value: &str) -> Result { + let Some(display_name) = normalize_required_string(value) else { + return Err(PasswordEntryError::InvalidDisplayName); + }; + let length = display_name.chars().count(); + if !(2..=20).contains(&length) { + return Err(PasswordEntryError::InvalidDisplayName); + } + if !display_name.chars().all(|character| { + character == '_' + || character.is_ascii_alphanumeric() + || is_common_chinese_character(character) + }) { + return Err(PasswordEntryError::InvalidDisplayName); + } + + Ok(display_name) +} + +fn normalize_profile_avatar_url(value: &str) -> Result { + let Some(avatar_url) = normalize_required_string(value) else { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + }; + if !avatar_url.starts_with("data:image/") || !avatar_url.contains(";base64,") { + return Err(PasswordEntryError::InvalidAvatarDataUrl); + } + + Ok(avatar_url) +} + +fn is_common_chinese_character(character: char) -> bool { + ('\u{4e00}'..='\u{9fff}').contains(&character) +} + +fn build_random_password_seed() -> String { + format!( + "seed_{}_{}", + new_uuid_simple_string(), + new_uuid_simple_string() + ) +} + +fn generate_random_phone_verify_code() -> String { + let digest = Sha256::digest(new_uuid_simple_string().as_bytes()); + let mut digits = digest + .iter() + .take(SMS_CODE_LENGTH) + .map(|byte| char::from(b'0' + (*byte % 10))) + .collect::(); + while digits.len() < SMS_CODE_LENGTH { + digits.push('0'); + } + digits +} + +fn hash_phone_verify_code( + salt: &str, + phone_number: &str, + scene: &PhoneAuthScene, + verify_code: &str, +) -> String { + let content = format!( + "{}:{}:{}:{}", + salt, + phone_number.trim(), + scene.as_str(), + verify_code.trim() + ); + let digest = Sha256::digest(content.as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn format_rfc3339(value: OffsetDateTime) -> Result { + format_shared_rfc3339(value) +} + +#[allow(dead_code)] +fn current_auth_user_created_at() -> String { + format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| default_auth_user_created_at()) +} + +#[allow(dead_code)] +fn default_auth_user_created_at() -> String { + "1970-01-01T00:00:00Z".to_string() +} + +fn parse_phone_code_time(value: &str, field_label: &str) -> Result { + parse_rfc3339(value) + .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 create_wechat_state_token() -> String { + new_uuid_simple_string() +} + +fn format_rfc3339_with_context( + value: OffsetDateTime, + field_label: &str, +) -> Result { + format_shared_rfc3339(value) + .map_err(|error| RefreshSessionError::Store(format!("{field_label}格式化失败:{error}"))) +} + +fn parse_rfc3339_with_context( + value: &str, + field_label: &str, +) -> Result { + parse_rfc3339(value) + .map_err(|error| RefreshSessionError::Store(format!("{field_label}解析失败:{error}"))) +} + +#[cfg(test)] +mod tests { + use platform_auth::{ + DEFAULT_SMS_CASE_AUTH_POLICY, DEFAULT_SMS_CODE_LENGTH, DEFAULT_SMS_CODE_TYPE, + DEFAULT_SMS_COUNTRY_CODE, DEFAULT_SMS_DUPLICATE_POLICY, DEFAULT_SMS_ENDPOINT, + DEFAULT_SMS_INTERVAL_SECONDS, DEFAULT_SMS_MOCK_VERIFY_CODE, DEFAULT_SMS_TEMPLATE_PARAM_KEY, + DEFAULT_SMS_VALID_TIME_SECONDS, SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, + hash_refresh_session_token, + }; + + use super::*; + + #[test] + fn build_wechat_username_uses_display_name_and_provider_uid() { + assert_eq!( + build_wechat_username("小明", "wx-openid-123"), + "小明_wx-openid-123" + ); + assert_eq!( + build_wechat_username(" ", "wx-openid-123"), + "微信旅人_wx-openid-123" + ); + } + + #[test] + fn mainland_china_phone_normalization_accepts_pure_phone_number() { + let national = normalize_mainland_china_phone_number("13800138000") + .expect("national phone should normalize"); + + assert_eq!(national.e164, "+8613800138000"); + } + + #[test] + fn mainland_china_country_code_defaults_to_china_and_accepts_explicit_86() { + validate_mainland_china_country_code(None).expect("missing country code should default"); + validate_mainland_china_country_code(Some(" 86 ")) + .expect("explicit mainland China country code should pass"); + } + + #[test] + fn mainland_china_country_code_rejects_foreign_or_non_wechat_format() { + for country_code in ["1", "+86", ""] { + let error = validate_mainland_china_country_code(Some(country_code)) + .expect_err("unsupported country code should fail"); + + assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); + assert_eq!(error.to_string(), "仅支持中国大陆手机号(+86)"); + } + } + + fn build_store() -> InMemoryAuthStore { + InMemoryAuthStore::default() + } + + fn empty_projection_store() -> InMemoryAuthStore { + InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 0, + users: vec![], + identities: vec![], + refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], + }) + .expect("projection should restore") + } + + fn projection_user( + user_id: &str, + username: &str, + phone_number: Option<&str>, + ) -> AuthStoreProjectionUser { + AuthStoreProjectionUser { + user_id: user_id.to_string(), + public_user_code: "SY-00000009".to_string(), + username: username.to_string(), + display_name: "138****8009".to_string(), + avatar_url: None, + phone_number_masked: Some("138****8009".to_string()), + phone_number_e164: phone_number.map(str::to_string), + login_method: "phone".to_string(), + binding_status: "active".to_string(), + wechat_bound: false, + password_hash: "hash-existing".to_string(), + password_login_enabled: false, + token_version: 1, + created_at: "2026-06-30T00:00:00Z".to_string(), + } + } + + fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService { + PasswordEntryService::new(store) + } + + fn build_phone_service(store: InMemoryAuthStore) -> PhoneAuthService { + let sms_provider = SmsAuthProvider::new( + SmsAuthConfig::new( + SmsAuthProviderKind::Mock, + DEFAULT_SMS_ENDPOINT.to_string(), + None, + None, + String::new(), + String::new(), + DEFAULT_SMS_TEMPLATE_PARAM_KEY.to_string(), + DEFAULT_SMS_COUNTRY_CODE.to_string(), + None, + DEFAULT_SMS_CODE_LENGTH, + DEFAULT_SMS_CODE_TYPE, + DEFAULT_SMS_VALID_TIME_SECONDS, + DEFAULT_SMS_INTERVAL_SECONDS, + DEFAULT_SMS_DUPLICATE_POLICY, + DEFAULT_SMS_CASE_AUTH_POLICY, + false, + DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + ) + .expect("mock sms config should be valid"), + ) + .expect("mock sms provider should be valid"); + PhoneAuthService::new(store, sms_provider) + } + + fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService { + RefreshSessionService::new(store, 30) + } + + fn build_user_service(store: InMemoryAuthStore) -> AuthUserService { + AuthUserService::new(store) + } + + fn build_client_info() -> RefreshSessionClientInfo { + RefreshSessionClientInfo { + client_type: "web_browser".to_string(), + client_runtime: "chrome".to_string(), + client_platform: "windows".to_string(), + client_instance_id: Some("client-instance-001".to_string()), + device_fingerprint: Some("device-fingerprint-001".to_string()), + device_display_name: "Windows / Chrome".to_string(), + mini_program_app_id: None, + mini_program_env: None, + user_agent: Some("Mozilla/5.0".to_string()), + ip: Some("203.0.113.10".to_string()), + } + } + + async fn create_phone_login_user(store: InMemoryAuthStore, phone_number: &str) -> AuthUser { + let phone_service = build_phone_service(store); + let now = OffsetDateTime::now_utc(); + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: phone_number.to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: phone_number.to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("phone login should create user") + .user + } + + #[tokio::test] + async fn password_entry_rejects_unknown_user_without_registration() { + let service = build_password_service(build_store()); + + let error = service + .execute(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("password login must not create user"); + + assert_eq!(error, PasswordEntryError::InvalidCredentials); + } + + #[tokio::test] + async fn password_entry_dev_registration_creates_unknown_phone_user() { + let service = build_password_service(build_store()); + + let created = service + .execute_with_dev_registration(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("dev registration should create user"); + let reused = service + .execute_with_dev_registration(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138009".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("same password should reuse created user"); + let wrong_password = service + .execute_with_dev_registration(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138009".to_string(), + password: "secret999".to_string(), + }) + .await + .expect_err("existing user still requires the right password"); + + assert!(created.created); + assert_eq!(created.user.login_method, AuthLoginMethod::Password); + assert!(!reused.created); + assert_eq!(created.user.id, reused.user.id); + assert_eq!(wrong_password, PasswordEntryError::InvalidCredentials); + } + + #[tokio::test] + async fn phone_user_can_set_password_then_login() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138000").await; + let service = build_password_service(store); + + service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("phone user should set first password"); + let result = service + .execute(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("password login should succeed after setting password"); + + assert!(!result.created); + assert_eq!(result.user.id, user.id); + assert_eq!(result.user.login_method, AuthLoginMethod::Password); + } + + #[tokio::test] + async fn change_password_and_revoke_all_sessions_revokes_every_refresh_session() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138030").await; + let password_service = build_password_service(store.clone()); + let refresh_service = build_refresh_service(store.clone()); + let now = OffsetDateTime::now_utc(); + + let first_password_user = password_service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("first password should set") + .user; + let first_token_hash = hash_refresh_session_token("change-password-token-01"); + let second_token_hash = hash_refresh_session_token("change-password-token-02"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("first session should create"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: second_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "safari".to_string(), + device_display_name: "iOS / Safari".to_string(), + ..build_client_info() + }, + }, + now + Duration::seconds(1), + ) + .expect("second session should create"); + + let changed_user = password_service + .change_password_and_revoke_all_sessions( + ChangePasswordInput { + user_id: user.id.clone(), + current_password: Some("secret123".to_string()), + new_password: "secret456".to_string(), + }, + now + Duration::minutes(1), + ) + .await + .expect("password change should revoke all sessions") + .user; + + assert_eq!( + changed_user.token_version, + first_password_user.token_version + 1 + ); + assert!( + refresh_service + .list_active_sessions_by_user(&user.id, now + Duration::minutes(2)) + .expect("active sessions should list") + .sessions + .is_empty() + ); + for (token_hash, next_hash) in [ + ( + first_token_hash, + hash_refresh_session_token("change-password-token-01-next"), + ), + ( + second_token_hash, + hash_refresh_session_token("change-password-token-02-next"), + ), + ] { + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: token_hash, + next_refresh_token_hash: next_hash, + }, + now + Duration::minutes(2), + ) + .expect_err("revoked session should not rotate"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + } + + assert_eq!( + password_service + .execute(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138030".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("old password should fail"), + PasswordEntryError::InvalidCredentials + ); + let login = password_service + .execute(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138030".to_string(), + password: "secret456".to_string(), + }) + .await + .expect("new password should login"); + assert_eq!(login.user.id, user.id); + } + + #[tokio::test] + async fn password_entry_rejects_wrong_password_after_set() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138001").await; + let service = build_password_service(store); + service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("password should set"); + + let error = service + .execute(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138001".to_string(), + password: "secret999".to_string(), + }) + .await + .expect_err("wrong password should fail"); + + assert_eq!(error, PasswordEntryError::InvalidCredentials); + } + + #[tokio::test] + async fn reset_password_requires_existing_phone_user() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let now = OffsetDateTime::now_utc(); + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138002".to_string(), + scene: PhoneAuthScene::ResetPassword, + }, + now, + ) + .await + .expect("reset code should send"); + + let error = phone_service + .reset_password( + ResetPasswordInput { + country_code: None, + pure_phone_number: "13800138002".to_string(), + verify_code: "123456".to_string(), + new_password: "secret123".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect_err("unknown phone must not register by reset"); + + assert_eq!(error, PhoneAuthError::UserNotFound); + } + + #[tokio::test] + async fn dev_password_registration_after_empty_projection_restore() { + let service = build_password_service(empty_projection_store()); + + let created = service + .execute_with_dev_registration(PasswordEntryInput { + country_code: None, + pure_phone_number: "13800138004".to_string(), + password: "secret123".to_string(), + }) + .await + .expect("orphan phone index should not block dev registration"); + + assert!(created.created); + assert_eq!( + created.user.phone_number_masked.as_deref(), + Some("138****8004") + ); + } + + #[tokio::test] + async fn phone_login_after_empty_projection_restore() { + let phone_service = build_phone_service(empty_projection_store()); + let now = OffsetDateTime::now_utc(); + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138005".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + + let created = phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138005".to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("orphan phone index should not turn login into duplicate create"); + + assert!(created.created); + assert_eq!( + created.user.phone_number_masked.as_deref(), + Some("138****8005") + ); + } + + #[tokio::test] + async fn phone_login_reuses_user_restored_from_projection() { + let phone_service = build_phone_service( + InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![projection_user( + "user_existing_phone", + "phone_existing", + Some("+8613800138009"), + )], + identities: vec![], + refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], + }) + .expect("projection should restore"), + ); + let now = OffsetDateTime::now_utc(); + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138009".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + + let reused = phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138009".to_string(), + verify_code: "123456".to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("phone login should reuse existing user"); + + assert!(!reused.created); + assert_eq!(reused.user.id, "user_existing_phone"); + } + + #[tokio::test] + async fn projection_restores_user_and_refresh_session_after_roundtrip() { + let store = InMemoryAuthStore::default(); + let user = create_phone_login_user(store.clone(), "13800138003").await; + let password_service = build_password_service(store.clone()); + let refresh_service = build_refresh_service(store.clone()); + password_service + .change_password(ChangePasswordInput { + user_id: user.id.clone(), + current_password: None, + new_password: "secret123".to_string(), + }) + .await + .expect("password should set before persistence check"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("persist-token-01"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + OffsetDateTime::now_utc(), + ) + .expect("refresh session should be persisted"); + + let projection = store + .export_projection_view(1) + .expect("projection export should succeed"); + let restored_store = + InMemoryAuthStore::from_projection_view(projection).expect("projection should restore"); + let restored_user = build_password_service(restored_store.clone()) + .get_user_by_id(&user.id) + .expect("restored user query should succeed") + .expect("restored user should exist") + .user; + assert_eq!(restored_user.username, user.username); + + let rotated = build_refresh_service(restored_store) + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: hash_refresh_session_token("persist-token-01"), + next_refresh_token_hash: hash_refresh_session_token("persist-token-02"), + }, + OffsetDateTime::now_utc(), + ) + .expect("restored refresh session should rotate"); + assert_eq!(rotated.user.id, user.id); + } + + #[tokio::test] + async fn projection_roundtrip_preserves_phone_code_and_wechat_state() { + let store = InMemoryAuthStore::default(); + let phone_service = build_phone_service(store.clone()); + let wechat_state_service = WechatAuthStateService::new(store.clone(), 5); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138040".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send before projection export"); + let created_state = wechat_state_service + .create_state( + CreateWechatAuthStateInput { + redirect_path: "/studio".to_string(), + scene: WechatAuthScene::Desktop, + request_user_agent: Some("test-agent".to_string()), + bind_user_id: None, + }, + now, + ) + .expect("wechat state should be created before projection export"); + + let projection = store + .export_projection_view(1) + .expect("projection export should include short-lived auth state"); + assert_eq!(projection.phone_codes.len(), 1); + assert_eq!(projection.wechat_states.len(), 1); + + let restored_store = InMemoryAuthStore::from_projection_view(projection) + .expect("projection should restore short-lived auth state"); + let restored_phone_service = build_phone_service(restored_store.clone()); + let login = restored_phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138040".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("restored phone code should verify"); + assert!(login.created); + + let consumed_state = WechatAuthStateService::new(restored_store, 5) + .consume_state(&created_state.state.state_token, now + Duration::seconds(1)) + .expect("restored wechat state should be consumable"); + assert_eq!(consumed_state.state.redirect_path, "/studio"); + } + + #[test] + fn wechat_state_creation_is_bounded_before_projection_sync() { + let store = InMemoryAuthStore::default(); + let service = WechatAuthStateService::new(store, 5); + let now = OffsetDateTime::now_utc(); + + for index in 0..MAX_ACTIVE_WECHAT_AUTH_STATES { + service + .create_state( + CreateWechatAuthStateInput { + redirect_path: format!("/studio?attempt={index}"), + scene: WechatAuthScene::Desktop, + request_user_agent: None, + bind_user_id: None, + }, + now, + ) + .expect("active wechat state should fit within the projection budget"); + } + + let error = service + .create_state( + CreateWechatAuthStateInput { + redirect_path: "/studio".to_string(), + scene: WechatAuthScene::Desktop, + request_user_agent: None, + bind_user_id: None, + }, + now, + ) + .expect_err("wechat state creation must reject an unbounded projection"); + assert!(matches!(error, WechatAuthError::Store(message) if message.contains("请求过多"))); + } + + #[tokio::test] + async fn refresh_from_projection_view_merges_session_created_by_another_process() { + let source_store = InMemoryAuthStore::default(); + let user = create_phone_login_user(source_store.clone(), "13800138033").await; + let source_refresh_service = build_refresh_service(source_store.clone()); + let source_session = source_refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("remote-process-token"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + OffsetDateTime::now_utc(), + ) + .expect("source session should create"); + let projection = source_store + .export_projection_view(1) + .expect("source projection should export"); + + let local_store = InMemoryAuthStore::default(); + let local_phone_service = build_phone_service(local_store.clone()); + let local_now = OffsetDateTime::now_utc(); + local_phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now, + ) + .await + .expect("local transient phone code should send"); + local_store + .refresh_from_projection_view(projection) + .expect("local store should refresh"); + assert!( + build_refresh_service(local_store) + .is_session_active_for_user( + &user.id, + &source_session.session.session_id, + OffsetDateTime::now_utc() + Duration::minutes(1) + ) + .expect("refreshed session active check should succeed") + ); + // 刷新到数据库正式投影后,短期认证状态也以数据库快照为准;本地未同步的验证码 + // 不得继续留在工作集里,避免消费已被其他节点清理的验证码。 + local_phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now + Duration::seconds(5), + ) + .await + .expect("phone code should be resendable after authoritative refresh"); + } + + #[test] + fn conditional_projection_refresh_rejects_stale_revision() { + let store = InMemoryAuthStore::default(); + let projection = AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![], + identities: vec![], + refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], + }; + + assert_eq!(store.revision(), 0); + store + .refresh_from_projection_view(projection.clone()) + .expect("initial projection refresh should succeed"); + assert_eq!(store.revision(), 1); + assert!( + !store + .refresh_from_projection_view_if_revision(projection, 0) + .expect("stale projection refresh should be checked without error") + ); + assert_eq!(store.revision(), 1); + } + + #[tokio::test] + async fn empty_projection_restore_does_not_block_phone_login() { + let phone_service = build_phone_service(empty_projection_store()); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138032".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + let result = phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138032".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("orphan phone index should not block phone login"); + + assert!(result.created); + assert_eq!( + result.user.phone_number_masked.as_deref(), + Some("138****8032") + ); + } + + #[tokio::test] + async fn password_entry_rejects_email_or_username_identifier() { + let service = build_password_service(build_store()); + + let error = service + .execute(PasswordEntryInput { + country_code: None, + pure_phone_number: "user@example.com".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("email should fail"); + + assert_eq!(error, PasswordEntryError::InvalidPhoneNumber); + } + + #[tokio::test] + async fn password_entry_rejects_foreign_country_code() { + let service = build_password_service(build_store()); + + let error = service + .execute(PasswordEntryInput { + country_code: Some("1".to_string()), + pure_phone_number: "12025550123".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("foreign phone should fail"); + + assert_eq!(error, PasswordEntryError::UnsupportedPhoneCountryCode); + } + + #[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 { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("first phone code should send"); + + let error = service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now + Duration::seconds(10), + ) + .await + .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 authoritative_phone_code_reservation_blocks_duplicate_provider_send() { + let service = build_phone_service(build_store()); + let input = SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138001".to_string(), + scene: PhoneAuthScene::Login, + }; + let now = OffsetDateTime::now_utc(); + + service + .reserve_code_send(&input, now) + .expect("authoritative reservation should be representable locally"); + let duplicate = service + .reserve_code_send(&input, now + Duration::seconds(1)) + .expect_err("a second reservation must observe the local cooldown"); + assert!(matches!(duplicate, PhoneAuthError::SendCoolingDown { .. })); + + service + .send_code_after_authoritative_reservation(input, now) + .await + .expect("provider send should replace the reservation with the real code"); + } + + #[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 { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("login scene code should send"); + let bind_result = service.send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now + Duration::seconds(1), + ); + + assert!(bind_result.await.is_ok()); + } + + #[test] + fn random_phone_verify_code_is_six_digits() { + let code = generate_random_phone_verify_code(); + + assert_eq!(code.len(), SMS_CODE_LENGTH); + assert!(code.chars().all(|character| character.is_ascii_digit())); + } + + #[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 { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + + for attempt in 1..SMS_CODE_MAX_FAILED_ATTEMPTS { + let error = service + .login( + PhoneLoginInput { + country_code: None, + pure_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 { + country_code: None, + pure_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 { + country_code: None, + pure_phone_number: "13800138000".to_string(), + verify_code: DEFAULT_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 { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)), + ) + .await + .expect("deleted snapshot should allow a new code"); + let login = service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + verify_code: DEFAULT_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(); + let user = create_phone_login_user(store.clone(), "13800138004").await; + let refresh_service = build_refresh_service(store); + let now = OffsetDateTime::now_utc(); + let first_token_hash = hash_refresh_session_token("refresh-token-01"); + let created = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("session should create"); + + let rotated = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: first_token_hash, + next_refresh_token_hash: hash_refresh_session_token("refresh-token-02"), + }, + now + Duration::minutes(10), + ) + .expect("session should rotate"); + + assert_eq!(rotated.user.id, user.id); + assert_eq!(rotated.session.session_id, created.session.session_id); + assert_ne!( + rotated.session.refresh_token_hash, + created.session.refresh_token_hash + ); + } + + #[tokio::test] + async fn refresh_session_rejects_unknown_token_hash() { + let store = build_store(); + let refresh_service = build_refresh_service(store); + + let error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: hash_refresh_session_token("missing"), + next_refresh_token_hash: hash_refresh_session_token("next"), + }, + OffsetDateTime::now_utc(), + ) + .expect_err("unknown token should fail"); + + assert_eq!(error, RefreshSessionError::SessionNotFound); + } + + #[tokio::test] + async fn logout_current_session_revokes_only_current_session_without_token_version_bump() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138005").await; + let refresh_service = build_refresh_service(store.clone()); + let user_service = build_user_service(store); + let refresh_token_hash = hash_refresh_session_token("logout-token"); + let other_refresh_token_hash = hash_refresh_session_token("logout-token-other"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + OffsetDateTime::now_utc(), + ) + .expect("session should create"); + let other_session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: other_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + OffsetDateTime::now_utc() + Duration::seconds(1), + ) + .expect("other session should create"); + + let result = user_service + .logout_current_session( + LogoutCurrentSessionInput { + user_id: user.id.clone(), + refresh_token_hash: Some(refresh_token_hash.clone()), + session_id: None, + }, + OffsetDateTime::now_utc(), + ) + .expect("logout should succeed"); + + assert_eq!(result.user.token_version, user.token_version); + + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-token-next"), + }, + OffsetDateTime::now_utc(), + ) + .expect_err("revoked session should fail"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + assert!( + refresh_service + .is_session_active_for_user( + &user.id, + &other_session.session.session_id, + OffsetDateTime::now_utc() + Duration::minutes(2) + ) + .expect("other session active check should succeed") + ); + let rotated_other = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: other_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-token-other-next"), + }, + OffsetDateTime::now_utc() + Duration::minutes(2), + ) + .expect("other session should still rotate"); + assert_eq!(rotated_other.user.id, user.id); + } + + #[tokio::test] + async fn revoke_session_by_user_and_session_revokes_only_target_without_token_bump() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138028").await; + let refresh_service = build_refresh_service(store.clone()); + let now = OffsetDateTime::now_utc(); + let first_token_hash = hash_refresh_session_token("revoke-target-token"); + let second_token_hash = hash_refresh_session_token("revoke-current-token"); + + let target = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("target session should create"); + let current = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: second_token_hash, + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + now + Duration::seconds(1), + ) + .expect("current session should create"); + + let revoke = refresh_service + .revoke_session_by_user_and_session( + RevokeRefreshSessionByUserInput { + user_id: user.id.clone(), + session_id: target.session.session_id.clone(), + }, + now + Duration::minutes(1), + ) + .expect("target session should revoke"); + + assert!(revoke.revoked); + assert_eq!(revoke.session_id, target.session.session_id); + assert!( + !refresh_service + .is_session_active_for_user( + &user.id, + &target.session.session_id, + now + Duration::minutes(2) + ) + .expect("target active check should succeed") + ); + assert!( + refresh_service + .is_session_active_for_user( + &user.id, + ¤t.session.session_id, + now + Duration::minutes(2) + ) + .expect("current active check should succeed") + ); + assert_eq!( + store + .find_by_user_id(&user.id) + .expect("user lookup should succeed") + .expect("user should exist") + .user + .token_version, + user.token_version + ); + + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: first_token_hash, + next_refresh_token_hash: hash_refresh_session_token("revoke-target-next"), + }, + now + Duration::minutes(2), + ) + .expect_err("revoked target should not rotate"); + assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + } + + #[tokio::test] + async fn logout_current_session_uses_session_id_when_refresh_cookie_missing() { + let store = build_store(); + let user = create_phone_login_user(store.clone(), "13800138029").await; + let refresh_service = build_refresh_service(store.clone()); + let user_service = build_user_service(store); + let now = OffsetDateTime::now_utc(); + let refresh_token_hash = hash_refresh_session_token("logout-sid-token"); + let session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("session should create"); + + let result = user_service + .logout_current_session( + LogoutCurrentSessionInput { + user_id: user.id.clone(), + refresh_token_hash: None, + session_id: Some(session.session.session_id.clone()), + }, + now + Duration::minutes(1), + ) + .expect("logout should succeed"); + + assert_eq!(result.user.token_version, user.token_version); + assert!( + !refresh_service + .is_session_active_for_user( + &user.id, + &session.session.session_id, + now + Duration::minutes(2) + ) + .expect("session active check should succeed") + ); + + let refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-sid-next"), + }, + now + Duration::minutes(2), + ) + .expect_err("sid-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(); + let user = create_phone_login_user(store.clone(), "13800138006").await; + let refresh_service = build_refresh_service(store.clone()); + let user_service = build_user_service(store); + let first_refresh_token_hash = hash_refresh_session_token("logout-all-token-01"); + let second_refresh_token_hash = hash_refresh_session_token("logout-all-token-02"); + let now = OffsetDateTime::now_utc(); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: first_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("first session should create"); + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: second_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + now + Duration::seconds(1), + ) + .expect("second session should create"); + + let result = user_service + .logout_all_sessions( + LogoutAllSessionsInput { + user_id: user.id.clone(), + }, + now + Duration::minutes(1), + ) + .expect("logout all should succeed"); + + assert_eq!(result.user.token_version, 2); + assert_eq!( + refresh_service + .list_active_sessions_by_user(&user.id, now + Duration::minutes(2)) + .expect("sessions should list") + .sessions + .len(), + 0 + ); + + let first_refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: first_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-all-token-03"), + }, + now + Duration::minutes(2), + ) + .expect_err("first revoked session should fail"); + assert_eq!(first_refresh_error, RefreshSessionError::SessionNotFound); + + let second_refresh_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: second_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-all-token-04"), + }, + now + Duration::minutes(2), + ) + .expect_err("second revoked session should fail"); + assert_eq!(second_refresh_error, RefreshSessionError::SessionNotFound); + } + + #[tokio::test] + async fn list_active_sessions_by_user_filters_revoked_and_expired_sessions() { + let store = build_store(); + let refresh_service = build_refresh_service(store.clone()); + let user = create_phone_login_user(store.clone(), "13800138007").await; + let now = OffsetDateTime::now_utc(); + + let active_session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("sessions-active"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("active session should create"); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("sessions-revoked"), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "edge".to_string(), + device_display_name: "Windows / Edge".to_string(), + ..build_client_info() + }, + }, + now - Duration::minutes(5), + ) + .expect("revoked session should create"); + store + .revoke_session_by_refresh_token_hash( + &hash_refresh_session_token("sessions-revoked"), + now - Duration::minutes(1), + ) + .expect("revoked session should revoke"); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("sessions-expired"), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + now - Duration::days(40), + ) + .expect("expired session should create"); + + let listed = refresh_service + .list_active_sessions_by_user(&user.id, now) + .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].client_info.client_runtime, "chrome"); + assert_eq!( + listed.sessions[0].client_info.device_display_name, + "Windows / Chrome" + ); + } + + #[tokio::test] + async fn stale_refresh_sessions_are_pruned_from_both_indexes() { + let store = build_store(); + let refresh_service = build_refresh_service(store.clone()); + let user = create_phone_login_user(store.clone(), "13800138008").await; + let now = OffsetDateTime::now_utc(); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("stale-revoked"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now - Duration::days(2), + ) + .expect("stale session should create"); + store + .revoke_session_by_refresh_token_hash( + &hash_refresh_session_token("stale-revoked"), + now - Duration::days(2), + ) + .expect("stale session should revoke"); + + refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("recent-revoked"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + now, + ) + .expect("recent session should create"); + store + .revoke_session_by_refresh_token_hash( + &hash_refresh_session_token("recent-revoked"), + now, + ) + .expect("recent session should revoke"); + + let projection = store + .export_projection_view(now.unix_timestamp()) + .expect("projection export should prune stale sessions"); + assert_eq!(projection.refresh_sessions.len(), 1); + assert_eq!( + projection.refresh_sessions[0].refresh_token_hash, + hash_refresh_session_token("recent-revoked") + ); + + let stale_error = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: hash_refresh_session_token("stale-revoked"), + next_refresh_token_hash: hash_refresh_session_token("stale-next"), + }, + now, + ) + .expect_err("pruned session should no longer be indexed"); + assert_eq!(stale_error, RefreshSessionError::SessionNotFound); + } + + #[test] + fn projection_restore_rejects_too_many_retained_refresh_sessions() { + let client_info_json = + serde_json::to_string(&build_client_info()).expect("client info should serialize"); + let refresh_sessions = (0..=MAX_REFRESH_SESSIONS) + .map(|index| AuthStoreProjectionRefreshSession { + session_id: format!("session-{index}"), + user_id: "user_projection_cap".to_string(), + refresh_token_hash: format!("hash-{index}"), + issued_by_provider: "password".to_string(), + client_info_json: client_info_json.clone(), + expires_at: "2999-01-01T00:00:00Z".to_string(), + revoked_at: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + last_seen_at: "2026-01-01T00:00:00Z".to_string(), + }) + .collect(); + + let error = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![projection_user( + "user_projection_cap", + "projection_cap", + None, + )], + identities: vec![], + refresh_sessions, + phone_codes: vec![], + wechat_states: vec![], + }) + .expect_err("projection restore must enforce the refresh session cap"); + + assert!(error.contains("refresh session")); + assert!(error.contains(&MAX_REFRESH_SESSIONS.to_string())); + } + + #[tokio::test] + async fn wechat_login_hits_existing_user_by_union_id_before_openid() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_service = WechatAuthService::new(store); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + let phone_user = phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_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, + session_key: None, + }, + }) + .await + .expect("first wechat login should succeed"); + + assert!(first_wechat.created); + assert_eq!( + first_wechat.user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + assert_eq!(first_wechat.user.username, "微信旅人甲_wx-openid-first"); + assert_eq!( + first_wechat.user.wechat_display_name.as_deref(), + Some("微信旅人甲") + ); + assert!(first_wechat.user.id.starts_with("user_")); + assert!(!first_wechat.user.id.ends_with("00000001")); + + 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, + session_key: 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); + assert_eq!(second_wechat.user.username, first_wechat.user.username); + assert_eq!( + second_wechat.user.wechat_display_name.as_deref(), + Some("微信旅人乙") + ); + } + + #[tokio::test] + async fn bind_wechat_phone_merges_pending_wechat_user_into_existing_phone_user() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_service = WechatAuthService::new(store.clone()); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone login code should send"); + let phone_user = phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_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, + session_key: None, + }, + }) + .await + .expect("wechat login should succeed") + .user; + + assert_eq!( + wechat_user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + assert_eq!( + wechat_user.wechat_display_name.as_deref(), + Some("待绑定微信用户") + ); + assert_ne!(wechat_user.id, phone_user.id); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138000".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now + Duration::seconds(2), + ) + .await + .expect("bind phone code should send"); + let merged = phone_service + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: wechat_user.id.clone(), + country_code: None, + pure_phone_number: "13800138000".to_string(), + verify_code: "123456".to_string(), + wechat_display_name: None, + }, + 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); + assert_eq!( + merged.user.wechat_display_name.as_deref(), + Some("待绑定微信用户") + ); + assert_eq!(merged.user.display_name, "待绑定微信用户"); + + 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, + session_key: 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); + assert_eq!( + reused_wechat_user.user.wechat_display_name.as_deref(), + Some("已归并微信用户") + ); + assert_eq!(reused_wechat_user.user.display_name, "已归并微信用户"); + } + + #[tokio::test] + async fn bind_wechat_verified_phone_rejects_foreign_country_before_account_merge() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_user = WechatAuthService::new(store.clone()) + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-foreign-phone".to_string(), + provider_union_id: None, + display_name: Some("境外手机号用户".to_string()), + avatar_url: None, + session_key: None, + }, + }) + .await + .expect("wechat login should succeed") + .user; + + let error = phone_service + .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { + user_id: wechat_user.id.clone(), + country_code: "1".to_string(), + pure_phone_number: "12025550123".to_string(), + wechat_display_name: None, + }) + .await + .expect_err("foreign country code must fail before account merge"); + + assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); + let unchanged_user = store + .find_by_user_id(&wechat_user.id) + .expect("user lookup should succeed") + .expect("pending wechat user should remain"); + assert_eq!( + unchanged_user.user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + assert!(unchanged_user.user.phone_number.is_none()); + } + + #[tokio::test] + async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { + let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { + base_updated_at_micros: 0, + updated_at_micros: 1, + users: vec![projection_user( + "user_existing_phone_bind", + "phone_existing_bind", + Some("+8613800138032"), + )], + identities: vec![], + refresh_sessions: vec![], + phone_codes: vec![], + wechat_states: vec![], + }) + .expect("projection should restore"); + let phone_service = build_phone_service(store.clone()); + let wechat_service = WechatAuthService::new(store); + let now = OffsetDateTime::now_utc(); + let wechat_user = wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-existing-phone-bind".to_string(), + provider_union_id: Some("wx-union-existing-phone-bind".to_string()), + display_name: Some("待绑定微信用户".to_string()), + avatar_url: None, + session_key: None, + }, + }) + .await + .expect("wechat login should succeed") + .user; + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138032".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now, + ) + .await + .expect("bind phone code should send"); + let merged = phone_service + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: wechat_user.id, + country_code: None, + pure_phone_number: "13800138032".to_string(), + verify_code: "123456".to_string(), + wechat_display_name: None, + }, + now + Duration::seconds(1), + ) + .await + .expect("bind phone should merge existing phone user"); + + assert_eq!(merged.user.id, "user_existing_phone_bind"); + assert!(merged.user.wechat_bound); + assert_eq!( + wechat_service + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-existing-phone-bind".to_string(), + provider_union_id: Some("wx-union-existing-phone-bind".to_string()), + display_name: Some("已归并微信用户".to_string()), + avatar_url: None, + session_key: None, + }, + }) + .await + .expect("wechat login should reuse merged user") + .user + .id, + "user_existing_phone_bind" + ); + } + + #[tokio::test] + async fn bind_wechat_phone_uses_submitted_display_name_when_identity_has_no_display_name() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_service = WechatAuthService::new(store.clone()); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138031".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone login code should send"); + let phone_user = phone_service + .login( + PhoneLoginInput { + country_code: None, + pure_phone_number: "13800138031".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-mini-bind".to_string(), + provider_union_id: Some("wx-union-mini-bind".to_string()), + display_name: None, + avatar_url: None, + session_key: Some("mini-session-key".to_string()), + }, + }) + .await + .expect("mini program wechat login should succeed") + .user; + + assert_eq!(wechat_user.wechat_display_name, None); + assert_eq!( + wechat_user.wechat_account.as_deref(), + Some("wx-openid-mini-bind") + ); + assert_ne!(wechat_user.id, phone_user.id); + + phone_service + .send_code( + SendPhoneCodeInput { + country_code: None, + pure_phone_number: "13800138031".to_string(), + scene: PhoneAuthScene::BindPhone, + }, + now + Duration::seconds(2), + ) + .await + .expect("bind phone code should send"); + let merged = phone_service + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: wechat_user.id.clone(), + country_code: None, + pure_phone_number: "13800138031".to_string(), + verify_code: "123456".to_string(), + wechat_display_name: Some("补填微信昵称".to_string()), + }, + now + Duration::seconds(3), + ) + .await + .expect("bind phone should succeed"); + + assert_eq!(merged.user.id, phone_user.id); + assert!(merged.user.wechat_bound); + assert_eq!(merged.user.display_name, "补填微信昵称"); + assert_eq!( + merged.user.wechat_display_name.as_deref(), + Some("补填微信昵称") + ); + assert_eq!( + merged.user.wechat_account.as_deref(), + Some("wx-openid-mini-bind") + ); + + let restored_user = build_password_service(store) + .get_user_by_id(&phone_user.id) + .expect("user lookup should succeed") + .expect("merged user should exist") + .user; + assert_eq!(restored_user.display_name, "补填微信昵称"); + assert_eq!( + restored_user.wechat_display_name.as_deref(), + Some("补填微信昵称") + ); + assert_eq!( + restored_user.wechat_account.as_deref(), + Some("wx-openid-mini-bind") + ); + } +} diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 51df31e98..d0a1fcbe8 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -72,6 +72,8 @@ const MAIN_SPA_PATHS: &[&str] = &[ "/games/mine", "/games/play", "/games/publish", + "/creators", + "/creators/connections", "/pay", "/profile", "/profile/payment", diff --git a/server-rs/crates/shared-contracts/src/creator.rs b/server-rs/crates/shared-contracts/src/creator.rs new file mode 100644 index 000000000..3f36e86c2 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/creator.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreatorUser { + pub id: String, + pub public_user_code: String, + pub display_name: String, + pub avatar_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreatorRelationship { + pub is_self: bool, + pub is_following: bool, + pub is_followed_by: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreatorProfile { + pub user: CreatorUser, + pub following_count: u64, + pub follower_count: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreatorConnection { + pub user: CreatorUser, + pub followed_at: String, + pub relationship: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreatorConnections { + pub items: Vec, + pub next_cursor: Option, + pub total: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CreatorRelationshipResponse { + pub user_id: String, + pub relationship: CreatorRelationship, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn creator_public_list_contract_has_explicit_nulls_and_only_public_fields() { + let list = CreatorConnections { + items: vec![CreatorConnection { + user: CreatorUser { + id: "b".into(), + public_user_code: "SY-2".into(), + display_name: "作者乙".into(), + avatar_url: None, + }, + followed_at: "2026-10-05T00:00:00Z".into(), + relationship: None, + }], + next_cursor: None, + total: 1, + }; + let expected = json!({"items":[{"user":{"id":"b","publicUserCode":"SY-2","displayName":"作者乙","avatarUrl":null},"followedAt":"2026-10-05T00:00:00Z","relationship":null}],"nextCursor":null,"total":1}); + assert_eq!(serde_json::to_value(&list).unwrap(), expected); + assert_eq!( + serde_json::from_value::(expected).unwrap(), + list + ); + } + + #[test] + fn creator_remove_follower_receipt_keeps_outgoing_direction() { + let response = CreatorRelationshipResponse { + user_id: "b".into(), + relationship: CreatorRelationship { + is_self: false, + is_following: true, + is_followed_by: false, + }, + }; + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({"userId":"b","relationship":{"isSelf":false,"isFollowing":true,"isFollowedBy":false}}) + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index ed92100fb..3ef622bf1 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -8,6 +8,7 @@ pub mod assets; pub mod auth; pub mod client_downloads; pub mod creation_audio; +pub mod creator; pub mod editor_agent; pub mod editor_generation; pub mod error_reports; diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 7c706f083..2538df05b 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -10,7 +10,7 @@ path = "src/active.rs" [dependencies] module-ai = { workspace = true } module-assets = { workspace = true } -module-auth = { workspace = true } +module-auth = { workspace = true, features = ["services"] } module-runtime = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 4f387f1c6..391474c2b 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -15,6 +15,7 @@ pub mod agc_models; pub mod ai; pub mod assets; pub mod auth; +pub mod creator; pub mod editor_agent; pub mod editor_project; #[path = "active/error_reports.rs"] diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 07b1a9cf8..e8b18529f 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -10,6 +10,8 @@ mod ai; mod assets; #[path = "mapper/auth.rs"] mod auth; +#[path = "mapper/creator.rs"] +pub(crate) mod creator; #[path = "mapper/editor_agent.rs"] mod editor_agent; #[path = "mapper/editor_project.rs"] diff --git a/server-rs/crates/spacetime-client/src/active/mapper/creator.rs b/server-rs/crates/spacetime-client/src/active/mapper/creator.rs new file mode 100644 index 000000000..38831b6d6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/active/mapper/creator.rs @@ -0,0 +1,120 @@ +use crate::{SpacetimeClientError, module_bindings as b}; +use shared_contracts::creator as c; + +fn result(data: Option, error: Option) -> Result { + if error.is_some() { + return Err(SpacetimeClientError::procedure_failed(error)); + } + data.ok_or_else(|| SpacetimeClientError::procedure_failed(Some("创作者查询缺少结果".into()))) +} + +fn user(value: b::CreatorUserView) -> c::CreatorUser { + c::CreatorUser { + id: value.id, + public_user_code: value.public_user_code, + display_name: value.display_name, + avatar_url: value.avatar_url, + } +} +fn relationship(value: b::CreatorRelationshipView) -> c::CreatorRelationship { + c::CreatorRelationship { + is_self: value.is_self, + is_following: value.is_following, + is_followed_by: value.is_followed_by, + } +} +pub(crate) fn profile( + value: b::CreatorProfileResult, +) -> Result { + let value = result(value.profile, value.error_code)?; + Ok(c::CreatorProfile { + user: user(value.user), + following_count: value.following_count, + follower_count: value.follower_count, + }) +} +pub(crate) fn connections( + value: b::CreatorConnectionsResult, +) -> Result { + let value = result(value.connections, value.error_code)?; + Ok(c::CreatorConnections { + items: value + .items + .into_iter() + .map(|row| c::CreatorConnection { + user: user(row.user), + followed_at: row.followed_at, + relationship: row.relationship.map(relationship), + }) + .collect(), + next_cursor: value.next_cursor, + total: value.total, + }) +} +pub(crate) fn relationship_response( + value: b::CreatorRelationshipResult, +) -> Result { + let value = result(value.record, value.error_code)?; + Ok(c::CreatorRelationshipResponse { + user_id: value.user_id, + relationship: relationship(value.relationship), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn creator_mapper_does_not_convert_errors_to_empty_success() { + assert!( + profile(b::CreatorProfileResult { + profile: None, + error_code: None + }) + .is_err() + ); + assert!( + connections(b::CreatorConnectionsResult { + connections: None, + error_code: Some("forbidden".into()) + }) + .is_err() + ); + } + #[test] + fn creator_mapper_preserves_readonly_rows_and_incoming_removal() { + let mapped = connections(b::CreatorConnectionsResult { + connections: Some(b::CreatorConnectionsView { + items: vec![b::CreatorConnectionView { + user: b::CreatorUserView { + id: "b".into(), + public_user_code: "SY-2".into(), + display_name: "作者".into(), + avatar_url: None, + }, + followed_at: "2026-10-05T00:00:00Z".into(), + relationship: None, + }], + total: 1, + next_cursor: None, + }), + error_code: None, + }) + .unwrap(); + assert_eq!(mapped.items[0].relationship, None); + let mapped = relationship_response(b::CreatorRelationshipResult { + record: Some(b::CreatorRelationshipRecord { + user_id: "b".into(), + relationship: b::CreatorRelationshipView { + is_self: false, + is_following: true, + is_followed_by: false, + }, + }), + error_code: None, + }) + .unwrap(); + assert!(mapped.relationship.is_following); + assert!(!mapped.relationship.is_followed_by); + } +} diff --git a/server-rs/crates/spacetime-client/src/creator.rs b/server-rs/crates/spacetime-client/src/creator.rs new file mode 100644 index 000000000..518a40ccb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/creator.rs @@ -0,0 +1,110 @@ +use super::*; +use crate::mapper::creator as map; +use shared_contracts::creator as c; + +#[derive(Clone, Debug)] +pub struct CreatorListInput { + pub owner_id: String, + pub viewer_id: Option, + pub kind: module_auth::creator::ConnectionKind, + pub limit: u32, + pub cursor: Option, +} + +impl SpacetimeClient { + pub async fn creator_profile( + &self, + user_id: String, + ) -> Result { + self.call_after_connect("creator_profile", move |connection, sender| { + connection.procedures().get_creator_profile_and_return_then( + user_id, + move |_, result| { + send_once( + &sender, + result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map::profile), + ); + }, + ); + }) + .await + } + + pub async fn creator_connections( + &self, + input: CreatorListInput, + ) -> Result { + let input = module_bindings::CreatorConnectionsInput { + owner_id: input.owner_id, + viewer_id: input.viewer_id, + kind: match input.kind { + module_auth::creator::ConnectionKind::Following => "following", + module_auth::creator::ConnectionKind::Followers => "followers", + } + .into(), + limit: input.limit, + cursor: input.cursor, + }; + self.call_after_connect("creator_connections", move |connection, sender| { + connection + .procedures() + .list_creator_connections_and_return_then(input, move |_, result| { + send_once( + &sender, + result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map::connections), + ); + }); + }) + .await + } + + pub async fn creator_relationship( + &self, + actor_id: String, + target_id: String, + ) -> Result { + let input = module_bindings::CreatorRelationshipInput { + actor_id, + target_id, + }; + self.call_after_connect("creator_relationship", move |connection, sender| { + connection + .procedures() + .get_creator_relationship_and_return_then(input, move |_, result| { + send_once( + &sender, + result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map::relationship_response), + ); + }); + }) + .await + } + + pub async fn mutate_creator_relationship( + &self, + actor_id: String, + target_id: String, + action: module_auth::creator::CreatorAction, + ) -> Result { + let input = module_bindings::CreatorRelationshipInput { + actor_id, + target_id, + }; + self.call_after_connect("mutate_creator_relationship", move |connection, sender| { + let callback = move |_: &module_bindings::ProcedureEventContext, result: Result| { + send_once(&sender, result.map_err(SpacetimeClientError::from_sdk_error).and_then(map::relationship_response)); + }; + match action { + module_auth::creator::CreatorAction::Follow => connection.procedures().follow_creator_and_return_then(input, callback), + module_auth::creator::CreatorAction::Unfollow => connection.procedures().unfollow_creator_and_return_then(input, callback), + module_auth::creator::CreatorAction::RemoveFollower => connection.procedures().remove_creator_follower_and_return_then(input, callback), + } + }).await + } +} diff --git a/server-rs/crates/spacetime-client/src/game_distribution.rs b/server-rs/crates/spacetime-client/src/game_distribution.rs index bdea2bc5f..5d31e3f0b 100644 --- a/server-rs/crates/spacetime-client/src/game_distribution.rs +++ b/server-rs/crates/spacetime-client/src/game_distribution.rs @@ -32,6 +32,7 @@ pub struct GameDistributionPublicGameListRecordInput { pub search: Option, pub category: Option, pub limit: u32, + pub author_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -531,6 +532,7 @@ impl SpacetimeClient { search: input.search, category: input.category, limit: input.limit, + author_id: input.author_id, }; self.call_after_connect("list_game_distribution_games", move |connection, sender| { connection diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index ffce9b440..25644441d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -180,6 +180,17 @@ pub mod creation_entry_config_table; pub mod creation_entry_config_type; pub mod creation_entry_type_config_table; pub mod creation_entry_type_config_type; +pub mod creator_connection_view_type; +pub mod creator_connections_input_type; +pub mod creator_connections_result_type; +pub mod creator_connections_view_type; +pub mod creator_profile_result_type; +pub mod creator_profile_view_type; +pub mod creator_relationship_input_type; +pub mod creator_relationship_record_type; +pub mod creator_relationship_result_type; +pub mod creator_relationship_view_type; +pub mod creator_user_view_type; pub mod database_migration_authorize_operator_input_type; pub mod database_migration_export_input_type; pub mod database_migration_import_chunk_input_type; @@ -407,6 +418,7 @@ pub mod feature_gate_config_snapshot_type; pub mod feature_gate_config_table; pub mod feature_gate_config_type; pub mod find_editor_asset_group_source_and_return_procedure; +pub mod follow_creator_and_return_procedure; pub mod game_distribution_admin_game_list_input_type; pub mod game_distribution_admin_game_list_result_type; pub mod game_distribution_admin_game_snapshot_type; @@ -472,6 +484,8 @@ pub mod get_admin_game_distribution_user_review_and_return_procedure; pub mod get_asset_object_by_id_and_return_procedure; pub mod get_asset_object_by_location_and_return_procedure; pub mod get_asset_read_access_by_location_and_return_procedure; +pub mod get_creator_profile_and_return_procedure; +pub mod get_creator_relationship_and_return_procedure; pub mod get_editor_agent_conversation_and_return_procedure; pub mod get_editor_asset_folder_and_return_procedure; pub mod get_editor_asset_library_and_return_procedure; @@ -513,6 +527,7 @@ pub mod list_admin_game_distribution_games_and_return_procedure; pub mod list_admin_game_distribution_user_reviews_and_return_procedure; pub mod list_agc_tracking_events_procedure; pub mod list_asset_history_and_return_procedure; +pub mod list_creator_connections_and_return_procedure; pub mod list_editor_agent_conversations_and_return_procedure; pub mod list_editor_projects_and_return_procedure; pub mod list_error_reports_and_return_procedure; @@ -669,6 +684,7 @@ pub mod refresh_session_type; pub mod refund_profile_wallet_points_and_return_procedure; pub mod reject_game_distribution_version_and_return_procedure; pub mod release_profile_recharge_refund_hold_and_return_procedure; +pub mod remove_creator_follower_and_return_procedure; pub mod rename_editor_project_and_return_procedure; pub mod renew_external_generation_job_lease_and_return_procedure; pub mod repair_editor_asset_media_and_return_procedure; @@ -845,6 +861,7 @@ pub mod tracking_daily_stat_table; pub mod tracking_daily_stat_type; pub mod tracking_event_table; pub mod tracking_event_type; +pub mod unfollow_creator_and_return_procedure; pub mod unpublish_game_distribution_game_and_return_procedure; pub mod update_admin_account_and_return_procedure; pub mod update_editor_asset_and_return_procedure; @@ -865,6 +882,8 @@ pub mod user_account_table; pub mod user_account_type; pub mod user_browse_history_table; pub mod user_browse_history_type; +pub mod user_follow_table; +pub mod user_follow_type; pub mod validate_auth_session_procedure; pub use acknowledge_external_generation_job_summaries_and_return_procedure::acknowledge_external_generation_job_summaries_and_return; @@ -1041,6 +1060,17 @@ pub use creation_entry_config_table::*; pub use creation_entry_config_type::CreationEntryConfig; pub use creation_entry_type_config_table::*; pub use creation_entry_type_config_type::CreationEntryTypeConfig; +pub use creator_connection_view_type::CreatorConnectionView; +pub use creator_connections_input_type::CreatorConnectionsInput; +pub use creator_connections_result_type::CreatorConnectionsResult; +pub use creator_connections_view_type::CreatorConnectionsView; +pub use creator_profile_result_type::CreatorProfileResult; +pub use creator_profile_view_type::CreatorProfileView; +pub use creator_relationship_input_type::CreatorRelationshipInput; +pub use creator_relationship_record_type::CreatorRelationshipRecord; +pub use creator_relationship_result_type::CreatorRelationshipResult; +pub use creator_relationship_view_type::CreatorRelationshipView; +pub use creator_user_view_type::CreatorUserView; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; pub use database_migration_export_input_type::DatabaseMigrationExportInput; pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; @@ -1268,6 +1298,7 @@ pub use feature_gate_config_snapshot_type::FeatureGateConfigSnapshot; pub use feature_gate_config_table::*; pub use feature_gate_config_type::FeatureGateConfig; pub use find_editor_asset_group_source_and_return_procedure::find_editor_asset_group_source_and_return; +pub use follow_creator_and_return_procedure::follow_creator_and_return; pub use game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput; pub use game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult; pub use game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot; @@ -1333,6 +1364,8 @@ pub use get_admin_game_distribution_user_review_and_return_procedure::get_admin_ pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_return; pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return; +pub use get_creator_profile_and_return_procedure::get_creator_profile_and_return; +pub use get_creator_relationship_and_return_procedure::get_creator_relationship_and_return; pub use get_editor_agent_conversation_and_return_procedure::get_editor_agent_conversation_and_return; pub use get_editor_asset_folder_and_return_procedure::get_editor_asset_folder_and_return; pub use get_editor_asset_library_and_return_procedure::get_editor_asset_library_and_return; @@ -1374,6 +1407,7 @@ pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game pub use list_admin_game_distribution_user_reviews_and_return_procedure::list_admin_game_distribution_user_reviews_and_return; pub use list_agc_tracking_events_procedure::list_agc_tracking_events; pub use list_asset_history_and_return_procedure::list_asset_history_and_return; +pub use list_creator_connections_and_return_procedure::list_creator_connections_and_return; pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return; pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return; pub use list_error_reports_and_return_procedure::list_error_reports_and_return; @@ -1530,6 +1564,7 @@ pub use refresh_session_type::RefreshSession; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; pub use reject_game_distribution_version_and_return_procedure::reject_game_distribution_version_and_return; pub use release_profile_recharge_refund_hold_and_return_procedure::release_profile_recharge_refund_hold_and_return; +pub use remove_creator_follower_and_return_procedure::remove_creator_follower_and_return; pub use rename_editor_project_and_return_procedure::rename_editor_project_and_return; pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return; pub use repair_editor_asset_media_and_return_procedure::repair_editor_asset_media_and_return; @@ -1706,6 +1741,7 @@ pub use tracking_daily_stat_table::*; pub use tracking_daily_stat_type::TrackingDailyStat; pub use tracking_event_table::*; pub use tracking_event_type::TrackingEvent; +pub use unfollow_creator_and_return_procedure::unfollow_creator_and_return; pub use unpublish_game_distribution_game_and_return_procedure::unpublish_game_distribution_game_and_return; pub use update_admin_account_and_return_procedure::update_admin_account_and_return; pub use update_editor_asset_and_return_procedure::update_editor_asset_and_return; @@ -1726,6 +1762,8 @@ pub use user_account_table::*; pub use user_account_type::UserAccount; pub use user_browse_history_table::*; pub use user_browse_history_type::UserBrowseHistory; +pub use user_follow_table::*; +pub use user_follow_type::UserFollow; pub use validate_auth_session_procedure::validate_auth_session; #[derive(Clone, PartialEq, Debug)] @@ -1933,6 +1971,7 @@ pub struct DbUpdate { tracking_event: __sdk::TableUpdate, user_account: __sdk::TableUpdate, user_browse_history: __sdk::TableUpdate, + user_follow: __sdk::TableUpdate, } impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { @@ -2272,6 +2311,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "user_browse_history" => db_update .user_browse_history .append(user_browse_history_table::parse_table_update(table_update)?), + "user_follow" => db_update + .user_follow + .append(user_follow_table::parse_table_update(table_update)?), unknown => { return Err(__sdk::InternalError::unknown_name( @@ -2765,6 +2807,9 @@ impl __sdk::DbUpdate for DbUpdate { &self.user_browse_history, ) .with_updates_by_pk(|row| &row.browse_history_id); + diff.user_follow = cache + .apply_diff_to_table::("user_follow", &self.user_follow) + .with_updates_by_pk(|row| &row.relationship_id); diff } @@ -3045,6 +3090,9 @@ impl __sdk::DbUpdate for DbUpdate { "user_browse_history" => db_update .user_browse_history .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "user_follow" => db_update + .user_follow + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -3331,6 +3379,9 @@ impl __sdk::DbUpdate for DbUpdate { "user_browse_history" => db_update .user_browse_history .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "user_follow" => db_update + .user_follow + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -3446,6 +3497,7 @@ pub struct AppliedDiff<'r> { tracking_event: __sdk::TableAppliedDiff<'r, TrackingEvent>, user_account: __sdk::TableAppliedDiff<'r, UserAccount>, user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>, + user_follow: __sdk::TableAppliedDiff<'r, UserFollow>, __unused: std::marker::PhantomData<&'r ()>, } @@ -3902,6 +3954,7 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.user_browse_history, event, ); + callbacks.invoke_table_row_callbacks::("user_follow", &self.user_follow, event); } } @@ -4653,6 +4706,7 @@ impl __sdk::SpacetimeModule for RemoteModule { tracking_event_table::register_table(client_cache); user_account_table::register_table(client_cache); user_browse_history_table::register_table(client_cache); + user_follow_table::register_table(client_cache); } const ALL_TABLE_NAMES: &'static [&'static str] = &[ "admin_account", @@ -4746,5 +4800,6 @@ impl __sdk::SpacetimeModule for RemoteModule { "tracking_event", "user_account", "user_browse_history", + "user_follow", ]; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_connection_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_connection_view_type.rs new file mode 100644 index 000000000..426f2404a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_connection_view_type.rs @@ -0,0 +1,20 @@ +// 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::creator_relationship_view_type::CreatorRelationshipView; +use super::creator_user_view_type::CreatorUserView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorConnectionView { + pub user: CreatorUserView, + pub followed_at: String, + pub relationship: Option, +} + +impl __sdk::InModule for CreatorConnectionView { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_input_type.rs new file mode 100644 index 000000000..9c1bcd5b7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_input_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorConnectionsInput { + pub owner_id: String, + pub viewer_id: Option, + pub kind: String, + pub limit: u32, + pub cursor: Option, +} + +impl __sdk::InModule for CreatorConnectionsInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_result_type.rs new file mode 100644 index 000000000..9a6f77a71 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_result_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}; + +use super::creator_connections_view_type::CreatorConnectionsView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorConnectionsResult { + pub connections: Option, + pub error_code: Option, +} + +impl __sdk::InModule for CreatorConnectionsResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_view_type.rs new file mode 100644 index 000000000..b9957d1e7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_connections_view_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::creator_connection_view_type::CreatorConnectionView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorConnectionsView { + pub items: Vec, + pub next_cursor: Option, + pub total: u64, +} + +impl __sdk::InModule for CreatorConnectionsView { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_profile_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_profile_result_type.rs new file mode 100644 index 000000000..d22cffd1b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_profile_result_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}; + +use super::creator_profile_view_type::CreatorProfileView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorProfileResult { + pub profile: Option, + pub error_code: Option, +} + +impl __sdk::InModule for CreatorProfileResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_profile_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_profile_view_type.rs new file mode 100644 index 000000000..6d0bd49f1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_profile_view_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::creator_user_view_type::CreatorUserView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorProfileView { + pub user: CreatorUserView, + pub following_count: u64, + pub follower_count: u64, +} + +impl __sdk::InModule for CreatorProfileView { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_input_type.rs new file mode 100644 index 000000000..5c8b0927b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_input_type.rs @@ -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. + +#![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 CreatorRelationshipInput { + pub actor_id: String, + pub target_id: String, +} + +impl __sdk::InModule for CreatorRelationshipInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_record_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_record_type.rs new file mode 100644 index 000000000..97a11b057 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_record_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}; + +use super::creator_relationship_view_type::CreatorRelationshipView; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorRelationshipRecord { + pub user_id: String, + pub relationship: CreatorRelationshipView, +} + +impl __sdk::InModule for CreatorRelationshipRecord { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_result_type.rs new file mode 100644 index 000000000..f27d94800 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_result_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}; + +use super::creator_relationship_record_type::CreatorRelationshipRecord; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CreatorRelationshipResult { + pub record: Option, + pub error_code: Option, +} + +impl __sdk::InModule for CreatorRelationshipResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_view_type.rs new file mode 100644 index 000000000..97e7da13f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_relationship_view_type.rs @@ -0,0 +1,17 @@ +// 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 CreatorRelationshipView { + pub is_self: bool, + pub is_following: bool, + pub is_followed_by: bool, +} + +impl __sdk::InModule for CreatorRelationshipView { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/creator_user_view_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/creator_user_view_type.rs new file mode 100644 index 000000000..8e0b67e9d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/creator_user_view_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)] +pub struct CreatorUserView { + pub id: String, + pub public_user_code: String, + pub display_name: String, + pub avatar_url: Option, +} + +impl __sdk::InModule for CreatorUserView { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/follow_creator_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/follow_creator_and_return_procedure.rs new file mode 100644 index 000000000..58a53fda3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/follow_creator_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::creator_relationship_input_type::CreatorRelationshipInput; +use super::creator_relationship_result_type::CreatorRelationshipResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FollowCreatorAndReturnArgs { + pub input: CreatorRelationshipInput, +} + +impl __sdk::InModule for FollowCreatorAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `follow_creator_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait follow_creator_and_return { + fn follow_creator_and_return(&self, input: CreatorRelationshipInput) { + self.follow_creator_and_return_then(input, |_, _| {}); + } + + fn follow_creator_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl follow_creator_and_return for super::RemoteProcedures { + fn follow_creator_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreatorRelationshipResult>( + "follow_creator_and_return", + FollowCreatorAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs index 1d02c17ab..c7b8add29 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/game_distribution_public_game_list_input_type.rs @@ -10,6 +10,7 @@ pub struct GameDistributionPublicGameListInput { pub search: Option, pub category: Option, pub limit: u32, + pub author_id: Option, } impl __sdk::InModule for GameDistributionPublicGameListInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_creator_profile_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_creator_profile_and_return_procedure.rs new file mode 100644 index 000000000..c15318f03 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_creator_profile_and_return_procedure.rs @@ -0,0 +1,58 @@ +// 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::creator_profile_result_type::CreatorProfileResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCreatorProfileAndReturnArgs { + pub user_id: String, +} + +impl __sdk::InModule for GetCreatorProfileAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_creator_profile_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_creator_profile_and_return { + fn get_creator_profile_and_return(&self, user_id: String) { + self.get_creator_profile_and_return_then(user_id, |_, _| {}); + } + + fn get_creator_profile_and_return_then( + &self, + user_id: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_creator_profile_and_return for super::RemoteProcedures { + fn get_creator_profile_and_return_then( + &self, + user_id: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreatorProfileResult>( + "get_creator_profile_and_return", + GetCreatorProfileAndReturnArgs { user_id }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_creator_relationship_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_creator_relationship_and_return_procedure.rs new file mode 100644 index 000000000..4239ab6c1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_creator_relationship_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::creator_relationship_input_type::CreatorRelationshipInput; +use super::creator_relationship_result_type::CreatorRelationshipResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetCreatorRelationshipAndReturnArgs { + pub input: CreatorRelationshipInput, +} + +impl __sdk::InModule for GetCreatorRelationshipAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_creator_relationship_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_creator_relationship_and_return { + fn get_creator_relationship_and_return(&self, input: CreatorRelationshipInput) { + self.get_creator_relationship_and_return_then(input, |_, _| {}); + } + + fn get_creator_relationship_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_creator_relationship_and_return for super::RemoteProcedures { + fn get_creator_relationship_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreatorRelationshipResult>( + "get_creator_relationship_and_return", + GetCreatorRelationshipAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_creator_connections_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_creator_connections_and_return_procedure.rs new file mode 100644 index 000000000..5df000f5e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_creator_connections_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::creator_connections_input_type::CreatorConnectionsInput; +use super::creator_connections_result_type::CreatorConnectionsResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListCreatorConnectionsAndReturnArgs { + pub input: CreatorConnectionsInput, +} + +impl __sdk::InModule for ListCreatorConnectionsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_creator_connections_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_creator_connections_and_return { + fn list_creator_connections_and_return(&self, input: CreatorConnectionsInput) { + self.list_creator_connections_and_return_then(input, |_, _| {}); + } + + fn list_creator_connections_and_return_then( + &self, + input: CreatorConnectionsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_creator_connections_and_return for super::RemoteProcedures { + fn list_creator_connections_and_return_then( + &self, + input: CreatorConnectionsInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreatorConnectionsResult>( + "list_creator_connections_and_return", + ListCreatorConnectionsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/remove_creator_follower_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/remove_creator_follower_and_return_procedure.rs new file mode 100644 index 000000000..e9d51e5f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/remove_creator_follower_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::creator_relationship_input_type::CreatorRelationshipInput; +use super::creator_relationship_result_type::CreatorRelationshipResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RemoveCreatorFollowerAndReturnArgs { + pub input: CreatorRelationshipInput, +} + +impl __sdk::InModule for RemoveCreatorFollowerAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `remove_creator_follower_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait remove_creator_follower_and_return { + fn remove_creator_follower_and_return(&self, input: CreatorRelationshipInput) { + self.remove_creator_follower_and_return_then(input, |_, _| {}); + } + + fn remove_creator_follower_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl remove_creator_follower_and_return for super::RemoteProcedures { + fn remove_creator_follower_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreatorRelationshipResult>( + "remove_creator_follower_and_return", + RemoveCreatorFollowerAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/unfollow_creator_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/unfollow_creator_and_return_procedure.rs new file mode 100644 index 000000000..5a9045062 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/unfollow_creator_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::creator_relationship_input_type::CreatorRelationshipInput; +use super::creator_relationship_result_type::CreatorRelationshipResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UnfollowCreatorAndReturnArgs { + pub input: CreatorRelationshipInput, +} + +impl __sdk::InModule for UnfollowCreatorAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `unfollow_creator_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait unfollow_creator_and_return { + fn unfollow_creator_and_return(&self, input: CreatorRelationshipInput) { + self.unfollow_creator_and_return_then(input, |_, _| {}); + } + + fn unfollow_creator_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl unfollow_creator_and_return for super::RemoteProcedures { + fn unfollow_creator_and_return_then( + &self, + input: CreatorRelationshipInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, CreatorRelationshipResult>( + "unfollow_creator_and_return", + UnfollowCreatorAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_follow_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_follow_table.rs new file mode 100644 index 000000000..21ce094c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_follow_table.rs @@ -0,0 +1,228 @@ +// 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 super::user_follow_type::UserFollow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `user_follow`. +/// +/// Obtain a handle from the [`UserFollowTableAccess::user_follow`] method on [`super::RemoteTables`], +/// like `ctx.db.user_follow()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.user_follow().on_insert(...)`. +pub struct UserFollowTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `user_follow`. +pub struct UserFollowTableAccessor; + +impl __sdk::TableAccessor for UserFollowTableAccessor { + type Row = UserFollow; + type Handle<'db> = UserFollowTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.user_follow() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `user_follow`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait UserFollowTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`UserFollowTableHandle`], which mediates access to the table `user_follow`. + fn user_follow(&self) -> UserFollowTableHandle<'_>; +} + +impl UserFollowTableAccess for super::RemoteTables { + fn user_follow(&self) -> UserFollowTableHandle<'_> { + UserFollowTableHandle { + imp: self.imp.get_table::("user_follow"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct UserFollowInsertCallbackId(__sdk::CallbackId); +pub struct UserFollowDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for UserFollowTableHandle<'ctx> { + type Row = UserFollow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for UserFollowTableHandle<'ctx> { + type Row = UserFollow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = UserFollowInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> UserFollowInsertCallbackId { + UserFollowInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: UserFollowInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = UserFollowDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> UserFollowDeleteCallbackId { + UserFollowDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: UserFollowDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for UserFollowTableHandle<'ctx> { + type InsertCallbackId = UserFollowInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> UserFollowInsertCallbackId { + UserFollowInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: UserFollowInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for UserFollowTableHandle<'ctx> { + type DeleteCallbackId = UserFollowDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> UserFollowDeleteCallbackId { + UserFollowDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: UserFollowDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct UserFollowUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for UserFollowTableHandle<'ctx> { + type UpdateCallbackId = UserFollowUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> UserFollowUpdateCallbackId { + UserFollowUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: UserFollowUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for UserFollowTableHandle<'ctx> { + type UpdateCallbackId = UserFollowUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> UserFollowUpdateCallbackId { + UserFollowUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: UserFollowUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `relationship_id` unique index on the table `user_follow`, +/// which allows point queries on the field of the same name +/// via the [`UserFollowRelationshipIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.user_follow().relationship_id().find(...)`. +pub struct UserFollowRelationshipIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> UserFollowTableHandle<'ctx> { + /// Get a handle on the `relationship_id` unique index on the table `user_follow`. + pub fn relationship_id(&self) -> UserFollowRelationshipIdUnique<'ctx> { + UserFollowRelationshipIdUnique { + imp: self.imp.get_unique_constraint::("relationship_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> UserFollowRelationshipIdUnique<'ctx> { + /// Find the subscribed row whose `relationship_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("user_follow"); + _table.add_unique_constraint::("relationship_id", |row| &row.relationship_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `UserFollow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait user_followQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `UserFollow`. + fn user_follow(&self) -> __sdk::__query_builder::Table; +} + +impl user_followQueryTableAccess for __sdk::QueryTableAccessor { + fn user_follow(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("user_follow") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_follow_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_follow_type.rs new file mode 100644 index 000000000..5478feab3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_follow_type.rs @@ -0,0 +1,62 @@ +// 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 UserFollow { + pub relationship_id: String, + pub follower_user_id: String, + pub followee_user_id: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for UserFollow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `UserFollow`. +/// +/// Provides typed access to columns for query building. +pub struct UserFollowCols { + pub relationship_id: __sdk::__query_builder::Col, + pub follower_user_id: __sdk::__query_builder::Col, + pub followee_user_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for UserFollow { + type Cols = UserFollowCols; + fn cols(table_name: &'static str) -> Self::Cols { + UserFollowCols { + relationship_id: __sdk::__query_builder::Col::new(table_name, "relationship_id"), + follower_user_id: __sdk::__query_builder::Col::new(table_name, "follower_user_id"), + followee_user_id: __sdk::__query_builder::Col::new(table_name, "followee_user_id"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `UserFollow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct UserFollowIxCols { + pub followee_user_id: __sdk::__query_builder::IxCol, + pub follower_user_id: __sdk::__query_builder::IxCol, + pub relationship_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for UserFollow { + type IxCols = UserFollowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + UserFollowIxCols { + followee_user_id: __sdk::__query_builder::IxCol::new(table_name, "followee_user_id"), + follower_user_id: __sdk::__query_builder::IxCol::new(table_name, "follower_user_id"), + relationship_id: __sdk::__query_builder::IxCol::new(table_name, "relationship_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for UserFollow {} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index 7500bb0cb..b07af4e81 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -14,6 +14,7 @@ serde = { workspace = true } serde_json = { workspace = true } shared-contracts = { workspace = true } module-ai = { workspace = true, features = ["spacetime-types"] } +module-auth = { workspace = true } module-assets = { workspace = true, features = ["spacetime-types"] } module-editor-agent = { workspace = true, features = ["spacetime-types"] } module-game-distribution = { workspace = true } diff --git a/server-rs/crates/spacetime-module/src/active.rs b/server-rs/crates/spacetime-module/src/active.rs index 12d2789e2..1b213d0b6 100644 --- a/server-rs/crates/spacetime-module/src/active.rs +++ b/server-rs/crates/spacetime-module/src/active.rs @@ -20,6 +20,8 @@ mod agc_models; mod ai; mod asset_metadata; mod auth; +mod creator; +pub use creator::*; mod editor_agent_storage; mod editor_project_storage; #[path = "error_report.rs"] diff --git a/server-rs/crates/spacetime-module/src/creator.rs b/server-rs/crates/spacetime-module/src/creator.rs new file mode 100644 index 000000000..456b3b733 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/creator.rs @@ -0,0 +1,441 @@ +//! 关注事实和事务投影;只有受信服务身份可传入经过认证的访问者。 +use crate::auth::{UserAccount, user_account}; +use crate::editor_project_storage::require_editor_generation_runtime_service_identity; +use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp}; +use module_auth::creator::{self, ConnectionCursor, ConnectionKind, CreatorAction}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +#[spacetimedb::table( + accessor = user_follow, + index(accessor = by_follower_user_id, btree(columns = [follower_user_id])), + index(accessor = by_followee_user_id, btree(columns = [followee_user_id])) +)] +pub struct UserFollow { + #[primary_key] + pub relationship_id: String, + pub follower_user_id: String, + pub followee_user_id: String, + pub created_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorUserView { + pub id: String, + pub public_user_code: String, + pub display_name: String, + pub avatar_url: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorRelationshipView { + pub is_self: bool, + pub is_following: bool, + pub is_followed_by: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorProfileView { + pub user: CreatorUserView, + pub following_count: u64, + pub follower_count: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorConnectionView { + pub user: CreatorUserView, + pub followed_at: String, + pub relationship: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorConnectionsView { + pub items: Vec, + pub next_cursor: Option, + pub total: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorRelationshipRecord { + pub user_id: String, + pub relationship: CreatorRelationshipView, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorProfileResult { + pub profile: Option, + pub error_code: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorConnectionsResult { + pub connections: Option, + pub error_code: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorRelationshipResult { + pub record: Option, + pub error_code: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorConnectionsInput { + pub owner_id: String, + pub viewer_id: Option, + pub kind: String, + pub limit: u32, + pub cursor: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct CreatorRelationshipInput { + pub actor_id: String, + pub target_id: String, +} + +fn creator_id(raw: &str) -> Result { + creator::normalize_user_id(raw).map_err(|_| "invalid_input".into()) +} + +fn creator_user(ctx: &ReducerContext, id: &str) -> Result { + ctx.db + .user_account() + .user_id() + .find(id.to_owned()) + .ok_or_else(|| "user_not_found".into()) +} + +fn public_user(user: UserAccount) -> CreatorUserView { + CreatorUserView { + id: user.user_id, + public_user_code: user.public_user_code, + display_name: user.display_name, + avatar_url: user.avatar_url, + } +} + +fn creator_relationship( + ctx: &ReducerContext, + actor: &str, + target: &str, +) -> CreatorRelationshipView { + CreatorRelationshipView { + is_self: actor == target, + is_following: actor != target + && ctx + .db + .user_follow() + .relationship_id() + .find(creator::relationship_id(actor, target)) + .is_some(), + is_followed_by: actor != target + && ctx + .db + .user_follow() + .relationship_id() + .find(creator::relationship_id(target, actor)) + .is_some(), + } +} + +/// 此分支包住查询本身,不能先算关系再把他人列表字段清空。 +fn creator_owner_relationship( + owner: &str, + viewer: Option<&str>, + read: impl FnOnce() -> T, +) -> Option { + (viewer == Some(owner)).then(read) +} + +#[spacetimedb::procedure] +pub fn get_creator_profile_and_return( + ctx: &mut ProcedureContext, + user_id: String, +) -> CreatorProfileResult { + let caller = ctx.sender(); + let result = ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller) + .map_err(|_| "forbidden".to_string())?; + let id = creator_id(&user_id)?; + let user = public_user(creator_user(tx, &id)?); + let following_count = tx + .db + .user_follow() + .by_follower_user_id() + .filter(&id) + .filter(|row| creator_user(tx, &row.followee_user_id).is_ok()) + .count() as u64; + let follower_count = tx + .db + .user_follow() + .by_followee_user_id() + .filter(&id) + .filter(|row| creator_user(tx, &row.follower_user_id).is_ok()) + .count() as u64; + Ok(CreatorProfileView { + user, + following_count, + follower_count, + }) + }); + match result { + Ok(profile) => CreatorProfileResult { + profile: Some(profile), + error_code: None, + }, + Err(error) => CreatorProfileResult { + profile: None, + error_code: Some(error), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_creator_relationship_and_return( + ctx: &mut ProcedureContext, + input: CreatorRelationshipInput, +) -> CreatorRelationshipResult { + let caller = ctx.sender(); + let result = ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller) + .map_err(|_| "forbidden".to_string())?; + let actor = creator_id(&input.actor_id)?; + let target = creator_id(&input.target_id)?; + creator_user(tx, &actor)?; + creator_user(tx, &target)?; + Ok(CreatorRelationshipRecord { + relationship: creator_relationship(tx, &actor, &target), + user_id: target, + }) + }); + relationship_result(result) +} + +fn relationship_result( + result: Result, +) -> CreatorRelationshipResult { + match result { + Ok(record) => CreatorRelationshipResult { + record: Some(record), + error_code: None, + }, + Err(error) => CreatorRelationshipResult { + record: None, + error_code: Some(error), + }, + } +} + +fn mutate_creator( + ctx: &mut ProcedureContext, + input: CreatorRelationshipInput, + action: CreatorAction, +) -> CreatorRelationshipResult { + let caller = ctx.sender(); + relationship_result(ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller) + .map_err(|_| "forbidden".to_string())?; + let command = creator::prepare_mutation(&input.actor_id, &input.target_id, action) + .map_err(|_| "invalid_input".to_string())?; + creator_user(tx, &command.actor_id)?; + creator_user(tx, &command.target_id)?; + if command.insert { + if tx + .db + .user_follow() + .relationship_id() + .find(&command.relationship_id) + .is_none() + { + tx.db.user_follow().insert(UserFollow { + relationship_id: command.relationship_id, + follower_user_id: command.follower_id, + followee_user_id: command.followee_id, + created_at: tx.timestamp, + }); + } + } else { + tx.db + .user_follow() + .relationship_id() + .delete(&command.relationship_id); + } + Ok(CreatorRelationshipRecord { + relationship: creator_relationship(tx, &command.actor_id, &command.target_id), + user_id: command.target_id, + }) + })) +} + +#[spacetimedb::procedure] +pub fn follow_creator_and_return( + ctx: &mut ProcedureContext, + input: CreatorRelationshipInput, +) -> CreatorRelationshipResult { + mutate_creator(ctx, input, CreatorAction::Follow) +} + +#[spacetimedb::procedure] +pub fn unfollow_creator_and_return( + ctx: &mut ProcedureContext, + input: CreatorRelationshipInput, +) -> CreatorRelationshipResult { + mutate_creator(ctx, input, CreatorAction::Unfollow) +} + +#[spacetimedb::procedure] +pub fn remove_creator_follower_and_return( + ctx: &mut ProcedureContext, + input: CreatorRelationshipInput, +) -> CreatorRelationshipResult { + mutate_creator(ctx, input, CreatorAction::RemoveFollower) +} + +#[spacetimedb::procedure] +pub fn list_creator_connections_and_return( + ctx: &mut ProcedureContext, + input: CreatorConnectionsInput, +) -> CreatorConnectionsResult { + let caller = ctx.sender(); + let result = ctx.try_with_tx(|tx| { + require_editor_generation_runtime_service_identity(tx, caller) + .map_err(|_| "forbidden".to_string())?; + list_creator_connections_tx(tx, &input) + }); + match result { + Ok(connections) => CreatorConnectionsResult { + connections: Some(connections), + error_code: None, + }, + Err(error) => CreatorConnectionsResult { + connections: None, + error_code: Some(error), + }, + } +} + +fn list_creator_connections_tx( + ctx: &ReducerContext, + input: &CreatorConnectionsInput, +) -> Result { + let owner = creator_id(&input.owner_id)?; + creator_user(ctx, &owner)?; + let viewer = input.viewer_id.as_deref().map(creator_id).transpose()?; + if let Some(id) = &viewer { + creator_user(ctx, id)?; + } + let kind = match input.kind.as_str() { + "following" => ConnectionKind::Following, + "followers" => ConnectionKind::Followers, + _ => return Err("invalid_input".into()), + }; + let limit = creator::parse_list_limit(Some(&input.limit.to_string())) + .map_err(|_| "invalid_input".to_string())?; + let cursor = input + .cursor + .as_deref() + .map(|raw| ConnectionCursor::decode(raw, &owner, kind)) + .transpose() + .map_err(|_| "invalid_input".to_string())?; + let rows: Vec<_> = match kind { + ConnectionKind::Following => ctx + .db + .user_follow() + .by_follower_user_id() + .filter(&owner) + .collect(), + ConnectionKind::Followers => ctx + .db + .user_follow() + .by_followee_user_id() + .filter(&owner) + .collect(), + }; + let mut visible = rows + .into_iter() + .filter_map(|row| { + let peer = match kind { + ConnectionKind::Following => &row.followee_user_id, + ConnectionKind::Followers => &row.follower_user_id, + }; + creator_user(ctx, peer) + .ok() + .map(|user| (row, public_user(user))) + }) + .collect::>(); + let total = visible.len() as u64; + visible.sort_by(|(a, _), (b, _)| { + b.created_at + .cmp(&a.created_at) + .then_with(|| b.relationship_id.cmp(&a.relationship_id)) + }); + let mut page = visible + .into_iter() + .filter(|(row, _)| { + cursor.as_ref().is_none_or(|cursor| { + cursor.is_after( + row.created_at.to_micros_since_unix_epoch(), + &row.relationship_id, + ) + }) + }) + .take(limit + 1) + .collect::>(); + let has_more = page.len() > limit; + page.truncate(limit); + let next_cursor = if has_more { + page.last().map(|(row, _)| { + ConnectionCursor::new( + owner.clone(), + kind, + row.created_at.to_micros_since_unix_epoch(), + row.relationship_id.clone(), + ) + .encode() + }) + } else { + None + }; + let items = page + .into_iter() + .map(|(row, user)| { + let relationship = creator_owner_relationship(&owner, viewer.as_deref(), || { + creator_relationship(ctx, &owner, &user.id) + }); + let followed_at = OffsetDateTime::from_unix_timestamp_nanos( + i128::from(row.created_at.to_micros_since_unix_epoch()) * 1000, + ) + .map_err(|_| "invalid_timestamp".to_string())? + .format(&Rfc3339) + .map_err(|_| "invalid_timestamp".to_string())?; + Ok(CreatorConnectionView { + user, + followed_at, + relationship, + }) + }) + .collect::, String>>()?; + Ok(CreatorConnectionsView { + items, + next_cursor, + total, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn creator_other_and_guest_lists_never_evaluate_relationship_query() { + for viewer in [None, Some("visitor")] { + assert_eq!( + creator_owner_relationship("owner", viewer, || panic!("他人列表不应查询关系")), + None::<()> + ); + } + assert_eq!( + creator_owner_relationship("owner", Some("owner"), || 42), + Some(42) + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/game_distribution.rs b/server-rs/crates/spacetime-module/src/game_distribution.rs index e580a3df5..c471fab9e 100644 --- a/server-rs/crates/spacetime-module/src/game_distribution.rs +++ b/server-rs/crates/spacetime-module/src/game_distribution.rs @@ -1113,6 +1113,7 @@ pub struct GameDistributionPublicGameListInput { pub search: Option, pub category: Option, pub limit: u32, + pub author_id: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -3458,6 +3459,12 @@ fn list_public_game_distribution_games_tx( ctx: &ReducerContext, input: GameDistributionPublicGameListInput, ) -> Result, String> { + let author_id = input + .author_id + .as_deref() + .map(module_auth::creator::normalize_user_id) + .transpose() + .map_err(|error| error.to_string())?; let limit = if input.limit == 0 { GAME_DISTRIBUTION_MAX_LIST_LIMIT } else { @@ -3473,6 +3480,11 @@ fn list_public_game_distribution_games_tx( .db .game_distribution_game() .iter() + .filter(|game| { + author_id + .as_deref() + .is_none_or(|id| game.owner_user_id == id) + }) .filter(|game| game.visibility == GAME_DISTRIBUTION_VISIBILITY_PUBLISHED) .filter(|game| game.deleted_at.is_none()) .filter(|game| game.active_version_id.is_some()) diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 83458de87..2ee01af29 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -148,6 +148,7 @@ macro_rules! migration_tables { auth_store_projection_meta, admin_account, user_account, + user_follow, auth_identity, refresh_session, ai_task, diff --git a/src/ActiveApp.tsx b/src/ActiveApp.tsx index 2c6a3c54d..743435a2e 100644 --- a/src/ActiveApp.tsx +++ b/src/ActiveApp.tsx @@ -45,6 +45,7 @@ function resolveInitialAppSelectionStage() { export default function App() { const authUi = useAuthUi(); const hostNavigation = useHostNavigationCanGoBack(); + const [, setLocationRevision] = useState(0); const [, setHostRuntimeRevision] = useState(0); const [selectionStage, setRawSelectionStage] = useState( resolveInitialAppSelectionStage, @@ -53,6 +54,7 @@ export default function App() { const setSelectionStage = useCallback( (stage: SelectionStage, options?: { path?: string }) => { setRawSelectionStage(stage); + setLocationRevision((revision) => revision + 1); pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage)); }, [], @@ -70,6 +72,8 @@ export default function App() { useEffect(() => { const syncStageFromHistory = () => { + // 同一舞台切换用户、列表方向或浏览器返回也要重读 URL。 + setLocationRevision((revision) => revision + 1); if ( shouldRedirectEditorCanvasWithoutProject( window.location.pathname, diff --git a/src/components/creator/CreatorConnectionsPage.tsx b/src/components/creator/CreatorConnectionsPage.tsx new file mode 100644 index 000000000..8535a68ba --- /dev/null +++ b/src/components/creator/CreatorConnectionsPage.tsx @@ -0,0 +1,280 @@ +import './creator.css'; + +import { CreatorUserRow } from '@genarrative/shared/components'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { + CreatorConnection, + CreatorRelationship, +} from '../../../packages/shared/src/contracts/creator'; +import { APP_PAGE_VIEW_STATE_KEY } from '../../routing/activeAppPageRoutes'; +import { + connectionsPath, + type CreatorConnectionKind, + creatorPath, + getCreatorConnections, +} from '../../services/creatorProfileClient'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { followCreatorLink } from './creatorNavigation'; +import { CreatorRelationshipControl } from './CreatorRelationshipControl'; +import { CREATOR_CHANGED_EVENT } from './creatorRelationshipState'; + +type Props = { + id: string | null; + tab: CreatorConnectionKind; + onOpenCreator: (id: string) => void; + onOpenConnections: (id: string, tab: CreatorConnectionKind) => void; + onBack: () => void; +}; +export function CreatorConnectionsPage(props: Props) { + const auth = useAuthUi(); + const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null; + if (!props.id) return

缺少创作者 ID

; + return ( + + ); +} +function ConnectionsContent({ + id, + tab, + viewer, + onOpenCreator, + onOpenConnections, + onBack, +}: Props & { id: string; viewer: string | null }) { + const own = viewer === id; + const root = useRef(null); + const locationKey = window.location.pathname + window.location.search; + const saved = useRef(window.history.state?.[APP_PAGE_VIEW_STATE_KEY]); + const restoration = + saved.current?.url === locationKey && saved.current?.viewer === viewer + ? saved.current + : null; + const pages = useRef(restoration?.pages ?? 1); + const scrollTop = useRef(restoration?.scrollTop ?? 0); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(null); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const retained = useRef(new Map()); + const sequence = useRef(0); + const mounted = useRef(true); + const loaded = useRef(false); + const save = useCallback(() => { + if (window.location.pathname + window.location.search !== locationKey) + return; + window.history.replaceState( + { + ...window.history.state, + [APP_PAGE_VIEW_STATE_KEY]: { + url: locationKey, + viewer, + pages: pages.current, + scrollTop: scrollTop.current, + }, + }, + '', + ); + }, [locationKey, viewer]); + const load = useCallback( + async (wanted = pages.current, refresh = false) => { + if (!mounted.current) return; + const serial = ++sequence.current; + setLoading(true); + setError(''); + if (refresh) retained.current.clear(); + try { + let next: string | null = null; + const rows = new Map(); + let count = 0, + actual = 0; + for (let page = 0; page < wanted; page++) { + const result = await getCreatorConnections( + id, + tab, + next, + viewer !== null, + ); + if (serial !== sequence.current) return; + for (const item of result.items) rows.set(item.user.id, item); + next = result.nextCursor; + count = result.total; + actual++; + if (!next) break; + } + setItems((previous) => { + const merged = [...rows.values()]; + for (const [key, row] of retained.current) { + if (!rows.has(key)) + merged.splice( + Math.max( + 0, + previous.findIndex((item) => item.user.id === key), + ), + 0, + row, + ); + } + return merged; + }); + setTotal(count); + setCursor(next); + pages.current = actual; + if (!loaded.current) { + loaded.current = true; + requestAnimationFrame(() => { + if (serial === sequence.current) + root.current + ?.closest('.platform-tab-panel') + ?.scrollTo({ top: scrollTop.current }); + }); + } + save(); + } catch (reason) { + if (serial === sequence.current) + setError(reason instanceof Error ? reason.message : '加载失败'); + } finally { + if (serial === sequence.current) setLoading(false); + } + }, + [id, tab, viewer, save], + ); + useEffect(() => { + mounted.current = true; + void load(); + const refresh = () => { + void load(); + }; + window.addEventListener('focus', refresh); + // 写后事件异步回读,先让行回调记录暂留状态。 + const changed = () => { + queueMicrotask(refresh); + }; + window.addEventListener(CREATOR_CHANGED_EVENT, changed); + const panel = root.current?.closest('.platform-tab-panel'); + const scroll = () => { + scrollTop.current = panel?.scrollTop ?? 0; + save(); + }; + panel?.addEventListener('scroll', scroll); + const invalidate = () => { + sequence.current++; + }; + return () => { + mounted.current = false; + invalidate(); + window.removeEventListener('focus', refresh); + window.removeEventListener(CREATOR_CHANGED_EVENT, changed); + panel?.removeEventListener('scroll', scroll); + }; + }, [load, save]); + const result = (row: CreatorConnection, value: CreatorRelationship) => { + if (!mounted.current) return; + if (tab === 'following') { + if (value.isFollowing) retained.current.delete(row.user.id); + else retained.current.set(row.user.id, { ...row, relationship: value }); + } + if (tab === 'followers' && !value.isFollowedBy) + setItems((current) => + current.filter((item) => item.user.id !== row.user.id), + ); + void load(); + }; + return ( +
+ +

+ {own ? '我的' : ''} + {tab === 'following' ? '关注' : '粉丝'} + {total !== null && ` · ${total}`} +

+ + + {error && ( +

+ {error}{' '} + +

+ )} +
    + {items.map((row) => ( +
  • + + followCreatorLink(event, () => onOpenCreator(row.user.id)) + } + actions={ + own ? ( + result(row, value)} + /> + ) : undefined + } + /> +
  • + ))} +
+ {loading ? ( +

正在加载列表…

+ ) : !error && items.length === 0 ? ( +

{tab === 'following' ? '暂无关注' : '暂无粉丝'}

+ ) : null} + {cursor && ( + + )} +
+ ); +} diff --git a/src/components/creator/CreatorPage.tsx b/src/components/creator/CreatorPage.tsx new file mode 100644 index 000000000..abd8e10ab --- /dev/null +++ b/src/components/creator/CreatorPage.tsx @@ -0,0 +1,181 @@ +import '../game-distribution/gameDistribution.css'; +import './creator.css'; + +import { CreatorUserRow, GameCard } from '@genarrative/shared/components'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { CreatorProfile } from '../../../packages/shared/src/contracts/creator'; +import { + connectionsPath, + type CreatorConnectionKind, + creatorPath, + getCreator, +} from '../../services/creatorProfileClient'; +import { + type GameDistributionGame, + listGames, +} from '../../services/gameDistributionClient'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { GameCover } from '../game-distribution/GameCover'; +import { followCreatorLink } from './creatorNavigation'; +import { CreatorRelationshipControl } from './CreatorRelationshipControl'; +import { CREATOR_CHANGED_EVENT } from './creatorRelationshipState'; + +export function CreatorPage({ + id, + onOpenConnections, + onOpenGame, + onBack, +}: { + id: string | null; + onOpenConnections: (id: string, tab: CreatorConnectionKind) => void; + onOpenGame: (id: string) => void; + onBack: () => void; +}) { + const auth = useAuthUi(); + const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null; + const target = id ?? viewer; + if (!target) + return ( +
+

创作者主页

+

登录后查看自己的创作者主页。

+ +
+ ); + return ( + + ); +} +function CreatorProfileContent({ + id, + onOpenConnections, + onOpenGame, + onBack, +}: { + id: string; + onOpenConnections: (id: string, tab: CreatorConnectionKind) => void; + onOpenGame: (id: string) => void; + onBack: () => void; +}) { + const [profile, setProfile] = useState(null); + const [error, setError] = useState(''); + const [games, setGames] = useState(null); + const [gameError, setGameError] = useState(''); + const serial = useRef(0); + const load = useCallback(() => { + const request = ++serial.current; + setError(''); + setGameError(''); + void getCreator(id) + .then((value) => { + if (serial.current === request) setProfile(value); + }) + .catch((reason) => { + if (serial.current === request) setError(reason.message); + }); + void listGames({ authorId: id }) + .then((value) => { + if (serial.current === request) setGames(value); + }) + .catch((reason) => { + if (serial.current === request) setGameError(reason.message); + }); + }, [id]); + useEffect(() => { + load(); + window.addEventListener('focus', load); + window.addEventListener(CREATOR_CHANGED_EVENT, load); + const invalidate = () => { + serial.current++; + }; + return () => { + invalidate(); + window.removeEventListener('focus', load); + window.removeEventListener(CREATOR_CHANGED_EVENT, load); + }; + }, [load]); + return ( +
+ +

创作者主页

+ {error && ( +

+ {error}{' '} + +

+ )} + {!profile && !error &&

正在加载创作者…

} + {profile && ( + <> + +

公开游戏

+ {gameError ? ( +

+ {gameError}{' '} + +

+ ) : games === null ? ( +

正在加载游戏…

+ ) : games.length === 0 ? ( +

还没有公开游戏

+ ) : ( +
+ {games.map((game) => ( + } + onOpen={() => onOpenGame(game.id)} + /> + ))} +
+ )} + + )} +
+ ); +} diff --git a/src/components/creator/CreatorPages.test.tsx b/src/components/creator/CreatorPages.test.tsx new file mode 100644 index 000000000..3cc220e69 --- /dev/null +++ b/src/components/creator/CreatorPages.test.tsx @@ -0,0 +1,309 @@ +/* @vitest-environment jsdom */ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import type { CreatorConnection } from '../../../packages/shared/src/contracts/creator'; +import { CreatorConnectionsPage } from './CreatorConnectionsPage'; +import { CreatorPage } from './CreatorPage'; +import { CreatorAuthor } from './CreatorRelationshipControl'; +import { seedRelationship, setCreatorViewer } from './creatorRelationshipState'; +const api = vi.hoisted(() => ({ + viewer: 'a' as string | null, + list: vi.fn(), + read: vi.fn(), + write: vi.fn(), + profile: vi.fn(), + games: vi.fn(), + login: vi.fn(), +})); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ + user: api.viewer ? { id: api.viewer } : null, + canAccessProtectedData: !!api.viewer, + openLoginModal: api.login, + }), +})); +vi.mock('../../services/creatorProfileClient', async (original) => ({ + ...(await original()), + getCreatorConnections: api.list, + getCreatorRelationship: api.read, + changeCreatorRelationship: api.write, + getCreator: api.profile, +})); +vi.mock('../../services/gameDistributionClient', () => ({ + listGames: api.games, +})); +vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({ + useResolvedAssetReadUrl: () => ({ resolvedUrl: '' }), +})); +const following = { isSelf: false, isFollowing: true, isFollowedBy: true }; +const user = { + id: 'b', + displayName: '用户乙', + publicUserCode: '10002', + avatarUrl: null, +}; +const row = (relation = following): CreatorConnection => ({ + user, + followedAt: '2026-10-05T00:00:00Z', + relationship: relation, +}); +const callbacks = { + onOpenCreator: vi.fn(), + onOpenConnections: vi.fn(), + onBack: vi.fn(), +}; +beforeEach(() => { + vi.clearAllMocks(); + api.viewer = 'a'; + setCreatorViewer(null); + setCreatorViewer('a'); + window.history.replaceState( + {}, + '', + '/creators/connections?id=a&tab=following', + ); + Object.defineProperty(Element.prototype, 'scrollTo', { + configurable: true, + value: vi.fn(), + }); + api.read.mockResolvedValue({ relationship: following }); + api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 }); + api.profile.mockResolvedValue({ user, followingCount: 1, followerCount: 2 }); + api.games.mockResolvedValue([]); +}); +afterEach(cleanup); +it.each(['following', 'followers'] as const)( + '他人的 %s 列表有主页链接,但缓存已有关系时仍无操作和补查', + async (tab) => { + seedRelationship('a', 'b', following); + api.list.mockResolvedValue({ + items: [{ ...row(), relationship: null }], + nextCursor: null, + total: 1, + }); + render(); + const link = await screen.findByRole('link', { name: /用户乙/ }); + expect(link.getAttribute('href')).toBe('/creators?id=b'); + fireEvent.click(link); + expect(callbacks.onOpenCreator).toHaveBeenCalledWith('b'); + expect(screen.queryByRole('button', { name: '取消关注' })).toBeNull(); + expect(screen.queryByRole('button', { name: '回关' })).toBeNull(); + expect(screen.queryByRole('button', { name: '移除粉丝' })).toBeNull(); + expect(api.read).not.toHaveBeenCalled(); + }, +); +it('本人关注取消后保留原行,重新关注,再取消并刷新后去行', async () => { + render(); + await screen.findByRole('button', { name: '取消关注' }); + api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 }); + api.write.mockResolvedValue({ + relationship: { ...following, isFollowing: false }, + }); + fireEvent.click(screen.getByRole('button', { name: '取消关注' })); + await screen.findByRole('button', { name: '重新关注' }); + expect(screen.getByRole('link', { name: /用户乙/ })).toBeTruthy(); + api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 }); + api.write.mockResolvedValue({ relationship: following }); + fireEvent.click(screen.getByRole('button', { name: '重新关注' })); + await screen.findByRole('button', { name: '取消关注' }); + api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 }); + api.write.mockResolvedValue({ + relationship: { ...following, isFollowing: false }, + }); + fireEvent.click(screen.getByRole('button', { name: '取消关注' })); + await screen.findByRole('button', { name: '重新关注' }); + await waitFor(() => + expect( + (screen.getByRole('button', { name: '刷新列表' }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); + fireEvent.click(screen.getByRole('button', { name: '刷新列表' })); + await screen.findByText('暂无关注'); + expect(screen.queryByRole('link', { name: /用户乙/ })).toBeNull(); +}); +it('自己的粉丝支持回关和取消回关,移除须确认且只移除该行', async () => { + api.list.mockResolvedValue({ + items: [row({ ...following, isFollowing: false })], + nextCursor: null, + total: 1, + }); + render(); + await screen.findByRole('button', { name: '回关' }); + api.write.mockResolvedValue({ relationship: following }); + api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 }); + fireEvent.click(screen.getByRole('button', { name: '回关' })); + await screen.findByRole('button', { name: '取消关注' }); + expect(api.write).toHaveBeenLastCalledWith('b', 'follow'); + fireEvent.click(screen.getByRole('button', { name: '移除粉丝' })); + const dialog = await screen.findByRole('dialog'); + expect(api.write).toHaveBeenCalledTimes(1); + api.write.mockResolvedValue({ + relationship: { ...following, isFollowedBy: false }, + }); + api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 }); + fireEvent.click(within(dialog).getByRole('button', { name: '移除粉丝' })); + await screen.findByText('暂无粉丝'); + expect(api.write).toHaveBeenLastCalledWith('b', 'remove-follower'); +}); +it('列表切换账号,迟到响应不能把原账号的私有行带入', async () => { + let resolve!: (value: unknown) => void; + api.list.mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }), + ); + const view = render( + , + ); + api.viewer = 'c'; + api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 }); + view.rerender( + , + ); + await screen.findByText('暂无关注'); + await act(async () => { + resolve({ items: [row()], nextCursor: null, total: 1 }); + }); + expect(screen.queryByRole('link', { name: /用户乙/ })).toBeNull(); +}); +it('主页查询作者游戏,自己不显示关注按钮,空游戏仍有主页', async () => { + render( + , + ); + await screen.findByText('还没有公开游戏'); + expect(api.games).toHaveBeenCalledWith({ authorId: 'a' }); + expect(screen.queryByRole('button', { name: '取消关注' })).toBeNull(); + fireEvent.click(screen.getByRole('link', { name: '2 粉丝' })); + expect(callbacks.onOpenConnections).toHaveBeenCalledWith('a', 'followers'); +}); +it('游客点击关注仅唤起登录,自己作者也能打开主页', async () => { + api.viewer = null; + const view = render( + , + ); + fireEvent.click(screen.getByRole('button', { name: '关注' })); + expect(api.login).toHaveBeenCalledTimes(1); + expect(api.write).not.toHaveBeenCalled(); + api.viewer = 'b'; + view.rerender(); + fireEvent.click(screen.getByRole('link', { name: '用户乙' })); + expect(callbacks.onOpenCreator).toHaveBeenCalledWith('b'); + expect(screen.queryByRole('button', { name: '关注' })).toBeNull(); + expect(api.write).not.toHaveBeenCalled(); +}); +it('分页加载追加,离开重进重新读取,临时取消的行不持久化', async () => { + api.list.mockImplementation((_id, _tab, cursor) => + Promise.resolve( + cursor + ? { + items: [ + { ...row(), user: { ...user, id: 'c', displayName: '用户丙' } }, + ], + nextCursor: null, + total: 2, + } + : { items: [row()], nextCursor: 'page2', total: 2 }, + ), + ); + const view = render( + , + ); + fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await screen.findByRole('link', { name: /用户丙/ }); + view.unmount(); + api.list.mockClear(); + render(); + await screen.findByRole('link', { name: /用户丙/ }); + expect(api.list).toHaveBeenCalledTimes(2); +}); + +it('显式主页登录后保持原作者,裸主页游客显示登录入口', async () => { + api.viewer = null; + const view = render( + , + ); + expect(screen.getByText('登录后查看自己的创作者主页。')).toBeTruthy(); + view.rerender( + , + ); + await screen.findByText('还没有公开游戏'); + fireEvent.click(screen.getByRole('button', { name: '关注' })); + api.viewer = 'a'; + view.rerender( + , + ); + await screen.findByRole('button', { name: '取消关注' }); + expect(api.profile).toHaveBeenLastCalledWith('b'); + expect(api.write).not.toHaveBeenCalled(); +}); +it('同账号切换主页,迟到资料不覆盖新目标;关系读取失败不阻断公开内容', async () => { + let resolve!: (value: unknown) => void; + api.profile.mockReturnValueOnce( + new Promise((done) => { + resolve = done; + }), + ); + api.read.mockRejectedValue(new Error('关系读取暂时失败')); + const view = render( + , + ); + api.profile.mockResolvedValue({ + user: { ...user, id: 'new', displayName: '新的创作者' }, + followingCount: 0, + followerCount: 0, + }); + view.rerender( + , + ); + await screen.findByText('还没有公开游戏'); + await screen.findByText('关系读取暂时失败'); + await act(async () => { + resolve({ + user: { ...user, displayName: '旧作者' }, + followingCount: 1, + followerCount: 2, + }); + }); + expect(screen.getByRole('link', { name: /新的创作者/ })).toBeTruthy(); + expect(screen.queryByText('旧作者')).toBeNull(); +}); diff --git a/src/components/creator/CreatorRelationshipControl.tsx b/src/components/creator/CreatorRelationshipControl.tsx new file mode 100644 index 000000000..8d8ac000d --- /dev/null +++ b/src/components/creator/CreatorRelationshipControl.tsx @@ -0,0 +1,116 @@ +import { + CreatorFollowButton, + CreatorUserRow, +} from '@genarrative/shared/components'; +import { useState } from 'react'; + +import type { + CreatorRelationship, + CreatorUser, +} from '../../../packages/shared/src/contracts/creator'; +import { creatorPath } from '../../services/creatorProfileClient'; +import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { followCreatorLink } from './creatorNavigation'; +import { useCreatorRelationship } from './useCreatorRelationship'; + +export function CreatorRelationshipControl({ + target, + initial, + followLabel, + followedLabel = '已关注', + allowRemove = false, + onResult, +}: { + target: string; + initial?: CreatorRelationship | null; + followLabel?: string; + followedLabel?: string; + allowRemove?: boolean; + onResult?: (value: CreatorRelationship) => void; +}) { + const relation = useCreatorRelationship(target, initial); + const [confirm, setConfirm] = useState(false); + if (relation.isSelf) return null; + const disabled = Boolean( + relation.viewer && + (!relation.value || relation.loading || relation.uncertain), + ); + const act = async (action: 'follow' | 'unfollow' | 'remove-follower') => { + const result = await relation.mutate(action); + if (result) onResult?.(result); + setConfirm(false); + }; + return ( +
+
+ + relation.viewer + ? void act(relation.value?.isFollowing ? 'unfollow' : 'follow') + : relation.login() + } + /> + {allowRemove && ( + + )} +
+ {relation.error && ( +
+ {relation.error} + +
+ )} + { + if (!relation.pending) setConfirm(false); + }} + onConfirm={() => { + void act('remove-follower'); + }} + /> +
+ ); +} +export function CreatorAuthor({ + user, + onOpen, +}: { + user: CreatorUser; + onOpen?: (id: string) => void; +}) { + return ( + + followCreatorLink(event, onOpen && (() => onOpen(user.id))) + } + actions={} + /> + ); +} diff --git a/src/components/creator/creator.css b/src/components/creator/creator.css new file mode 100644 index 000000000..d4ff16ee1 --- /dev/null +++ b/src/components/creator/creator.css @@ -0,0 +1,82 @@ +.creator-page { + width: 100%; + max-width: 1120px; + margin: 0 auto; + color: var(--platform-text-base); +} +.creator-page h1 { + margin: 1.25rem 0; + font-size: 1.6rem; + font-weight: 700; +} +.creator-page h2 { + margin: 1.5rem 0 1rem; + font-size: 1.2rem; + font-weight: 600; +} +.creator-profile { + padding: 1.5rem; + border: 1px solid var(--platform-surface-border); + border-radius: 1.5rem; + background: var(--platform-panel-fill); +} +.creator-counts { + display: flex; + gap: 1.5rem; + margin-top: 1rem; +} +.creator-counts a { + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 44px; + color: inherit; + text-decoration: none; +} +.creator-counts strong { + font-size: 1.4rem; +} +.creator-tabs { + display: flex; + gap: 1rem; + margin: 1rem 0; +} +.creator-tabs a { + min-height: 44px; + display: flex; + align-items: center; + color: inherit; +} +.creator-tabs a[aria-current] { + font-weight: 700; + text-decoration: underline; +} +.creator-list { + list-style: none; + padding: 0; + margin: 1rem 0; +} +.creator-list > li { + padding: 1rem 0; + border-bottom: 1px solid var(--platform-surface-border); +} +.creator-error { + font-size: 0.85rem; + margin-top: 0.5rem; + max-width: 28rem; + overflow-wrap: anywhere; +} +.creator-relation { + min-width: 0; +} +@media (max-width: 600px) { + .creator-profile { + padding: 1rem; + } + .creator-list .creator-user-actions { + gap: 0.25rem; + } + .creator-list .creator-follow { + padding: 0.5rem 0.75rem; + } +} diff --git a/src/components/creator/creatorNavigation.ts b/src/components/creator/creatorNavigation.ts new file mode 100644 index 000000000..a2914002d --- /dev/null +++ b/src/components/creator/creatorNavigation.ts @@ -0,0 +1,18 @@ +import type { MouseEvent } from 'react'; + +export function followCreatorLink( + event: MouseEvent, + navigate?: () => void, +) { + if ( + navigate && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey && + event.button === 0 + ) { + event.preventDefault(); + navigate(); + } +} diff --git a/src/components/creator/creatorRelationshipState.test.ts b/src/components/creator/creatorRelationshipState.test.ts new file mode 100644 index 000000000..334766453 --- /dev/null +++ b/src/components/creator/creatorRelationshipState.test.ts @@ -0,0 +1,95 @@ +/* @vitest-environment jsdom */ +import { beforeEach, expect, it, vi } from 'vitest'; + +import { ApiClientError } from '../../services/apiClient'; +import { + mutateRelationship, + readRelationship, + relationshipSnapshot, + seedRelationship, + setCreatorViewer, +} from './creatorRelationshipState'; +const api = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() })); +vi.mock('../../services/creatorProfileClient', () => ({ + getCreatorRelationship: api.read, + changeCreatorRelationship: api.write, +})); +const no = { isSelf: false, isFollowing: false, isFollowedBy: true }; +const yes = { ...no, isFollowing: true }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +beforeEach(() => { + vi.resetAllMocks(); + setCreatorViewer(null); + setCreatorViewer('a'); + seedRelationship('a', 'b', no); +}); +it('同一目标写入单飞,正式回执到达前不修改关系', async () => { + const request = deferred<{ relationship: typeof yes }>(); + api.write.mockReturnValue(request.promise); + const first = mutateRelationship('a', 'b', 'follow'); + expect(await mutateRelationship('a', 'b', 'follow')).toBeNull(); + expect(api.write).toHaveBeenCalledTimes(1); + expect(relationshipSnapshot('a', 'b').value).toEqual(no); + request.resolve({ relationship: yes }); + await first; + expect(relationshipSnapshot('a', 'b').value).toEqual(yes); +}); +it('未知写结果只回读,失败后禁止继续写,读取成功才恢复', async () => { + api.write.mockRejectedValue(new Error('网络中断')); + api.read.mockRejectedValue(new Error('离线')); + await mutateRelationship('a', 'b', 'follow'); + expect(relationshipSnapshot('a', 'b').uncertain).toBe(true); + await mutateRelationship('a', 'b', 'follow'); + expect(api.write).toHaveBeenCalledTimes(1); + api.read.mockResolvedValue({ relationship: yes }); + await readRelationship('a', 'b'); + expect(relationshipSnapshot('a', 'b').uncertain).toBe(false); + expect(relationshipSnapshot('a', 'b').value).toEqual(yes); +}); +it('未收到回执而已落库时,以回读结果为准且不重放', async () => { + api.write.mockRejectedValue(new Error('timeout')); + api.read.mockResolvedValue({ relationship: yes }); + expect(await mutateRelationship('a', 'b', 'follow')).toEqual(yes); + expect(api.write).toHaveBeenCalledTimes(1); + expect(api.read).toHaveBeenCalledTimes(1); +}); +it('明确的业务拒绝保留原状态', async () => { + api.write.mockRejectedValue( + new ApiClientError({ + status: 404, + code: 'not_found', + message: '用户不存在', + }), + ); + await mutateRelationship('a', 'b', 'follow'); + expect(api.read).not.toHaveBeenCalled(); + expect(relationshipSnapshot('a', 'b').value).toEqual(no); +}); +it('退出后重新登录同一账号,旧写回执也不能污染新会话', async () => { + const request = deferred<{ relationship: typeof yes }>(); + api.write.mockReturnValue(request.promise); + const pending = mutateRelationship('a', 'b', 'follow'); + setCreatorViewer(null); + setCreatorViewer('a'); + request.resolve({ relationship: yes }); + await pending; + expect(relationshipSnapshot('a', 'b').value).toBeNull(); +}); +it('旧读响应不能覆盖新读或另一个目标', async () => { + const old = deferred<{ relationship: typeof no }>(); + api.read + .mockReturnValueOnce(old.promise) + .mockResolvedValue({ relationship: yes }); + const pending = readRelationship('a', 'b'); + await readRelationship('a', 'b'); + old.resolve({ relationship: no }); + await pending; + expect(relationshipSnapshot('a', 'b').value).toEqual(yes); + expect(relationshipSnapshot('a', 'c').value).toBeNull(); +}); diff --git a/src/components/creator/creatorRelationshipState.ts b/src/components/creator/creatorRelationshipState.ts new file mode 100644 index 000000000..1bbd75799 --- /dev/null +++ b/src/components/creator/creatorRelationshipState.ts @@ -0,0 +1,159 @@ +import type { CreatorRelationship } from '../../../packages/shared/src/contracts/creator'; +import { ApiClientError } from '../../services/apiClient'; +import { + changeCreatorRelationship, + type CreatorAction, + getCreatorRelationship, +} from '../../services/creatorProfileClient'; + +export const CREATOR_CHANGED_EVENT = 'genarrative-creator-changed'; +export interface RelationshipState { + value: CreatorRelationship | null; + loading: boolean; + pending: boolean; + uncertain: boolean; + error: string; +} +const EMPTY: RelationshipState = { + value: null, + loading: false, + pending: false, + uncertain: false, + error: '', +}; +let activeViewer: string | null = null; +let epoch = 0; +const states = new Map(); +const serials = new Map(); +const listeners = new Set<() => void>(); +const key = (viewer: string, target: string) => + JSON.stringify([viewer, target]); +const emit = () => listeners.forEach((listener) => listener()); +export function subscribeRelationships(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +export function setCreatorViewer(viewer: string | null) { + if (activeViewer === viewer) return; + activeViewer = viewer; + epoch += 1; + states.clear(); + serials.clear(); + emit(); +} +export function relationshipSnapshot(viewer: string | null, target: string) { + return viewer ? (states.get(key(viewer, target)) ?? EMPTY) : EMPTY; +} +function update(id: string, patch: Partial) { + states.set(id, { ...(states.get(id) ?? EMPTY), ...patch }); + emit(); +} +export function seedRelationship( + viewer: string, + target: string, + value: CreatorRelationship, +) { + if (activeViewer !== viewer) return; + const id = key(viewer, target); + if (states.get(id)?.pending) return; + serials.set(id, (serials.get(id) ?? 0) + 1); + update(id, { value, loading: false, uncertain: false, error: '' }); +} +export async function readRelationship(viewer: string, target: string) { + if (activeViewer !== viewer) return; + const id = key(viewer, target), + generation = epoch; + if (states.get(id)?.pending) return; + const serial = (serials.get(id) ?? 0) + 1; + serials.set(id, serial); + update(id, { loading: true, error: '' }); + try { + const result = await getCreatorRelationship(target); + if (generation !== epoch || serials.get(id) !== serial) return; + const previous = states.get(id); + update(id, { + value: result.relationship, + loading: false, + uncertain: false, + }); + if ( + previous?.uncertain || + (previous?.value && + (previous.value.isFollowing !== result.relationship.isFollowing || + previous.value.isFollowedBy !== result.relationship.isFollowedBy)) + ) { + window.dispatchEvent( + new CustomEvent(CREATOR_CHANGED_EVENT, { detail: { viewer, target } }), + ); + } + } catch (error) { + if (generation !== epoch || serials.get(id) !== serial) return; + update(id, { + loading: false, + error: error instanceof Error ? error.message : '读取关注状态失败', + }); + } +} +export async function mutateRelationship( + viewer: string, + target: string, + action: CreatorAction, +): Promise { + if (activeViewer !== viewer || viewer === target) return null; + const id = key(viewer, target), + generation = epoch; + const state = states.get(id) ?? EMPTY; + if (state.pending || state.loading || state.uncertain || !state.value) + return null; + serials.set(id, (serials.get(id) ?? 0) + 1); + update(id, { pending: true, loading: false, error: '' }); + const changed = () => + window.dispatchEvent( + new CustomEvent(CREATOR_CHANGED_EVENT, { detail: { viewer, target } }), + ); + try { + const result = await changeCreatorRelationship(target, action); + if (generation !== epoch) return null; + update(id, { + value: result.relationship, + pending: false, + uncertain: false, + }); + changed(); + return result.relationship; + } catch (error) { + if (generation !== epoch) return null; + const knownFailure = + error instanceof ApiClientError && + error.status >= 400 && + error.status < 500 && + error.status !== 408; + if (knownFailure) { + update(id, { pending: false, error: error.message }); + return null; + } + // 结果未知时只读正式状态,不重试写入或反转意图。 + try { + const result = await getCreatorRelationship(target); + if (generation !== epoch) return null; + update(id, { + value: result.relationship, + pending: false, + uncertain: false, + error: '请求未收到回执,已刷新关注状态', + }); + changed(); + return result.relationship; + } catch { + if (generation !== epoch) return null; + update(id, { + pending: false, + uncertain: true, + error: '状态未确认,请刷新关注状态后再操作', + }); + return null; + } + } +} diff --git a/src/components/creator/useCreatorRelationship.ts b/src/components/creator/useCreatorRelationship.ts new file mode 100644 index 000000000..d7836ba1d --- /dev/null +++ b/src/components/creator/useCreatorRelationship.ts @@ -0,0 +1,51 @@ +import { useEffect, useSyncExternalStore } from 'react'; + +import type { CreatorRelationship } from '../../../packages/shared/src/contracts/creator'; +import { type CreatorAction } from '../../services/creatorProfileClient'; +import { useAuthUi } from '../auth/AuthUiContext'; +import { + mutateRelationship, + readRelationship, + relationshipSnapshot, + seedRelationship, + setCreatorViewer, + subscribeRelationships, +} from './creatorRelationshipState'; + +export function useCreatorRelationship( + target: string, + initial?: CreatorRelationship | null, +) { + const auth = useAuthUi(); + const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null; + const state = useSyncExternalStore(subscribeRelationships, () => + relationshipSnapshot(viewer, target), + ); + useEffect(() => { + setCreatorViewer(viewer); + if (!viewer || viewer === target) return; + if (initial) seedRelationship(viewer, target, initial); + else if (initial === undefined) void readRelationship(viewer, target); + }, [viewer, target, initial]); + useEffect(() => { + if (!viewer || viewer === target || initial !== undefined) return; + const refresh = () => { + void readRelationship(viewer, target); + }; + window.addEventListener('focus', refresh); + return () => window.removeEventListener('focus', refresh); + }, [viewer, target, initial]); + return { + ...state, + viewer, + isSelf: viewer === target, + refresh: () => { + if (viewer) void readRelationship(viewer, target); + }, + mutate: (action: CreatorAction) => + viewer + ? mutateRelationship(viewer, target, action) + : Promise.resolve(null), + login: () => auth?.openLoginModal(), + }; +} diff --git a/src/components/game-distribution/GameCover.tsx b/src/components/game-distribution/GameCover.tsx new file mode 100644 index 000000000..ce21357de --- /dev/null +++ b/src/components/game-distribution/GameCover.tsx @@ -0,0 +1,54 @@ +import './gameDistribution.css'; + +import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; +import type { GameDistributionGame } from '../../services/gameDistributionClient'; + +/** + * 游戏封面。 + * + * 有平台封面素材时换签名地址后显示真实封面,未上传、换签失败或还在解析时保留原有的 + * 渐变色 + 图标占位,保证首屏不出现空框或布局跳动;`objectKey` 允许详情页把封面位临时 + * 展示成选中的截图。 + */ +export function GameCover({ + game, + compact = false, + priority = false, + objectKey, +}: { + game: GameDistributionGame; + compact?: boolean; + /** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */ + priority?: boolean; + objectKey?: string | null; +}) { + const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim(); + const { resolvedUrl } = useResolvedAssetReadUrl(null, { + objectKey: targetKey, + }); + return ( + + ); +} diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 1fee3520d..a6e5e2061 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -24,6 +24,7 @@ import { import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { CreatorAuthor } from '../creator/CreatorRelationshipControl'; import { normalizeGameEntryUrl, normalizeGameId, @@ -45,6 +46,7 @@ type GameDetailPageProps = { gameId: string | null; onBack: () => void; onPlay: (gameId: string) => void; + onOpenCreator?: (id: string) => void; /** * 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。 * @@ -105,6 +107,7 @@ export function GameDetailPage({ gameId, onBack, onPlay, + onOpenCreator, ownerView = false, }: GameDetailPageProps) { const authUi = useAuthUi(); @@ -254,6 +257,7 @@ export function GameDetailPage({ void; onBack: () => void; onPlay: (gameId: string) => void; + onOpenCreator?: (id: string) => void; }) { /** * 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架 @@ -325,6 +331,17 @@ function GameDetailContent({ + } coverObjectKey={game.coverObjectKey} screenshotObjectKeys={game.screenshots} coverColor={game.coverColor} diff --git a/src/components/game-distribution/GameGalleryPage.tsx b/src/components/game-distribution/GameGalleryPage.tsx index 867fc9e14..3f222d484 100644 --- a/src/components/game-distribution/GameGalleryPage.tsx +++ b/src/components/game-distribution/GameGalleryPage.tsx @@ -1,11 +1,10 @@ import './gameDistribution.css'; import './gameDistributionShowcase.css'; -import { GameRatingSummaryText } from '@genarrative/shared/components'; +import { GameCard } from '@genarrative/shared/components'; import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { type GameCategory, type GameDistributionGame, @@ -14,6 +13,7 @@ import { import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { GameCover } from './GameCover'; import { GAME_GALLERY_CATEGORIES as CATEGORIES, GAME_GALLERY_DEVICE_FILTERS as DEVICE_FILTERS, @@ -37,87 +37,6 @@ type GameGalleryPageProps = { onOpenPublish?: () => void; }; -/** - * 游戏封面。 - * - * 有平台封面素材时换签名地址后显示真实封面,未上传、换签失败或还在解析时保留原有的 - * 渐变色 + 图标占位,保证首屏不出现空框或布局跳动;`objectKey` 允许详情页把封面位临时 - * 展示成选中的截图。 - */ -export function GameCover({ - game, - compact = false, - priority = false, - objectKey, -}: { - game: GameDistributionGame; - compact?: boolean; - /** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */ - priority?: boolean; - objectKey?: string | null; -}) { - const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim(); - const { resolvedUrl } = useResolvedAssetReadUrl(null, { - objectKey: targetKey, - }); - return ( - - ); -} - -function GameCard({ - game, - onOpen, -}: { - game: GameDistributionGame; - onOpen: () => void; -}) { - return ( - - ); -} - export function GameGalleryPage({ searchKeyword = '', onOpenDetail, @@ -446,6 +365,7 @@ export function GameGalleryPage({
{visibleGames.map((game) => ( } key={game.id} game={game} onOpen={() => openDetail(game.id)} diff --git a/src/components/game-distribution/GamePlayPage.tsx b/src/components/game-distribution/GamePlayPage.tsx index f4f1efebd..6c46fe7fc 100644 --- a/src/components/game-distribution/GamePlayPage.tsx +++ b/src/components/game-distribution/GamePlayPage.tsx @@ -16,13 +16,13 @@ import { } from '../../services/gameDistributionClient'; import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { GameCover } from './GameCover'; import { normalizeGameEntryUrl, normalizeGameId, useIsMobileViewport, useIsPortraitViewport, } from './gameDistributionGuards'; -import { GameCover } from './GameGalleryPage'; type GamePlayPageProps = { gameId: string | null; onBack: () => void }; diff --git a/src/components/game-distribution/MyGamesPage.tsx b/src/components/game-distribution/MyGamesPage.tsx index d819e999a..d8a8fffa6 100644 --- a/src/components/game-distribution/MyGamesPage.tsx +++ b/src/components/game-distribution/MyGamesPage.tsx @@ -20,7 +20,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; import { PlatformEmptyState } from '../common/PlatformEmptyState'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { GameCover } from './GameGalleryPage'; +import { GameCover } from './GameCover'; import { type GamePublishDraft, readPublishDraft, diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index ea2a86285..1fb8eb6c1 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -188,71 +188,6 @@ grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.1rem; } -.game-card { - display: block; - min-width: 0; - overflow: hidden; - border: 1px solid var(--platform-surface-border); - border-radius: 1.45rem; - padding: 0; - background: var(--platform-panel-fill); - color: inherit; - cursor: pointer; - text-align: left; - box-shadow: var(--platform-panel-shadow); - transition: 180ms ease; -} -.game-card:hover { - border-color: rgba(199, 101, 61, 0.38); - box-shadow: 0 1rem 2.4rem rgba(104, 54, 36, 0.14); - transform: translateY(-3px); -} -.game-card__body { - display: block; - padding: 0.95rem 1rem 1.1rem; -} -.game-card__title-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; -} -.game-card__title-row strong { - overflow: hidden; - font-size: 1rem; - text-overflow: ellipsis; - white-space: nowrap; -} -.game-card__play-count { - flex: none; - color: var(--platform-text-soft); - font-size: 0.66rem; -} -.game-card__summary { - display: block; - overflow: hidden; - margin-top: 0.42rem; - color: var(--platform-text-base); - font-size: 0.78rem; - line-height: 1.55; - text-overflow: ellipsis; - white-space: nowrap; -} -.game-card__rating { - display: block; - margin-top: 0.42rem; - color: var(--platform-text-base); - font-size: 0.78rem; - line-height: 1.55; - overflow-wrap: anywhere; -} -.game-card__meta { - display: flex; - gap: 0.38rem; - margin-top: 0.85rem; - color: var(--platform-text-soft); - font-size: 0.7rem; -} .game-cover { position: relative; display: flex; diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index d2bccb582..fe4393164 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -456,7 +456,7 @@ describe('PlatformEntryActiveFlowShell', () => { within(navigation) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['创作', '项目', '游戏', '我的']); + ).toEqual(['创作', '项目', '游戏', '创作者主页', '我的']); expect( within(navigation) .getByRole('button', { name: '创作' }) @@ -522,7 +522,7 @@ describe('PlatformEntryActiveFlowShell', () => { within(navigation) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['游戏', '我的']); + ).toEqual(['游戏', '创作者主页', '我的']); expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy(); expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy(); expect( @@ -607,7 +607,7 @@ describe('PlatformEntryActiveFlowShell', () => { within(navigation) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual(['游戏', '我的']); + ).toEqual(['游戏', '创作者主页', '我的']); expect( within(navigation) .getByRole('button', { name: '我的' }) diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 128133c6d..98183f125 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -6,6 +6,7 @@ import { Palette, Search, UserRound, + UsersRound, } from 'lucide-react'; import { type ComponentType, @@ -27,6 +28,11 @@ import { pushAppHistoryPath, replaceAppHistoryPath, } from '../../routing/activeAppPageRoutes'; +import { + connectionsPath, + type CreatorConnectionKind, + creatorPath, +} from '../../services/creatorProfileClient'; import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore'; @@ -35,6 +41,7 @@ import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry'; +import { setCreatorViewer } from '../creator/creatorRelationshipState'; import { PaymentCheckoutView } from './PaymentCheckoutView'; import { PlatformActiveMobileWelcomeDialog, @@ -80,6 +87,14 @@ const GameGalleryPage = lazy(async () => { return { default: module.GameGalleryPage }; }); +const CreatorPage = lazy(async () => { + const module = await import('../creator/CreatorPage'); + return { default: module.CreatorPage }; +}); +const CreatorConnectionsPage = lazy(async () => { + const module = await import('../creator/CreatorConnectionsPage'); + return { default: module.CreatorConnectionsPage }; +}); const GameDetailPage = lazy(async () => { const module = await import('../game-distribution/GameDetailPage'); return { default: module.GameDetailPage }; @@ -181,16 +196,20 @@ function MobileProfileDock({ onOpenProfile, gameActive, onOpenGames, + creatorActive, + onOpenCreator, }: { active: boolean; onOpenProfile: () => void; gameActive: boolean; onOpenGames: () => void; + creatorActive: boolean; + onOpenCreator: () => void; }) { return (