358 lines
24 KiB
Markdown
358 lines
24 KiB
Markdown
# 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|<wm.FavoriteCount>|<wm.RatingCount>|<wm.RatingSum>`。
|
||
- 详情:`reaction := AggregateFor(ctx,id)`;basis 追加 `|<fav>|<cnt>|<sum>`。
|
||
- 写侧(每个先 `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`:`<option value="new">最新收录</option>` 之后插 `<option value="hot">热门</option>`。
|
||
- [ ] **步骤 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 <token>`、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<ReactionView | unknown> {
|
||
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<ReactionView> => 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<string, number> }>
|
||
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-<i>"`(实心=floor(avg);已评显个人分)+ 文案 `{{ ratingCount>0 ? `${ratingAvg} · ${ratingCount} 人评分` : '暂无评分' }} · {{ favoriteCount }} 收藏`。挂载:GameView `<GameReactions v-if="game" :game="game" />` 插在操作链接(开始体验/GameHost)之后、intro 分隔线之前;`authEnabled`(VITE_API_BASE_URL 非空,import 自 `@/auth`)为假时组件内部直接不渲染(noauth 零请求)。
|
||
- [ ] **步骤 4.4** `GameCard.vue`:作者行之后、tags 行之前加徽标 `<p v-if="hasReaction" data-testid="reaction-badge">…`(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 + 取消收藏小钮);评分列表紧凑行 `作品名 ★<score>`;空态文案;`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: <ETAG1>` → 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)逐字一致。✔
|