Files
crearte-monorepo/docs/specs/2026-09-30-favorites-ratings-design.md
T

65 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P5 收藏/评分 设计 / Favorites & Ratings Design
- **状态**:设计已批准(2026-09-30,§1-§3 逐节确认)。
- **路线图**:`docs/ROADMAP.md` P5(第二波)。依赖 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/favorite`
- `PUT /api/games/:user/:slug/rating`(body `{"score":1..5}`,非法 400)/ `DELETE` 同径撤
- `GET /api/me/reactions`
- 全部 `RequireUser`;写路径共享 `reactionLimiter = NewRateLimiter(30, time.Minute)`;handler `Reactions` 新文件,`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。