补充创作者主页与关注粉丝工程文档 (#638)
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
Project CI / AI game creator shell Rust crates (push) Successful in 5m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m36s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 7m37s
Project CI / Backend tests (push) Successful in 9m22s
Project CI / Frontend tests (push) Successful in 4m40s
Project CI / AI game creator shell web tests (push) Successful in 3m50s
Project CI / Repository checks (push) Successful in 6m30s
Project CI / Native shell tests (push) Successful in 8m32s
本 PR 补齐创作者主页与关注粉丝功能的产品合同和工程设计,供后续按里程碑实施;本次只修改文档。 桌面导航第四项进入自己的创作者主页,“我的”移到第五项。游戏详情可进入作者主页并关注/取消关注;自己或他人的关注、粉丝列表均公开可查看,点击用户头像或昵称可进入其主页。本人支持取消/重新关注、回关和移除粉丝,并明确两个关注方向独立。 工程设计包含关系表与 DTO、认证和事务边界、跨页状态同步、共享组件、深链及验收要求,拆分关系查询和页面交互两个里程碑。按用户要求,游戏列表沿用最多 48 项的现有限制,不增加游戏目录分页改造。 验证: - 已合入最新 origin/master(21c79f54a);共享决策记录冲突保留双方新增内容。 - npm run check:encoding 通过。 - npm run check:doc-index 通过。 - git diff --check 与暂存差异检查通过。 - 相对 master 仅六份文档发生变化;没有业务代码或 schema 修改,未执行业务测试或部署。 Reviewed-on: #638 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #638.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,4 +109,4 @@ curl -sSI -H 'Accept-Encoding: br' \
|
||||
- 条件请求:命中 `If-None-Match` 时发行网关直接返回 `304`(无正文,保留 `Cache-Control` 与 `ETag`),所以 60 秒 `max-age` 之后的重复游玩只重验证、不重下整包;下架与换版仍按 60 秒窗口在新请求上生效。
|
||||
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
|
||||
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
|
||||
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 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/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
- [当前产品与工程约束](./【项目基线】当前产品与工程约束-2026-05-15.md):现役入口、账号钱包、UI 和后端分层。
|
||||
- [平台入口与玩法链路](./【玩法创作】平台入口与玩法链路-2026-05-15.md):平台壳、图片画布、游戏分发与在线游玩合同;网站游戏评分与评价已实现并通过本地验证,待用户验收,未部署。
|
||||
- [网站游戏评分与评价里程碑](./project-memory/plans/【里程碑】网站游戏评分与评价-2026-09-30.md):唯一评价、编辑预填、4000 字符、公共分页与平均分/人数的验收边界与本地证据。
|
||||
- [创作者主页与关注粉丝合同](./【玩法创作】平台入口与玩法链路-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):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-05 创作者主页与关注粉丝的产品边界
|
||||
|
||||
- 产品已确认:桌面第四项“创作者主页”默认进入当前账号主页,“我的”移到第五项;他人的关注/粉丝列表公开可查看,自己或他人的两类列表均可点击用户进入其创作者主页。
|
||||
- 关注为单向关系,取消回关与移除粉丝分别影响不同方向;只有本人可移除自己的粉丝,自己不能关注自己。
|
||||
- 他人的关注、粉丝列表统一只读:保留头像/昵称进入用户主页,不显示任何关系操作按钮;前后端均不额外检测访问者与列表用户的关注关系,已有缓存也不用于显示关系动作。
|
||||
- 已确认:移动端入口为“游戏 / 创作者主页 / 我的”;自己的主页也只展示公开游戏;自己的关注列表取消后当前行暂留以便重新关注;移除粉丝二次确认,取消关注不弹确认。
|
||||
- 用户明确本次不额外改造游戏目录分页:既有游戏广场和“我的游戏”保持现状,作者主页按作者过滤后沿用最多 48 项限制。新关注/粉丝列表的分页仍按主规范设计。
|
||||
- 关系纯规则放在 `module-auth::creator`,现有认证服务通过 `services` feature 隔离宿主依赖,数据库 WASM 只使用纯规则。私有 `user_follow` 不参与认证快照替换;只通过受信服务过程读写,HTTP 操作者取认证身份。
|
||||
- 行为真相见[创作者主页与关注粉丝合同](../../【玩法创作】平台入口与玩法链路-2026-05-15.md#创作者主页与关注粉丝合同),工程落点见[工程设计](../../technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md)。后端已完成隔离验证并由用户验收通过;页面工程验证通过,用户已要求提交并推送;证据归并工程设计,已完成临时计划删除,未上线。
|
||||
|
||||
## 2026-10-03 首页自动建项与 AI 项目命名解耦(Issue 599)
|
||||
|
||||
- 背景:首页「开启创作」原先串行执行「Web 预检 → `await suggest_automatic_project_name` → `create_automatic_local_game_project`」。项目名称不是创建工作区、导入附件或发起首轮创作的前置条件,命名请求(`AUTOMATIC_PROJECT_NAME_TIMEOUT_MS = 15s`,正常请求同样占时)却把用户按在「正在创建工作区」上。
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
|
||||
|
||||
## 2026-10-05 自定义作者插槽应保留昵称降级语义
|
||||
|
||||
- 游戏公开投影里的「创作者」「未知作者」是角色占位词。作者昵称 hook 已将加载中和查询失败分别转成 null 与空串,宿主不能再用 `|| game.author.name` 把占位词补回。
|
||||
- 替换共享详情组件的作者插槽时,同时接管了默认作者行的空名降级;只展示解析后的昵称,空名仍保留有可访问名称的主页入口,关注操作按作者 ID 和关系状态控制。回归覆盖补查等待、成功、空名和失败。
|
||||
|
||||
## 2026-10-05 页面返回兜底不能新增历史条目
|
||||
|
||||
- 普通链接整页打开的条目可能没有应用导航标记;此时返回若调用 `pushAppHistoryPath`,再点返回就会退回原深链,形成循环。无应用内历史的返回应使用 `replaceAppHistoryPath`,有历史时保留原生后退。
|
||||
- 创作者主页顶部资料不自链接;详情和关系列表中的用户链接保留。具体兜底目标见创作者专题合同;验证须覆盖真实浏览器连续返回及列表分页/滚动恢复,不能只检查 `history.back()` 被调用。
|
||||
|
||||
## 2026-10-05 运行画面点选的高亮框被尺寸上报当成页面内容:点选后预览自己缩放
|
||||
|
||||
- **现象**:three 项目里点选 3D 对象后预览画面自己缩放、视角异常(相机 aspect 与重新适配的距离都变了),点选结束后视口仍停在放大后的尺寸。实测夹具里是**自激振荡**:10 秒内 1459 条尺寸消息、1457 次 iframe 尺寸/缩放变更、1454 次 `resize`,相机 aspect 在 1.607143 ↔ 1.421112 之间来回变。
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# 创作者主页与关注粉丝工程设计
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| 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/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,复用现有连接和受信服务身份 |
|
||||
| HTTP | `api-server/src/modules/creator.rs` 与 `modules`/`app.rs` 注册;认证由现有 access token 校验链提供 |
|
||||
| 作者游戏 | 扩展现有 game-distribution 列表 query、共享内部输入及 facade/procedure,增加可选 `authorId` 精确过滤 |
|
||||
| 深链部署 | 按新增稳定路由同步 nginx SPA 路由及 Pingora 路由对照,覆盖直接访问、刷新与未知路径回退 |
|
||||
|
||||
以上新增文件是拟定落点,实施时可按相邻模块组织拆分,但不得建立第二套身份、作品或数据访问系统。不涉及 AGC 桌面客户端、外部 OpenAPI、后台管理页或游戏运行态。
|
||||
|
||||
源码已确认:公开游戏列表和“我的游戏”一次最多取 48 项,公开列表 `nextCursor` 固定为 null,前端 `listGames` 只返回数组;游戏广场的设备过滤发生在这份数组上。用户明确本次不额外改造这些现状。创作者主页只增加服务端作者过滤,沿用最多 48 项和原排序,不增加游戏分页、加载更多或新的筛选控件。
|
||||
|
||||
## 关系模型和授权
|
||||
|
||||
私有表 `user_follow`:
|
||||
|
||||
| 字段 | 类型与约束 |
|
||||
| --- | --- |
|
||||
| `relationship_id` | String 主键;编码为关注方 ID 的 UTF-8 字节长度、冒号、关注方 ID、被关注方 ID,保证有向用户对无歧义唯一 |
|
||||
| `follower_user_id` | String,关注发起方;建立对应索引 |
|
||||
| `followee_user_id` | String,被关注方;建立对应索引 |
|
||||
| `created_at` | Timestamp,只在关系首次建立时取事务时间;重复关注不改时间 |
|
||||
|
||||
同一用户对最多一行,双方不得相同。删除后重新关注视为新的建立时间。不另存关系计数、昵称、头像或互关布尔值;读时按对应方向索引计算,并以 `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 已取消关注。用户不再存在时不暴露其资料,计数和列表同步排除;本次不增加账号注销或清理任务。
|
||||
|
||||
## HTTP 与 DTO
|
||||
|
||||
接口路径、方法和错误状态见父规范,统一复用现有成功/错误响应信封。以下形状是信封内的数据字段,Rust 使用 camelCase 序列化,TS 同名。
|
||||
|
||||
```ts
|
||||
type CreatorUser = {
|
||||
id: string;
|
||||
publicUserCode: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
type CreatorRelationship = {
|
||||
isSelf: boolean;
|
||||
isFollowing: boolean;
|
||||
isFollowedBy: boolean;
|
||||
};
|
||||
type CreatorProfile = {
|
||||
user: CreatorUser;
|
||||
followingCount: number;
|
||||
followerCount: number;
|
||||
};
|
||||
type CreatorConnection = {
|
||||
user: CreatorUser;
|
||||
followedAt: string; // 当前列表所表示方向的建立时间,RFC 3339
|
||||
relationship: CreatorRelationship | null; // 仅本人读取自己的列表时返回,其他情况为 null
|
||||
};
|
||||
type CreatorConnections = {
|
||||
items: CreatorConnection[];
|
||||
nextCursor: string | null;
|
||||
total: number;
|
||||
};
|
||||
type CreatorRelationshipResponse = {
|
||||
userId: string; // 相对当前访问者的目标;移除粉丝时为 followerId
|
||||
relationship: CreatorRelationship;
|
||||
};
|
||||
```
|
||||
|
||||
`CreatorUser` 从现有公开资料投影所需字段,不返回手机号、登录名、钱包或认证数据。主页返回 `CreatorProfile`;关系列表返回 `CreatorConnections`;关系 GET 与三种写操作返回 `CreatorRelationshipResponse`。写请求无需 body,身份只来自认证,路径 ID 按统一规则 trim、校验并安全编码。
|
||||
|
||||
关系写操作成功返回 200,目标存在时重复关注/删除仍成功;对自己操作返回 400,未认证为 401,非受信或越权调用为 403,不存在的用户为 404。有效目标但不存在关系的删除与“目标不存在”明确区分。前端不自动重试结果未知的写请求,先回读关系。
|
||||
|
||||
主页摘要一次事务内完成用户和双向计数读取;关系列表一次事务内读取 `items/total`,仅已认证访问者 ID 等于列表主人时附带逐行关系。游客或访问他人列表时,following 只查询主人出边、followers 只查询主人入边及对应公开用户资料,不额外探测访问者与列表用户的关系,逐行 `relationship` 固定为 null。该条件由后端认证身份与路径主人 ID 决定,不接受客户端开关绕过。不同 HTTP 请求不承诺共享同一事务快照,跨页变化通过写后失效与回读收敛,不能要求两次独立请求永远返回相同计数。
|
||||
|
||||
公开主页不携带访问者状态;`relationship` 端点必须认证。列表无 Authorization 时按游客读取,有 Authorization 时校验并判断是否本人列表;他人的关注、粉丝列表始终跳过访问者关系计算,只有自己的列表批量返回所需关系。无效凭据返回 401,前端经现有认证处理后可回到游客读取。关系端点和带认证列表响应使用 `Cache-Control: private, no-store`,不得进入跨用户缓存。
|
||||
|
||||
## 关系列表分页与作者游戏读取
|
||||
|
||||
关注/粉丝列表的查询参数为 `limit`、`cursor`;默认 20,上限 50,非整数或超范围返回 400。按 `(created_at DESC, relationship_id DESC)` 排序,游标包含版本、列表主人、following/followers 类型与末项排序键;使用有长度上限的 base64url JSON 编码并严格解析,时间戳用十进制字符串避免 JS 精度损失。游标只是分页定位,不作为授权证明。
|
||||
|
||||
先排除不存在的对端,再计算 total 和分页;读取 limit+1 判断是否还有下一页。同时间多行不漏读,游标主人或类型不匹配返回 400。并发增删不保证多页构成冻结快照,按 userId 去重,刷新从首批开始;不引入跨请求数据库快照或游标持久化表。
|
||||
|
||||
作者游戏只扩展现有 `GET /api/game-distribution/games` 的 `authorId`:未传保持原行为,显式空值返回 400;先按稳定 owner ID 和既有公开条件过滤,再排序、截取最多 48 项。现有条件包含 published、未删除及有效公开版本。前端 `GameListQuery` 增加可选 authorId,仍返回现有游戏数组;不修改游戏表结构或引入新的游戏列表系统。
|
||||
|
||||
## 前端状态与组件责任
|
||||
|
||||
- 主页 `/creators` 解析当前账号,`/creators?id=...` 读取指定公开用户;关系列表用 `/creators/connections?id=...&tab=following|followers`,缺主人或非法 tab 展示明确无效链接状态。tab 缺省、空串及其他值均无效,不请求关系列表,并保留返回入口。显式他人 ID 不因登录切换改成自己的主页。
|
||||
- 游戏详情作者昵称只使用 `useGameAuthorDisplayName` 的解析结果,加载中或查询失败时不回退到公开投影的角色占位词;保留头像与可访问的主页链接,关注操作继续按作者 ID 和关系状态控制,不依赖昵称补查成功。
|
||||
- 关系状态按访问者 ID、目标 ID 隔离。业务 hook/client 负责请求、写入单飞、失败回读和失效;共享关注按钮、用户行只接收数据、pending 和回调,不直接持有认证或网络副作用。
|
||||
- 自己/他人的关注列表、粉丝列表四种组合均支持头像/昵称进入任意用户主页;用独立链接和按钮避免嵌套交互。列表用户为自己时隐藏关注按钮,主页仍可访问。
|
||||
- 他人的关注、粉丝列表统一使用只读用户行,不挂载关系动作或触发关系 hook,不调用 relationship 补查接口、不比对访问者自己的关注集合,也不使用预存关系缓存推导按钮。列表切换或账号切换后重新按主人/访问者身份确定模式;进入目标用户主页后,再按主页本身的规则读取关系和提供关注操作。
|
||||
- 取消关注后的行保留集合只属于当前列表生命周期,与服务端成员和 total 分离;成功重新关注后清除暂留标记。刷新、离开列表或账号变化清除暂留集合;正常失效回读时不可把可重新关注的行立即抹掉。
|
||||
- 移除粉丝成功从粉丝列表去行,回关/取消回关不去行。确认弹窗复用公共组件;两个方向的按钮在同一目标写入期间一起禁用,写后刷新摘要和相关列表。
|
||||
- 进入页面、返回和重新聚焦时回读;旧请求通过请求序号/取消机制隔离。认证变化清除私有关系缓存和旧页请求,不让上个账号响应覆盖新账号。
|
||||
- 新主页路由接入页面标题、导航高亮、返回兜底和滚动位置恢复。移动点击区域至少 44px;加载、错误、空态分别呈现,不因关系失败阻断公开游戏阅读。
|
||||
- 返回有应用内历史时使用 `history.back()`;没有时使用 `replaceAppHistoryPath`,关系列表回所属主页,主页回 `/games`,再同步页面状态,禁止通过 `openCreator` 新增兜底历史。主页顶部 `CreatorUserRow` 不传 `href`,渲染无链接、无额外键盘焦点的资料区域;列表和游戏详情继续传入链接与导航回调。
|
||||
|
||||
- 顶层访问层登记到 `vite.config.ts` 的现役模块白名单及 ESLint 范围,开发代理显式转发 `/api/creators`。页面深链同步三份 nginx 模板与 Pingora 路由清单。
|
||||
- 浏览器历史条目仅保存 URL、访问者、已加载页数和滚动位置,不持久化关系列表或暂留行;新导航清除继承的恢复标记。同一舞台切换用户、页签以及返回时,应用重读 URL 并重新渲染。
|
||||
|
||||
## 兼容、验证和交付
|
||||
|
||||
新关系表从空数据开始;同步迁移登记、后端表目录和 Rust 生成绑定。新增 procedure 与 facade 同步发布,不手改生成文件;游戏查询新增的 authorId 是可选能力,旧 HTTP 请求继续有效。后端部署先于新前端,回滚前端不删除关系数据;回滚后端必须保留新表的兼容 schema,禁止删库回退。
|
||||
|
||||
后端已由用户验收通过;页面工程验证完成后,用户要求提交并推送。已将持久结论和验证证据归并到主规范及本文,删除已完成的临时里程碑和实施计划。
|
||||
|
||||
按范围执行以下命令,后端与前端证据见本文对应章节;本文保留逐项验收对照:
|
||||
|
||||
```bash
|
||||
cargo test --locked --manifest-path server-rs/Cargo.toml -p module-auth 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
|
||||
npm run spacetime:generate
|
||||
npm run check:server-rs-ddd
|
||||
npm run test -- src/components/creator src/routing/activeAppPageRoutes.test.ts src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx src/components/game-distribution/GameDistributionPages.test.tsx
|
||||
npm run typecheck
|
||||
npm run check:nginx-spa-routes
|
||||
npm run check:pingora-route-parity
|
||||
npm run check:encoding
|
||||
npm run check:doc-index
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`creator` 测试组为实施时新增的定向分组,届时核对实际匹配数量,零匹配不算通过;补充共享 DTO/UI 和 facade 的对应测试。host 单测不替代 SpacetimeDB 事务验证,运行时通过 `npm run dev:api-server` 启动并验证实际开发地址 `/healthz`,使用隔离开发库的 A/B/C 三账号完成权限、重复写入、双向关系与未知结果回读。
|
||||
|
||||
浏览器覆盖桌面与 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` 指向临时浏览器目录。截图输出到临时目录,不提交登录信息、数据库或构建产物。
|
||||
|
||||
返回循环修复的定向验证:共享控件、创作者页面、平台导航与路由共 54 项测试通过,类型、定向 ESLint、编码与文档索引检查通过。真实浏览器确认裸主页点击本人资料不改变地址、返回到游戏广场;直接打开关系列表后连续返回依次到所属主页和广场;加载两页 40 行后进入用户主页,原生返回恢复全部行及滚动位置。
|
||||
|
||||
验证边界:浏览器使用真实 API/SpacetimeDB;超时、旧请求与账号切换由可控前端测试覆盖,没有声称做真实断网注入。游戏夹具无发行包,不验证游玩加载;未部署生产,未用真实用户数据。本次交付按用户指令提交并推送,生产部署另行执行。
|
||||
@@ -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`
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
交付“AGC 一键提交游戏 / 网页上传游戏 ZIP → 后端收取真实发行包 → 校验与人工审核 → 主站发现、详情、游客在线游玩 → 更新与下架”的完整闭环。验收必须使用真实上传、真实存储、真实审核状态和隔离发行域名;静态演示卡片、metadata-only 请求或前端本地发布状态不能作为完成证据。
|
||||
|
||||
首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。本发行合同 Version 0.2 不包含用户评分与评论,其扩展范围见下文“网站游戏评分与评价合同”;关注、榜单、推荐算法、交易和创作者收入仍不在范围内。
|
||||
首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。本发行合同 Version 0.2 不包含用户评分与评论,其扩展范围见下文“网站游戏评分与评价合同”;关注扩展见下文“创作者主页与关注粉丝合同”提案,榜单、推荐算法、交易和创作者收入仍不在范围内。
|
||||
|
||||
### 入口与产品体验
|
||||
|
||||
@@ -551,3 +551,117 @@
|
||||
|
||||
- 管理员权限继续复用现有游戏审核权限映射,还是单独增加 `game-review-preview` 动作权限;第一里程碑默认复用现有审核权限,不扩大权限模型。
|
||||
- 预览 Token 的单次 HTML 读取与同包静态资源多次读取需要统一 TTL 和失效规则;第一里程碑使用短 TTL 的版本绑定会话。
|
||||
|
||||
## 创作者主页与关注粉丝合同
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | implemented(工程验证通过,用户已确认提交交付,未部署) |
|
||||
| Date | 2026-10-05 |
|
||||
| 范围 | 主站导航、公开创作者主页、游戏详情作者入口、关注关系与列表管理 |
|
||||
|
||||
### 目标与当前基线
|
||||
|
||||
让用户从游戏详情进入任意作者主页、查看作者公开游戏、关注或取消关注,并在自己的列表管理关注和粉丝。验收以真实账号关系、计数、公开游戏过滤及跨页回读一致为准。
|
||||
|
||||
2026-10-05 实现状态:桌面导航为“创作 / 项目 / 游戏 / 创作者主页 / 我的”,移动端为“游戏 / 创作者主页 / 我的”;`activeAppPageRoutes` 已接入创作者主页和关系列表。`GameDetailPage` 通过共享 `GameDetailDisplay` 的作者插槽提供主页链接和独立关注按钮。私有关注关系表、查询/写入 API 以及页面交互已实现;后端已由用户验收,前端工程验证通过,用户已要求提交并推送。本文前部旧入口说明不作为本专题的导航基线。
|
||||
|
||||
必须项为入口、主页、双向列表查看、关注/取消关注/重新关注、本人移除粉丝、公开游戏列表及详情跳转。风险项为关系方向、越权、公开/私有游戏隔离、并发请求与关系列表分页回读。私信、关注动态、通知、拉黑、推荐榜单、主页装修、关系隐私设置和既有游戏目录分页改造不纳入首版。
|
||||
|
||||
### 页面与导航
|
||||
|
||||
- 桌面导航保留前三项顺序,新增第四项“创作者主页”,“我的”变为第五项。第四项默认进入当前登录账号自己的主页(用户已确认)。
|
||||
- 移动端为“游戏 / 创作者主页 / 我的”,保留创作、项目的现有桌面限制,不把五项桌面入口强行塞入底部导航。
|
||||
- 主页路由 `/creators?id=<userId>`;裸 `/creators` 表示自己的主页,登录后解析当前账号。显式 ID 深链可刷新、分享及直接访问,自己的主页与他人的主页共用页面。
|
||||
- 列表路由 `/creators/connections?id=<userId>&tab=following|followers`,关注数和粉丝数分别进入对应列表;返回恢复来源页及分页/滚动位置,直接访问的返回兜底为所属主页。
|
||||
- 主页和关系列表的返回优先使用应用内浏览器历史;没有可回退的应用内历史时,列表替换当前条目到所属主页,主页替换当前条目到游戏广场 `/games`。兜底返回不得新增历史条目,避免连续返回在主页地址之间循环。
|
||||
- 主页顶部的头像、昵称和陶泥号只展示当前作者资料,不链接到当前主页;游戏详情及关系列表的作者链接保持可点击,包括当前登录用户本人。
|
||||
- 主页展示头像、昵称、陶泥号、关注按钮、`关注数 | 粉丝数` 和公开游戏列表。没有游戏或关系时显示真实空态,接口失败展示重试,不以 0 或空列表冒充成功。
|
||||
- 他人主页按钮为“关注”或“已关注”,点击“已关注”执行取消关注;自己的主页隐藏该按钮,不允许关注自己。按钮的可访问名称明确表达“取消关注”。
|
||||
- 游戏详情的作者头像/名称可点击进入主页,包括本人;作者旁增加同一套关注按钮。作者链接和关注按钮是独立点击目标,不因关注同时触发跳转;自己的游戏隐藏关注按钮。
|
||||
- 主页游戏卡复用现有公开卡片和详情路由,只列该用户仍然公开、未删除且有有效公开版本的游戏。自己的主页也遵循公开口径;未公开、审核中、下架游戏继续在 `/games/mine` 管理。
|
||||
- 游戏列表沿用现有读取限制:服务端先按作者和公开可见性筛选,再按创建时间倒序、游戏 ID 升序排序,最多返回 48 项;`nextCursor` 仍为空,不增加游戏分页或加载更多。不能下载广场前 48 项后在前端过滤作者。游戏广场和“我的游戏”保持原状;用户已明确本次不做额外改造。
|
||||
|
||||
### 关系方向与列表操作
|
||||
|
||||
关系 `A → B` 表示 A 关注 B,同时 B 获得粉丝 A;反方向独立存在。“关注数”统计出边,“粉丝数”统计入边,互相关注不是第三种持久化状态。
|
||||
|
||||
| 场景 | 可用操作 | 正式关系变化 |
|
||||
| --- | --- | --- |
|
||||
| A 查看 B 主页或 B 的游戏详情 | 关注/取消关注 | 添加/删除 `A → B` |
|
||||
| A 查看自己的关注列表中的 B | 取消关注;取消后重新关注 | 删除/恢复 `A → B` |
|
||||
| A 查看自己的粉丝列表中的 B | 回关/取消关注;移除粉丝 | 前两项只改变 `A → B`;移除只删除 `B → A` |
|
||||
| A 查看他人的关注列表或粉丝列表 | 仅查看用户资料、点击进入主页;无关系操作按钮 | 不读取或改变 A 与列表用户的关注关系 |
|
||||
| 游客查看主页或列表 | 浏览;主页点击关注唤起登录,关系列表只读 | 未认证不写关系 |
|
||||
|
||||
关注/粉丝列表公开可查看(用户已确认),包括无已发布游戏用户的主页。仅允许本人移除自己的粉丝,任何客户端传入的主人 ID 都不能替代服务端认证身份。
|
||||
|
||||
列表行包含公开头像、昵称和陶泥号;只有本人查看自己的列表时展示关系状态和操作。他人的关注列表、粉丝列表统一只读,不显示关注、取消关注、重新关注或移除粉丝按钮,不查询或比较访问者与列表用户的关系,不使用已有关系缓存补出按钮。无论查看自己的还是他人的关注列表、粉丝列表,点击任意用户的头像或昵称都进入该用户的创作者主页(用户已确认);游客同样可以跳转,列表中的用户即使是自己或尚未发布游戏,也使用同一主页入口。自己的列表中,关注、取消关注与移除粉丝按钮是独立操作,不同时触发主页跳转;从用户主页返回时恢复来源列表及分页/滚动位置。
|
||||
|
||||
自己的关注列表取消后,当前已加载行暂留并显示“重新关注”,计数以服务端结果更新;重新进入或刷新后该行不再属于正式列表。这是临时 UI 行保留,不是第二份关系。粉丝列表取消回关不移除粉丝行,因为入边没有改变。
|
||||
|
||||
“移除粉丝”使用现有共享确认弹窗,确认后删除入边并移除当前行,保留自己对对方的关注。移除不是拉黑,对方仍可再次主动关注;本人无法代替对方恢复这条入边。取消关注默认不二次确认,支持在当前行重新关注。
|
||||
|
||||
### 登录、失败与一致性
|
||||
|
||||
- 主页和列表公开部分允许游客读取;登录后才查询访问者关系。裸主页入口未登录时展示登录入口,不猜测账号;登录成功进入自己的主页。游客在他人页面点击关注,登录后返回原目标并回读关系,再由用户执行关注。
|
||||
- 账号切换、退出、目标作者变化时清除访问者关系和列表临时保留行,失效旧请求;旧账号或旧页面的迟到响应不能覆盖当前页面。
|
||||
- 同一目标写操作进行中禁用重复点击。服务端接口表达“设为已关注/未关注”,不使用 toggle;重复相同请求不重复插行、不重复计数、不刷新既有关注时间。相反方向的并发操作按服务端事务提交顺序生效,回到页面或重新聚焦时回读。
|
||||
- 写入成功后回读当前主页摘要和受影响列表;游戏详情、主页和列表使用按访问者与目标区分的共享状态失效机制,防止各维护一份长期布尔值。
|
||||
- 超时或断网后不能断言写入失败:提示“状态未确认”,先查询正式关系,再允许重试当前意图;不得自动反转或重放陈旧的相反操作。确定失败保留已确认状态并显示错误。
|
||||
- 公共资料与访问者关系分开读取或使用正确的私有缓存策略,不能把某个用户的 `isFollowing` 放入匿名公共缓存。本人列表的分页响应批量查询所需关系,避免逐行网络查询;他人的关注、粉丝列表均跳过此查询,前端也不补查。
|
||||
- 不存在或不可公开的账号返回不可访问状态;资料只投影公开用户字段,不包含手机、会话、钱包或第三方身份凭据。计数与列表对不可公开账号使用相同过滤条件。
|
||||
|
||||
### 数据与接口合同
|
||||
|
||||
关注属于账号关系,复用现有账号身份,不新建创作者账号系统。在现有 `module-auth` 内划分关系子模块承载纯规则,SpacetimeDB 保存事实和事务,`spacetime-client` 提供 facade,`api-server` 提供 HTTP/BFF,Rust/TypeScript 共享 DTO 同步更新。
|
||||
|
||||
新增私有关注关系表:无歧义的有向用户对主键、`follower_user_id`、`followee_user_id`、`created_at`,分别支持关注方和被关注方索引。用户对唯一且不得相同;不保存昵称/头像副本。首版从关系索引计算准确计数,每次摘要或列表请求内部使用一致事务快照,不同 HTTP 请求通过回读收敛;如量级要求额外计数投影,须先补充事务一致性方案。
|
||||
|
||||
| 接口 | 用途与权限 |
|
||||
| --- | --- |
|
||||
| `GET /api/creators/{userId}` | 公开资料、关注数、粉丝数;游客可读 |
|
||||
| `GET /api/creators/{userId}/following` | 公开关注列表,游标分页 |
|
||||
| `GET /api/creators/{userId}/followers` | 公开粉丝列表,游标分页 |
|
||||
| `GET /api/creators/{userId}/relationship` | 登录访问者与目标的 `isSelf/isFollowing/isFollowedBy` |
|
||||
| `PUT /api/creators/{userId}/follow` | 当前登录用户关注目标,幂等 |
|
||||
| `DELETE /api/creators/{userId}/follow` | 当前登录用户取消关注目标,幂等 |
|
||||
| `DELETE /api/creators/me/followers/{followerId}` | 当前登录用户移除自己的粉丝,幂等 |
|
||||
| 扩展 `GET /api/game-distribution/games?authorId=<userId>` | 服务端精确作者过滤,复用公开游戏可见性与卡片契约 |
|
||||
|
||||
以上接口已在本地隔离环境实现并验证,未部署。关注/粉丝列表默认 20、上限 50,按关系建立时间与稳定身份倒序,返回 `items/nextCursor/total`;游标绑定主人和列表类型,非法/错域游标返回 400。增删期间重新加载可能改变页内成员,前端按用户 ID 去重,重新关注产生新的建立时间。此分页规则仅用于新关系列表;游戏列表继续最多读取 48 项,不实现分页。
|
||||
|
||||
关系写入成功返回 200 与当前访问者关系;重复删除不存在的关系仍成功。不合法输入或关注自己返回 400,未认证 401,越权 403,目标不存在或不可公开 404。本人读取自己的关系列表时以有效认证批量附带各行关系;游客和他人的关注、粉丝列表该字段为 null,后者即使已登录也不计算逐行关系。带关系的响应禁用共享缓存,认证失效时清除访问者关系并按游客重新读取。
|
||||
|
||||
此次只新增关系表和站内接口,不改现有账号/游戏主键,不迁移出虚构的历史关注,也不扩充 external v1。实施时同步 `migration.rs`、表目录、生成绑定和 schema 检查;若后续需要破坏性修改已有表,须另行确认迁移计划。
|
||||
|
||||
### 分阶段交付与验收
|
||||
|
||||
1. 关系与公开查询:先交付真实持久化、权限、幂等、双向查询、准确计数、公开资料和作者游戏筛选;通过 API 与数据库运行时验收后进入页面接入。
|
||||
2. 页面与交互:接入导航、主页、列表、详情作者链接、共享关注控件和移除弹窗,完成真实账号桌面/移动端闭环验收。
|
||||
|
||||
每阶段先评审里程碑,再编写该阶段的单独实施计划。后端阶段已完成工程实现及隔离验证并由用户验收通过,页面阶段的工程与真实浏览器验证已通过,用户已确认提交交付;证据保留在工程设计,已完成的临时计划删除,不以单元测试替代真实运行时验证。
|
||||
|
||||
| 验收条款 | 证据要求 |
|
||||
| --- | --- |
|
||||
| 桌面第四/第五项与移动三入口;自己/他人深链、刷新、返回 | 路由测试与桌面、375px 移动浏览器操作 |
|
||||
| A 关注 B、取消、重新关注;A/B 各自正确计数 | 两个真实账号的 API 与数据库回读 |
|
||||
| 互关后 A 移除 B 粉丝,`A → B` 保留、`B → A` 消失 | 定向事务测试与真实 API;重复移除不重复扣数 |
|
||||
| 取消关注后当前行可重新关注;刷新后正式列表正确 | UI 定向测试与浏览器操作 |
|
||||
| 自己/他人的关注/粉丝四类列表均可点击用户进入主页,操作按钮不误跳转 | 游客和登录态浏览器验证,覆盖本人、他人、无公开游戏用户及返回位置恢复 |
|
||||
| 他人的关注、粉丝列表均只读,已有关注缓存也不显示操作;后端和前端均不探测访问者与各行用户的关系 | 两种列表响应 relationship 全为 null、后端关系投影未调用、前端无关系补查或写请求;头像/昵称跳转保留 |
|
||||
| 游客只读;禁止自关注和第三方移除他人粉丝 | 权限负向测试;第三账号越权调用 |
|
||||
| 主页只含作者公开游戏,自己的主页也不泄露私有版本 | 混合公开/未公开/下架/删除作品夹具与 HTTP 验证 |
|
||||
| 超时后回读、重复请求、并发、快速换作者/账号 | 后端幂等及前端迟到响应测试 |
|
||||
| 分页、空态、错误态、长昵称、触摸与键盘操作 | 超过一页数据及移动/桌面 UI 验证 |
|
||||
|
||||
### 已确认决策与开工边界
|
||||
|
||||
- 已确认:第四项默认进入自己主页;他人关注和粉丝列表公开可查看;自己或他人的关注/粉丝列表均可点击任意用户进入其创作者主页;他人的关注、粉丝列表统一只读,无关系操作且不检测访问者与列表用户的关系;本次不额外改造游戏目录分页,保持既有广场和“我的游戏”行为。
|
||||
- 已确认:移动端采用“游戏 / 创作者主页 / 我的”;自己的主页也只列公开游戏;自己的关注列表取消后当前行暂留以便重新关注;移除粉丝二次确认,取消关注不弹确认。
|
||||
- 产品待定项已收口,接口命名、路由和关系列表分页按本文及工程设计落地。[工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md#后端验证证据)已记录后端验证证据;用户已验收通过,页面按独立实施计划推进。
|
||||
- 源码核验:现有公开用户查询按账号存在性返回资料,`user_account` 没有独立封禁/公开状态字段。首版复用该存在性口径,关系计数和列表一致排除不存在的账号,不在本功能内增加账号状态体系。
|
||||
- 后端证据:领域、DTO、facade、HTTP 定向测试、WASM 构建与绑定生成、schema/DDD/类型/编码/文档检查通过;隔离运行时脚本覆盖双向关系、权限、同时间分页、计数、账号快照、并发和作者游戏过滤。浏览器及生产部署未执行。
|
||||
|
||||
工程落点、DTO、事务与验证命令见[创作者主页与关注粉丝工程设计](./technical/【技术方案】创作者主页与关注粉丝工程设计-2026-10-05.md)。本节是行为主规范,工程设计不另立产品规则。
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/* @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 follow = vi.fn();
|
||||
render(
|
||||
<CreatorUserRow
|
||||
name="当前作者"
|
||||
subtitle="ID 10001"
|
||||
actions={<CreatorFollowButton followed={false} onClick={follow} />}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('当前作者').closest('a, button, [tabindex]'),
|
||||
).toBeNull();
|
||||
expect(screen.getByText('ID 10001')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关注' }));
|
||||
expect(follow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('用户链接与关注按钮互相独立,长昵称完整且不嵌套交互元素', () => {
|
||||
const navigate = vi.fn(),
|
||||
follow = vi.fn();
|
||||
const name = '很长的创作者名字'.repeat(10);
|
||||
render(
|
||||
<CreatorUserRow
|
||||
name={name}
|
||||
href="/creators?id=b"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate();
|
||||
}}
|
||||
actions={<CreatorFollowButton followed={false} onClick={follow} />}
|
||||
/>,
|
||||
);
|
||||
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(<CreatorFollowButton followed={false} pending onClick={click} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('已关注状态文案仍明确提供取消关注的可访问名称', () => {
|
||||
const click = vi.fn();
|
||||
render(
|
||||
<CreatorFollowButton followed followedLabel="已关注" onClick={click} />,
|
||||
);
|
||||
const button = screen.getByRole('button', { name: '取消关注' });
|
||||
expect(button.textContent).toBe('已关注');
|
||||
fireEvent.click(button);
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`creator-follow ${followed ? 'creator-follow--active' : ''}`}
|
||||
disabled={disabled || pending}
|
||||
aria-busy={pending}
|
||||
aria-label={followed && !pending ? '取消关注' : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{pending ? '处理中…' : followed ? followedLabel : followLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function CreatorUserRow({
|
||||
name,
|
||||
avatarUrl,
|
||||
subtitle,
|
||||
href,
|
||||
onClick,
|
||||
actions,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
subtitle?: string;
|
||||
href?: string;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="creator-avatar" aria-hidden="true">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
Array.from(name)[0] || '创'
|
||||
)}
|
||||
</span>
|
||||
<span className="creator-user-copy">
|
||||
<strong>{name}</strong>
|
||||
{subtitle && <small>{subtitle}</small>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="creator-user-row">
|
||||
{href ? (
|
||||
<a
|
||||
className="creator-user-link"
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
aria-label={name ? undefined : '查看创作者主页'}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="creator-user-link">{content}</div>
|
||||
)}
|
||||
{actions && <div className="creator-user-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<button type="button" className="game-card" onClick={onOpen}>
|
||||
{cover}
|
||||
<span className="game-card__body">
|
||||
<span className="game-card__title-row">
|
||||
<strong>{game.title}</strong>
|
||||
<span className="game-card__play-count">
|
||||
{game.playCount.toLocaleString()} 次游玩
|
||||
</span>
|
||||
</span>
|
||||
<span className="game-card__summary">{game.summary}</span>
|
||||
<span className="game-card__rating">
|
||||
<GameRatingSummaryText summary={game.ratingSummary} />
|
||||
</span>
|
||||
<span className="game-card__meta">
|
||||
<span>{game.category}</span>
|
||||
<span>·</span>
|
||||
<span>{game.author.name}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -56,6 +56,8 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
|
||||
/** 缺省时不显示游玩次数。 */
|
||||
playCount?: number;
|
||||
};
|
||||
/** 替换默认作者行;缺省时按 game.author.name 展示。 */
|
||||
authorContent?: ReactNode;
|
||||
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
|
||||
controlsLabel?: string;
|
||||
/** 「当前版本」文案;缺省 —。 */
|
||||
@@ -199,6 +201,7 @@ export function GameDetailDisplay({
|
||||
icon,
|
||||
assetUrls,
|
||||
renderAsset,
|
||||
authorContent,
|
||||
controlsLabel,
|
||||
versionLabel,
|
||||
infoCards,
|
||||
@@ -234,14 +237,15 @@ export function GameDetailDisplay({
|
||||
{rating ? (
|
||||
<div className="game-detail-hero__rating">{rating}</div>
|
||||
) : null}
|
||||
{authorName ? (
|
||||
<div className="game-detail-hero__author">
|
||||
<span className="game-detail-avatar" aria-hidden="true">
|
||||
{Array.from(authorName)[0]}
|
||||
</span>
|
||||
<span title={authorName}>由 {authorName} 制作</span>
|
||||
</div>
|
||||
) : null}
|
||||
{authorContent ??
|
||||
(authorName ? (
|
||||
<div className="game-detail-hero__author">
|
||||
<span className="game-detail-avatar" aria-hidden="true">
|
||||
{Array.from(authorName)[0]}
|
||||
</span>
|
||||
<span title={authorName}>由 {authorName} 制作</span>
|
||||
</div>
|
||||
) : null)}
|
||||
<div className="game-detail-hero__actions">
|
||||
{actions}
|
||||
{typeof game.playCount === 'number' ? (
|
||||
|
||||
@@ -259,3 +259,5 @@ export {
|
||||
// 后台管理端 chrome:页面壳、面板、按钮、提示、表格与表单外壳。
|
||||
// 只承载表现与交互,路由、鉴权和后台接口仍留在 apps/admin-web。
|
||||
export * from './admin';
|
||||
export { CreatorFollowButton, CreatorUserRow } from './CreatorControls';
|
||||
export { GameCard } from './GameCard';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export type * from './creator';
|
||||
export * from './editorAgent';
|
||||
export type * from './editorAudio';
|
||||
export * from './gameCreationApp';
|
||||
|
||||
@@ -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(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+|AdminGameReview\w*)(?<body>[\s\S]*?)\n\}/g;
|
||||
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+|AdminGameReview\w*|Creator\w+)(?<body>[\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));
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
});
|
||||
Generated
+2
@@ -2880,6 +2880,7 @@ dependencies = [
|
||||
name = "module-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"platform-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5472,6 +5473,7 @@ dependencies = [
|
||||
"log",
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-auth",
|
||||
"module-editor-agent",
|
||||
"module-game-distribution",
|
||||
"module-runtime",
|
||||
|
||||
@@ -22,7 +22,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 }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user