6.4 KiB
6.4 KiB
P5 收藏/评分 设计 / Favorites & Ratings Design
- 状态:设计已批准(2026-09-30,§1-§3 逐节确认)。
- 路线图:
docs/ROADMAP.mdP5(第二波)。依赖 P1(写侧完成,可并行 P2)。 - 范围定性:新数据表(favorites + ratings)+ 用户态 API + 目录「热门」排序 + 详情/账号页 UI。crearte-deploy 零改动(nginx
/api/反代既有,新路由自动透传)——路线图"三仓"措辞按本 spec 修订为两仓。
1. 背景与意图
读者目前对作品只能"看"。P5 加上两类反应:收藏(布尔、幂等)与 5 星评分(一人一分,可改可撤),并让目录支持 热门排序:贝叶斯均分(单人满分霸榜被先验压平)+ 收藏对数权重。成功标准:dev 栈冒烟里"注册→收藏→评分→列表聚合与 ETag 变化→第二用户评分均值变化→我的反应→取消/幂等→校验与鉴权"全链成立;前端 typecheck/build 全绿。
约束(与维护者确认):
- 键是账号 uuid(
users.id),不是 username;改用户名不影响既有反应。 - 下架作品的反应行保留(不删、不级联到作品删除语义之外)。
- 写端点全部
RequireUser+ 按 IP 限流(30/min),作品必须存在且可见否则 404。 ratingCount==0不显示均分;静态源(index.json 无反应字段)视为 0 优雅降级。- schemaVersion 维持 2:新增字段全部可选,老断言(
assertGameSummary)天然容忍。
2. 方案选型
| 方案 | 内容 | 取舍 |
|---|---|---|
| A. 两表 + 读侧嵌入 DTO(已选) | favorites / ratings 各一张,PK (user_id, work_id);列表/详情返回服务端聚合;热门分前端纯函数计算 | 语义直白、可反查(谁收藏了/该作品几星);聚合是 O(表) 全量 map,千级作品无压力 |
| B. 单表 interactions(kind) | 一张表 kind∈{favorite,rating} | 省一张表但 CHECK/语义纠缠,聚合查询反而绕 |
| C. 服务端算热门分返回排序 | /api/games?sort=hot | 与现"前端纯函数过滤排序"架构冲突,且 ETag 会随排序参数碎片化 |
ETag 正确性是方案 A 的关键暗礁:反应一改,旧列表/详情 ETag 若不变,304 将回吐过期聚合。对策:读侧 ETag 输入并入反应水位(Postgres 用两表 count + hashtext 校验和 + 评分和;memory 用等价计数器摘要)。
3. 设计详述
3.1 server — 数据与仓库(crearte-server 0.11.0)
0009_favorites_ratings.sql:两表 +work_id索引(FK 指 users/works,ON DELETE CASCADE;users 无删除路径故 favorites 行为事实不可变)。ReactionRepository:SetFavorite / SetRating / DeleteRating / ReactionFor(user,work) / AggregateFor(work) / AggregateAll() / ListByUser(user) / Watermark();Postgres(DBTX,双查询拼聚合)+ Memory(map[[2]string],WithTx快照补 favorites/ratings)。ContentRepository接口加Reactions();PostgresContentStore、txContent、MemoryContentStore三处接线。
3.2 server — 服务与 API
ContentService:ListPublished返回值加map[string]model.WorkReaction,ETag 摘要并入Watermark();GetPublishedDetail返回加model.WorkReaction,ETag basis 追加favCnt|rateCnt|sum;- 写侧:
SetFavorite(ctx,userID,workID,on)/RateWork(...,score)/UnrateWork—— 先查GetByID + Visible()(不可见 →ErrContentNotFound),再落库,返回dto.ReactionView。
dto:GameSummary增ratingAvg *float64 omitempty / ratingCount / favoriteCount(count 为 0 时 omitempty,与静态缺省同形);新ReactionView {favoriteCount, ratingCount, ratingAvg?, favorited, rated, score?};MeReactionsResponse {favorites []string, ratings map[string]int}。均分保留 1 位小数(math.Round(x*10)/10)。- 路由(
router.go常量 + 注册):PUT|DELETE /api/games/:user/:slug/favoritePUT /api/games/:user/:slug/rating(body{"score":1..5},非法 400)/DELETE同径撤GET /api/me/reactions- 全部
RequireUser;写路径共享reactionLimiter = NewRateLimiter(30, time.Minute);handlerReactions新文件,Deps.Reactions装配(cmd/serve.go)。
3.3 web — 排序与反应 UI(crearte 0.16.0)
data/types.ts:GameSummary三个可选字段;apiRepo断言容忍(不加必选)。lib/filter.ts:SortKey增'hot'(默认仍new);查询白名单同步;纯函数hotScore = (5·4 + Σ)/(5 + n) + 0.5·ln(1 + favoriteCount),Σ = ratingAvg×ratingCount;排序比较hotScore(b)-hotScore(a) || addedAt desc || id asc,浮点容差 1e-9 回退。components/ResultMeta.vue:下拉加<option value="hot">热门</option>(在「最新收录」之后)。- 新
lib/reactions.ts:封装 fetch(Bearer 取session.getToken();base 与 data/apiRepo 同源VITE_API_BASE_URL),导出toggleFavorite/rate/unrate/fetchMine,enabled判空 base。 - 新
components/GameReactions.vue:♥ 收藏钮(aria-pressed)+ 五星条(点亮=floor 均值;点击=打分,再点当前分=撤)+ 计数文案{{avg}} · {{count}} 人评分 · {{fav}} 收藏;匿名点击跳/login(带?next=);操作后即地用ReactionView更新。挂载于 GameView 标题 meta 区。 GameCard.vue:统计徽章行★ 4.3 · 12 人 · ♥ 30(仅显示存在的聚合);点击仍进详情(徽章非交互)。AccountView.vue:新「我的收藏」section —— 列表(链接 + 取消收藏钮)+「我的收藏评分」紧凑列表;空态「还没有收藏或评分。」
3.4 明确不做
作品删除级联语义变更、username 级联、防刷(仅 IP 限流)、评分分布图、匿名半星、分页排序(客户端全量前提不变)。
4. 测试与验收
- server:repository memory + pg-gated 测试(幂等、撤、聚合、Watermark 变);api httptest(401/404/400、PUT→DELETE 链、
/api/me/reactions形状、ETag 随反应变);gofmt/vet/build/test经 docker golang:1.24 全绿。 - web:vitest(hotScore 纯函数含压平/对数/容差用例、排序稳定性、GameReactions 交互 mock fetch、GameCard 徽章渲染);
typecheck+build绿。 - live:dev 栈冒烟全链(见实现计划任务 7)。
- 两仓
feat/favorites-ratings→ 各自 master--no-ff合并推送;wrapper 登记 ROADMAP P5 完成 + CHANGELOG 0.2.3。