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

24 KiB
Raw Blame History

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——
    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 逐字):

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:
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):
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:
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 核心):

// 签名变更(调用点同步更新)
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):
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 尾部加
RatingAvg   *float64 `json:"ratingAvg,omitempty"`
RatingCount int      `json:"ratingCount,omitempty"`
FavoriteCount int    `json:"favoriteCount,omitempty"`

dto/reaction.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:
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 也注册——反应不依赖对象存储):
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:
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';新导出:

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:

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)逐字一致。✔