From a6b4328edfc870c73e04077175d8c9afb159f279 Mon Sep 17 00:00:00 2001 From: XingfenD Date: Wed, 30 Sep 2026 02:32:38 +0800 Subject: [PATCH] docs: P5 favorites/ratings design + implementation plan (7 tasks, dockerized go tests) --- docs/plans/2026-09-30-favorites-ratings.md | 357 ++++++++++++++++++ .../2026-09-30-favorites-ratings-design.md | 64 ++++ 2 files changed, 421 insertions(+) create mode 100644 docs/plans/2026-09-30-favorites-ratings.md create mode 100644 docs/specs/2026-09-30-favorites-ratings-design.md diff --git a/docs/plans/2026-09-30-favorites-ratings.md b/docs/plans/2026-09-30-favorites-ratings.md new file mode 100644 index 0000000..554986d --- /dev/null +++ b/docs/plans/2026-09-30-favorites-ratings.md @@ -0,0 +1,357 @@ +# P5 收藏/评分 实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development 逐任务实现。步骤用 `- [ ]` 复选框跟踪。spec:`docs/specs/2026-09-30-favorites-ratings-design.md`(需求唯一真源)。 + +**目标:** 作品收藏(布尔幂等)+ 五星评分(upsert/可撤)贯通 server→web,目录新增「热门」排序,账号页显示我的反应。 +**架构:** 两新表 favorites/ratings(PK user_id+work_id);读侧把聚合嵌入 `GameSummary` 并把反应水位并入 ETag;「热门」= 贝叶斯均分 + 收藏对数,纯前端函数;deploy 零改动。 +**技术栈:** Go 1.24 + gin + pgx / Vue 3 + vitest。 + +## 全局约束(所有任务遵守) + +- **分支**:crearte-server 与 crearte 各自 `feat/favorites-ratings`(从 master 新开)。两仓 AGENTS 红线:master 上禁 commit;合并必须 `--no-ff`。wrapper 可直提 master。 +- **基线**:crearte-server master = `a8ea755`(P0 后未动过);crearte master = `63bb96d`(P1 后)。开工前各自 `git pull --ff-only origin master` 确认。 +- **宿主 go 是 1.18,模块要求 1.24**:一切 go 命令走 docker—— + ```bash + cd crearte-server/src + docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go test ./...' + ``` + `gofmt -l` 须输出空;首次拉镜像慢属正常。**宿主机严禁直接 go build/test**。 +- **web**:`cd crearte/src && npm ci`(node_modules 不在仓内,首次慢);验证 `npm test`、`npm run typecheck`、`npm run build` 全绿。 +- **不动的文件**:`.env`、compose、nginx——本计划 deploy 零 diff。`schemaVersion` 维持 2。 +- JSON 字段名逐字:`ratingAvg`(1 位小数)/`ratingCount`/`favoriteCount`;ReactionView:`favoriteCount/ratingCount/ratingAvg/favorited/rated/score`。 +- 每个测试步骤先 RED 后 GREEN;commit message 用计划给定文本。 + +--- + +### 任务 1:server 仓库层(迁移 + ReactionRepository + store 接线) + +**文件:** +- 创建:`src/internal/repository/migrations/0009_favorites_ratings.sql` +- 创建:`src/internal/model/reaction.go` +- 创建:`src/internal/repository/reaction.go`(Postgres 实现) +- 修改:`src/internal/repository/content.go`(接口 + PostgresContentStore + txContent) +- 修改:`src/internal/repository/memory_content.go`(MemoryContentStore) +- 测试:`src/internal/repository/reaction_test.go`(memory 全量 + pg-gated 冒烟) + +- [ ] **步骤 1.1** 开分支:`cd crearte-server && git checkout -b feat/favorites-ratings` + +- [ ] **步骤 1.2** 写迁移 `0009_favorites_ratings.sql`(spec §3.1 逐字): + +```sql +CREATE TABLE favorites ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + work_id text NOT NULL REFERENCES works(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, work_id) +); +CREATE TABLE ratings ( + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + work_id text NOT NULL REFERENCES works(id) ON DELETE CASCADE, + score smallint NOT NULL CHECK (score BETWEEN 1 AND 5), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, work_id) +); +CREATE INDEX favorites_work_idx ON favorites(work_id); +CREATE INDEX ratings_work_idx ON ratings(work_id); +``` + +- [ ] **步骤 1.3** `model/reaction.go`: + +```go +package model + +type WorkReaction struct { + FavoriteCount int + RatingCount int + RatingSum int +} + +func (r WorkReaction) Avg() float64 { + if r.RatingCount == 0 { + return 0 + } + return float64(r.RatingSum) / float64(r.RatingCount) +} + +type UserReactions struct { + Favorites []string + Ratings map[string]int +} + +type ReactionWatermark struct { + FavoriteCount int + RatingCount int + RatingSum int +} +``` + +- [ ] **步骤 1.4** `repository/reaction.go`——接口 + Postgres 实现(DBTX 模式同 `PostgresWorkRepository`): + +```go +type ReactionRepository interface { + SetFavorite(ctx context.Context, userID, workID string) error + DeleteFavorite(ctx context.Context, userID, workID string) error + UpsertRating(ctx context.Context, userID, workID string, score int) error + DeleteRating(ctx context.Context, userID, workID string) error + HasFavorite(ctx context.Context, userID, workID string) (bool, error) + GetRating(ctx context.Context, userID, workID string) (int, bool, error) + AggregateFor(ctx context.Context, workID string) (model.WorkReaction, error) + AggregateAll(ctx context.Context) (map[string]model.WorkReaction, error) + ListByUser(ctx context.Context, userID string) (model.UserReactions, error) + Watermark(ctx context.Context) (model.ReactionWatermark, error) +} +``` + +SQL 要点(全部 `mapPgError` 包装): +- `SetFavorite`:`INSERT INTO favorites(user_id, work_id) VALUES($1,$2) ON CONFLICT DO NOTHING` +- `UpsertRating`:`INSERT INTO ratings(user_id, work_id, score) VALUES($1,$2,$3) ON CONFLICT (user_id, work_id) DO UPDATE SET score=EXCLUDED.score, updated_at=now()` +- `Delete*`:无条件 `DELETE FROM ... WHERE user_id=$1 AND work_id=$2`(删不存在也算成功) +- `HasFavorite`:`SELECT EXISTS(...)`;`GetRating`:`SELECT score ...` + `pgx.ErrNoRows → (0,false,nil)` +- `AggregateFor`:两条 `count(*)/COALESCE(sum(score),0)` 查询合成 +- `AggregateAll`:两表各 `GROUP BY work_id`,合并进 map(仅出现过的 work 有条目) +- `ListByUser`:favorites 按 created_at 排序取 `[]string`;ratings 扫成 map +- `Watermark`:四条标量——`SELECT count(*), COALESCE(sum(score),0) FROM ratings`、`SELECT count(*) FROM favorites` + +- [ ] **步骤 1.5** 接线三处 store: +1. `content.go`:`ContentRepository` 接口加 `Reactions() ReactionRepository`;`PostgresContentStore` 加字段 `reactions *PostgresReactionRepository`(构造里 `NewPostgresReactionRepository(pool)`),accessor 一行;`txContent` 同样加 `Reactions()`(返回 `&PostgresReactionRepository{q: tx}`)。 +2. `memory_content.go`:`MemoryContentStore` 加字段 `favorites map[[2]string]struct{}`、`ratings map[[2]string]int`(构造函数初始化);accessor `func (m *MemoryContentStore) Reactions() ReactionRepository { return memoryReactions{m} }`;`WithTx` 的快照 clone 与回滚补这两个 map(与 works 同款)。 +3. 新 `memoryReactions` 实现同一接口(锁 `m.mu`;键 `[2]string{userID, workID}`)。`Watermark` 遍历两 map 累加。 + +- [ ] **步骤 1.6** 写 `reaction_test.go`——先失败测试(memory): +- `TestMemoryReactionRoundtrip`:SetFavorite 幂等(二次 count 仍 1)→ DeleteFavorite→count 0;UpsertRating 3→count1 sum3→改 5→sum5→Delete→count0。 +- `TestMemoryAggregateAll`:两作品多用户 → map 正确、缺席作品无条目。 +- `TestMemoryListByUser`:favorites 顺序 = 插入序(用 `SetNow` 推进时间),ratings map 完整。 +- `TestMemoryWatermarkChanges`:加收藏/加分/分从 3→5(Watermark.RatingSum 变)各触发水位变。 +- pg-gated:`func TestPostgresReactionGated(t)`——`os.Getenv("TEST_DATABASE_URL")==""` 时 `t.Skip`;非空时复用 `content_migrate_test.go` 的建库方式跑 Roundtrip 同断言。 +运行:`docker run --rm -v "$PWD:/src" -w /src golang:1.24-alpine go test ./internal/repository/ -run Reaction` → RED(接口不存在/编译失败也算 RED)。 +- [ ] **步骤 1.7** 实现至 GREEN:同命令 + `go vet ./...` + `gofmt -l .`(空输出)。 +- [ ] **步骤 1.8** Commit: + +```bash +git add -A && git commit -m "feat(repository): favorites/ratings tables + ReactionRepository (pg + memory + store wiring) + +- migration 0009 two tables PK(user_id,work_id), work_id indexes, FK cascade +- AggregateFor/All, ListByUser, Watermark for ETag invalidation +- ContentRepository gains Reactions(); tx + memory WithTx snapshot updated" +``` + +--- + +### 任务 2:server 服务 + DTO + 路由 + 装配 + +**文件:** +- 修改:`src/internal/service/content.go`(ListPublished/GetPublishedDetail 签名 + 写侧方法 + 水位并入 ETag) +- 修改:`src/internal/handler/games.go`(summary/detail 填聚合) +- 修改:`src/internal/api/dto/content.go`(GameSummary 三字段) +- 创建:`src/internal/api/dto/reaction.go` +- 创建:`src/internal/handler/reactions.go` +- 修改:`src/internal/api/router.go`(常量 + Deps + 注册)、`src/internal/api/ratelimit.go`(`DefaultReactionRateLimit = 30`) +- 修改:`src/cmd/serve.go`(装配 `Reactions: handler.NewReactions(contentSvc)`) +- 测试:`src/internal/api/reactions_test.go`、更新 `src/internal/service/content_test.go` 调用点(`grep -rn 'ListPublished(\|GetPublishedDetail(' src` 找全) +- 修改:`docs/CHANGELOG.md`(0.11.0 双语置顶) + +- [ ] **步骤 2.1** 先写失败测试 `reactions_test.go`(helper `newReactionEngine` 照抄 `uploads_test.go` 的 newWriteEngine 骨架,Deps 加 `Reactions: handler.NewReactions(contentSvc)`,种子作品用 `demoExternalWork()` + publish)。断言清单: +- 匿名 PUT favorite → 401。 +- u1 PUT favorite → 200 `{favoriteCount:1, favorited:true, ratingCount:0}`;再 PUT → favoriteCount 仍 1;DELETE → 0,可再 DELETE(幂等 200)。 +- u1 PUT rating `{"score":4}` → 200 avg4 count1;u2 `5` → avg 4.5;u1 改 `2` → avg 3.5;u1 DELETE → avg 5 count1;GET detail:`ratingAvg:5,ratingCount:1,favoriteCount:1`。 +- `score` 为 0/6/字符串/缺省 → 400 `invalid_request`;不存在作品 → 404 `not_found`;下架作品(SetDelisted true)→ 404。 +- GET `/api/games`:反应发生前后 ETag 头必须不同;条目含 `favoriteCount/ratingCount/ratingAvg`,无反应作品三字段缺省(omitempty)。 +- GET `/api/me/reactions`(u2)→ `{"favorites":[],"ratings":{"alice/2048":5}}`;u1 收藏后 favorites 含 id。 +- 运行 docker go test ./internal/api → RED。 +- [ ] **步骤 2.2** service 改造(GREEN 核心): + +```go +// 签名变更(调用点同步更新) +func (s *ContentService) ListPublished(ctx context.Context) ([]model.WorkWithVersion, map[string]model.WorkReaction, string, error) +func (s *ContentService) GetPublishedDetail(ctx context.Context, id string) (model.WorkWithVersion, model.WorkReaction, string, error) +``` +- 列表:取 `agg, _ := s.store.Reactions().AggregateAll(ctx)`、`wm, _ := Watermark(ctx)`;哈希 basis 由 `stamp|count` 扩为 `stamp|count|||`。 +- 详情:`reaction := AggregateFor(ctx,id)`;basis 追加 `|||`。 +- 写侧(每个先 `GetByID`+`Visible()` 校验 → `ErrNotFound/不可见` 映射 `ErrContentNotFound`): + +```go +func (s *ContentService) SetFavorite(ctx context.Context, userID, workID string, on bool) (model.WorkReaction, error) // on→SetFavorite off→DeleteFavorite +func (s *ContentService) RateWork(ctx context.Context, userID, workID string, score int) (model.WorkReaction, error) // UpsertRating +func (s *ContentService) UnrateWork(ctx context.Context, userID, workID string) (model.WorkReaction, error) // DeleteRating +func (s *ContentService) UserReaction(ctx context.Context, userID, workID string) (favorited bool, score int, rated bool, err error) +func (s *ContentService) MyReactions(ctx context.Context, userID string) (model.UserReactions, error) +``` + +- [ ] **步骤 2.3** dto:`GameSummary` 尾部加 + +```go +RatingAvg *float64 `json:"ratingAvg,omitempty"` +RatingCount int `json:"ratingCount,omitempty"` +FavoriteCount int `json:"favoriteCount,omitempty"` +``` + +`dto/reaction.go`: + +```go +type ReactionView struct { + FavoriteCount int `json:"favoriteCount"` + RatingCount int `json:"ratingCount"` + RatingAvg *float64 `json:"ratingAvg,omitempty"` + Favorited bool `json:"favorited"` + Rated bool `json:"rated"` + Score int `json:"score,omitempty"` +} +func NewReactionView(r model.WorkReaction, favorited bool, score int, rated bool) ReactionView // Avg 保留 1 位小数:math.Round(a*10)/10,count==0 时 RatingAvg=nil +type MeReactionsResponse struct { + Favorites []string `json:"favorites"` + Ratings map[string]int `json:"ratings"` +} +``` + +- [ ] **步骤 2.4** handler:`games.go` 的 `summary(item)`→`summary(item, reaction model.WorkReaction)`(无反应传零值,仅 >0 时填字段;float 指针同样只在 RatingCount>0 时给)。List/Detail 用新签名;Detail 的 `h.detail(item, reaction)` 同步。新 `handler/reactions.go`: + +```go +type Reactions struct{ svc *service.ContentService } +func NewReactions(svc *service.ContentService) *Reactions +// workID(c):c.Param("user")/("slug") 过 service.UsernamePattern/SlugPattern,不合→400 invalid_request;service.JoinWorkID 拼 id +// SetFavorite/DeleteFavorite/SetRating/DeleteRating:CurrentUser(c).ID → svc 写侧 → svc.UserReaction 组装 ReactionView → 200 JSON +// SetRating body:struct{ Score int `json:"score"` };越界(1-5 之外)/非法 JSON → 400 invalid_request +// ErrContentNotFound → 404 not_found;其他 err → 500 internal(log.Printf 同 games.go 风格) +// Me:svc.MyReactions → MeReactionsResponse(空 favorites 输出 [],不输出 null) +``` + +- [ ] **步骤 2.5** router:常量三条 + `DefaultReactionRateLimit = 30` + 注册块 + Deps 字段 `Reactions *handler.Reactions` + cmd/serve.go 装配(无 storage 也注册——反应不依赖对象存储): + +```go +RouteGameFavorite = "/api/games/:user/:slug/favorite" +RouteGameRating = "/api/games/:user/:slug/rating" +RouteMeReactions = "/api/me/reactions" +``` +PUT 两条挂 `RequireUser + reactionLimiter`;DELETE 两条与 GET 只挂 `RequireUser`。 + +- [ ] **步骤 2.6** 全绿验证:docker `gofmt -l . && go vet ./... && go test ./...`(content_test.go 调用点已按新签名改)。 +- [ ] **步骤 2.7** CHANGELOG 置顶 `## [0.11.0] - 2026-09-30`,双语:Added——favorites/ratings 两表 + 六端点 + 目录/详情聚合字段 + 反应水位并入 ETag(英文行紧接中文行)。 +- [ ] **步骤 2.8** Commit: + +```bash +git add -A && git commit -m "feat(api): favorites/ratings user-space endpoints + reaction aggregates in content read side + +- PUT/DELETE /api/games/:u/:s/favorite & /rating (RequireUser, 30/min), GET /api/me/reactions +- GameSummary gains ratingAvg/ratingCount/favoriteCount (omitempty, schemaVersion stays 2) +- list/detail ETag folds reaction watermark (counts+sum) — aggregates never served stale +- changelog 0.11.0" +``` + +--- + +### 任务 3:web 数据契约 + 热门排序 + +**文件:** +- 修改:`src/app/data/types.ts`(GameSummary 可选字段)、`src/app/lib/filter.ts`、`src/app/lib/filter.test.ts`、`src/app/components/ResultMeta.vue` + +- [ ] **步骤 3.1** `cd crearte && git checkout -b feat/favorites-ratings && cd src && npm ci` +- [ ] **步骤 3.2** 失败测试(vitest): +- `types.test.ts`/既有断言不因可选字段破坏; +- `filter.test.ts` 新增 describe('sort hot'): + - 构造 g1(n=1,sum=5,fav=0)、g2(n=100,sum=430,fav=0):m=435/101≈4.307 → g1 分 (5·4.307+5)/(105)=0.2527… vs g2 (5·4.307+430)/(105)=4.152… → g2 先; + - **压平用例**:单人 5 星(n=1,fav=0)输给 n=3 sum=12(均 4)+fav=1 的作品; + - 收藏对数:两作品同均分同 n,fav 10 vs 1 → fav10 先; + - 静态缺省:三字段 undefined 的作品全获同分 → 按 addedAt 倒序(退化为 new); + - URL 往返:`stateToQuery({sort:'hot'})` 出 `?sort=hot`,`parseQuery` 认 `hot`,未知值回退 `new`。 +- [ ] **步骤 3.3** 实现: +- `types.ts` GameSummary 加 `ratingAvg?: number; ratingCount?: number; favoriteCount?: number`。 +- `filter.ts`:`SortKey = 'new' | 'name' | 'duration' | 'hot'`;`parseFilterState` 白名单加 `'hot'`;新导出: + +```ts +export function hotScore(game: GameSummary, priorC: number, priorMean: number): number { + const n = game.ratingCount ?? 0 + const sum = (game.ratingAvg ?? 0) * n + return (priorC * priorMean + sum) / (priorC + n) + 0.5 * Math.log(1 + (game.favoriteCount ?? 0)) +} +``` +`filterGames` 的 hot 分支:先对**过滤后集合**求 `nAll=ΣratingCount`、`sumAll=Σ(ratingAvg×ratingCount)`,`m = nAll>0 ? sumAll/nAll : 4`;`scores = new Map(...)`(按 id 缓存一次计算,sort 比较器禁止重复算分);比较 `b-a`,`|Δ|<=1e-9` 时 `addedAt 倒序 → id 正序`。 +- `ResultMeta.vue`:`` 之后插 ``。 +- [ ] **步骤 3.4** `npm test` 全绿 + `npm run typecheck`。 +- [ ] **步骤 3.5** Commit `git add -A && git commit -m "feat(catalog): hot sort — bayesian prior mean + favorite log weight, pure fn with tie-breaks"` + +--- + +### 任务 4:web 反应 UI + 账号页 + +**文件:** +- 创建:`src/app/lib/reactions.ts`(+ test)、`src/app/components/GameReactions.vue`(+ test) +- 修改:`src/app/views/GameView.vue`、`src/app/components/GameCard.vue`(+ 其 test)、`src/app/views/AccountView.vue`(+ test) +- 修改:`docs/CHANGELOG.md`(0.16.0) + +- [ ] **步骤 4.1** 失败测试(列出断言,实现者按此写): +- `reactions.test.ts`:注入假 fetch——PUT favorite 带 `Authorization: Bearer `、URL 拼 `VITE_API_BASE_URL` 去尾斜杠;401 → 抛 `AuthRequiredError`;base 为空时 `reactionsEnabled=false`、调用抛错不发请求。 +- `GameReactions.test.ts`(mount,mock lib):显示 `4.5 · 2 人评分 · 3 收藏`;favorited=true 时 ♥ aria-pressed;点星 4 → setRating('u','s',4);当前分 4 再点 4 → unrate;匿名(session.getToken()=null)点按钮 → router.push('/login?next=…');乐观锁:在途禁再点(busy)。 +- `GameCard.test.ts` 增:ratingCount>0 显 `★ 4.5 · 2 人`;favoriteCount>0 显 `♥ 3`;两者 0/缺省不显徽标。 +- `AccountView.test.ts` 增:fetchMine → favorites 命中 repo 列表渲染链接行、`取消收藏` 调 setFavorite(on=false);空数据显「还没有收藏或评分。」 +- [ ] **步骤 4.2** `lib/reactions.ts`: + +```ts +import { session } from '@/auth' +const rawBase = (import.meta.env.VITE_API_BASE_URL ?? '').trim() +export const reactionsEnabled = rawBase !== '' +const base = rawBase.replace(/\/+$/, '') +export interface ReactionView { favoriteCount: number; ratingCount: number; ratingAvg?: number; favorited: boolean; rated: boolean; score?: number } +async function call(path: string, init: RequestInit): Promise { + if (!reactionsEnabled) throw new Error('未配置后端') + const token = session.getToken() + if (!token) throw new AuthRequiredError() + const res = await fetch(`${base}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.body ? { 'Content-Type': 'application/json' } : {}) } }) + if (res.status === 401) throw new AuthRequiredError() + if (!res.ok) throw new Error(`请求失败 ${res.status}`) + return res.json() +} +export const setFavorite = (user, slug, on): Promise => call(`/api/games/${user}/${slug}/favorite`, { method: on ? 'PUT' : 'DELETE' }) +export const setRating = (user, slug, score) => ...PUT body JSON.stringify({score})... +export const unrate = (user, slug) => ...DELETE... +export async function fetchMine(): Promise<{ favorites: string[]; ratings: Record }> +class AuthRequiredError extends Error {} // export +``` + +- [ ] **步骤 4.3** `GameReactions.vue`(props `game: Game`;script:`favorited/rated/score/counts` ref,`onMounted` 若有 token 调 `fetchMine()` 初始化个人态;`toggleFav()`、`pickStar(i)`(i===score→unrate 否则 setRating)、`reset()`(已评且点击当前星);每次成功后用 ReactionView 全量替换本地态;失败显示一行错误文本 `aria-live`;`busy` 在途禁用全部按钮)。模板:♥ 按钮(`data-testid="fav-btn"`,`aria-pressed`,样式沿用 border-2 border-ink bg-surface lift 族)+ 五星条五颗 `data-testid="star-"`(实心=floor(avg);已评显个人分)+ 文案 `{{ ratingCount>0 ? `${ratingAvg} · ${ratingCount} 人评分` : '暂无评分' }} · {{ favoriteCount }} 收藏`。挂载:GameView `` 插在操作链接(开始体验/GameHost)之后、intro 分隔线之前;`authEnabled`(VITE_API_BASE_URL 非空,import 自 `@/auth`)为假时组件内部直接不渲染(noauth 零请求)。 +- [ ] **步骤 4.4** `GameCard.vue`:作者行之后、tags 行之前加徽标 `

…`(computed 拼 `★ avg · n 人` 与 `♥ f`,`aria-hidden` 星形用文本即可)。 +- [ ] **步骤 4.5** `AccountView.vue`:会话 section 前插「我的收藏」——`onMounted && session.state.status==='authenticated'` 时 `fetchMine()` + `repo.listGames()` join(GameSummary.id 匹配);渲染复用 GameCard 链接行样式(标题→/games/u/s + 取消收藏小钮);评分列表紧凑行 `作品名 ★`;空态文案;`reactionsEnabled` 假时整节不渲染。 +- [ ] **步骤 4.6** 全绿:`npm test && npm run typecheck && npm run build`。 +- [ ] **步骤 4.7** CHANGELOG 0.16.0 双语置顶(Added:详情收藏/评分控件、目录热门排序与徽标、账号页我的反应)。 +- [ ] **步骤 4.8** Commit `git add -A && git commit -m "feat(ux): favorite & 5-star rating UI on game page, reaction badges on cards, my-reactions section on account, catalog hot sort option, changelog 0.16.0"` + +--- + +### 任务 5:live 冒烟(prod 栈实跑,零代码改动) + +- [ ] **步骤 5.1** `cd crearte-deploy && docker compose --profile prod build api-prod web-prod && docker compose --profile prod up -d`,等 api-prod healthy(新代码进镜像——**先 build 再 up**;web-prod 用最新前端)。 +- [ ] **步骤 5.2** 冒烟链(bash 脚本 /tmp/p5smoke.sh,`set -euo pipefail`;B=http://localhost:8080): +1. `ETAG0=$(curl -sD- -o /dev/null $B/api/games | grep -i '^etag' | tr -d '\r' | cut -d' ' -f2)` +2. 注册 u1@p5.example/u2@p5.example(curl register ×2,各 login 拿 T1/T2)。 +3. u1 PUT `$B/api/games/p1admin/p1-smoke/favorite`(Bearer T1)→ jq `.favoriteCount==1 and .favorited==true`;再 PUT → 仍 1。 +4. u1 rating `{"score":4}` → `.ratingAvg==4`;u2 `{"score":5}` → `4.5`;u1 改 `{"score":2}` → `3.5`;u1 DELETE → `.ratingAvg==5 and .ratingCount==1`。 +5. `curl -sD- -o /dev/null $B/api/games` 取 ETAG1 ≠ ETAG0;带 `If-None-Match: ` → 304。 +6. `curl -s $B/api/games` jq 断言 p1admin/p1-smoke 条目 `favoriteCount==1, ratingCount==1, ratingAvg==5`;`curl -s $B/api/games/p1admin/p1-smoke` 详情同样。 +7. `curl -s $B/api/me/reactions -H "Authorization: Bearer ***")` → `.favorites|index("p1admin/p1-smoke")!=null`;u2 → `.ratings["p1admin/p1-smoke"]==5`。 +8. 负路:匿名 PUT favorite→401;`{"score":7}`→400;不存在作品→404。 +9. 徽标渲染抽查:`curl -s http://localhost:8080/ | head -c 300` 200 即可(SPA,DOM 断言归 vitest;浏览器级验收 manual-deferred 如常)。 +- [ ] **步骤 5.3** 收尾:`docker compose --profile prod down`(保留卷);删 /tmp/p5*。 +- [ ] **步骤 5.4** 复跑静态验证:docker go 全套、`npm test/typecheck/build`(若任务 2/4 后无改动可引用其报告,重跑一次留快照更稳)。 +报告写 `.superpowers/sdd/p5-task-5-report.md`。 + +--- + +### 任务 6:两仓合并 + 推送 + +- [ ] **步骤 6.1** crearte-server:`git checkout master && git pull --ff-only origin master && git merge --no-ff feat/favorites-ratings -m "Merge branch 'feat/favorites-ratings' — favorites & 5-star ratings, hot sort server side (P5)" && git push origin master && git branch -d feat/favorites-ratings && git push origin --delete feat/favorites-ratings 2>/dev/null; git log --oneline -1` +- [ ] **步骤 6.2** crearte:同款 merge message 尾缀换 `(P5)`,push、删分支。 +- [ ] **步骤 6.3** `--ff-only` 若拉来新提交且冲突:`git merge --abort` 上报 BLOCKED,禁止强推。 + +--- + +### 任务 7:wrapper 收尾(控制者直接执行) + +ROADMAP P5 行 → `**完成**(server \`<6.1 hash>\` / crearte \`<6.2 hash>\`,2026-09-30 合并推送;范围修订 deploy 零改动,见 spec)`;索引表加 P5 两行文档;CHANGELOG 0.2.3 双语;commit+push;终态核验 `git -C crearte-server pull --ff-only origin master` 等三条 + `./clone_all.sh`。 + +--- + +## 自检记录(已内联修复) + +1. 规格覆盖:spec §3.1→任务 1;§3.2→任务 2;§3.3→任务 3/4;§4→任务 2/3/4/5 测试 + 任务 5 live;§2 ETag→1.4 Watermark/2.2 并入。✔ +2. 占位符扫描:无 TODO/待定;hash 占位为运行时事实。✔ +3. 命名一致:`ReactionRepository/Watermark/AggregateAll/UserReactions/ReactionView`(任务 1→2);`hotScore/setFavorite/unrate/fetchMine/AuthRequiredError`(任务 3→4);JSON 名三处(2.3、4.2、5.2)逐字一致。✔ diff --git a/docs/specs/2026-09-30-favorites-ratings-design.md b/docs/specs/2026-09-30-favorites-ratings-design.md new file mode 100644 index 0000000..07404dd --- /dev/null +++ b/docs/specs/2026-09-30-favorites-ratings-design.md @@ -0,0 +1,64 @@ +# 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`:下拉加 ``(在「最新收录」之后)。 +- 新 `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。