diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 892def3..f902497 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -72,3 +72,4 @@ | P11 服务端安全硬化 | `docs/specs/2026-10-02-p11-server-hardening-design.md` | `docs/plans/2026-10-02-p11-server-hardening.md`(已执行,2026-10-02:server 0.17.0 / deploy 0.7.0 / crearte 0.24.1) | | P12 窄屏溢出修复与打磨批 | `docs/specs/2026-10-02-p12-narrow-viewport-overflow-design.md` | `docs/plans/2026-10-02-p12-narrow-viewport-overflow.md`(已执行,2026-10-02:crearte 0.25.0 / server 0.17.1 / deploy 0.7.1) | | P13 暗色模式 | `docs/specs/2026-10-03-p13-dark-mode-design.md` | `docs/plans/2026-10-03-p13-dark-mode.md`(已执行,2026-10-03:crearte 0.26.0) | +| P14 拆分 ContentService god object | `docs/specs/2026-10-03-p14-content-service-split-design.md` | `docs/plans/2026-10-03-p14-content-service-split.md`(进行中,2026-10-03:crearte-server) | diff --git a/docs/plans/2026-10-03-p14-content-service-split.md b/docs/plans/2026-10-03-p14-content-service-split.md new file mode 100644 index 0000000..9cab250 --- /dev/null +++ b/docs/plans/2026-10-03-p14-content-service-split.md @@ -0,0 +1,92 @@ +# P14 实现计划:拆分 `ContentService` god object + +- **spec(权威)**:`docs/specs/2026-10-03-p14-content-service-split-design.md` +- **取证账本**:`crearte-server/.superpowers/sdd-p14/survey.md`(全部数字实测,含 Route C spike 输出与三次归属分析的自我纠错) +- **仓**:`crearte-server`(**仅此一个**)· **base** `dbf7fe5`(0.17.2)· 分支 `chore/p14-content-service-split` + > ⚠️ 分支前缀用 `chore/`,**不是 `refactor/`**:AGENTS.md 只允许 `{feat|fix|docs|chore}/` 四种前缀(P12 用 `fix/`、P13 用 `feat/`)。纯结构重构归 `chore/`。 +- **性质**:纯结构重构,**零行为变更** +- **目标版本**:crearte-server **0.18.0** · wrapper **0.3.7** +- **派发**:**一个**实现者子代理做完 T1→T3(所有任务同动单一工作树与 git index,`sdd-parallel-dispatch` §1 一仓一写者) + +> spec 与本计划冲突时 **spec 赢**。spec 全文(§0–§8)必读,尤其 §2 决策表 D-A…D-K、§3 实现要求(含逐字代码骨架与机械迁移规则)、§5 测试计划、§8 实现纪律(10 条累计教训)。 + +--- + +## 任务表 + +| 任务 | 交付物 | 要求 | 完成判据 | +|---|---|---|---| +| **T1** | `internal/service/architecture_test.go`(新增) | **RED 先行**(spec D-J / §5-T1)。三类 reflect 断言:① `ContentService` 恰 5 字段、全 `Anonymous`、全 `Ptr`、类型名集合恰为五个子 service ② 五个子 service 各自的**导出方法名集合逐字等于**其职责线预期集合(spec §5-T1.2 列全)③ `ContentService` 与五个子 service **都不得有名为 `now` 的字段** | T1 单独提交时 `go test ./internal/service/` 以**编译错误**失败(`undefined: CatalogService` 等),输出**逐字存证**到 `crearte-server/.superpowers/sdd-p14/impl-evidence/t1-red.txt`。Go 的 RED 不给断言级消息,**编译错误本身即 RED 证据,但必须存档** | +| **T2** | `catalog.go` / `reaction.go` / `upload.go` / `submission.go` / `moderation.go`(新增)+ `content.go`(改为组合根 + 共享声明) | 按 spec §3.1–§3.2 机械迁移。**方法体逐字不动**(规则 5);私有 helper 随唯一使用线迁移(规则 2);每个文件 import **只列实际用到的包**(规则 4);删除 `ContentService.now`(D-H),并**自行 grep 确认** `time` import 是否变悬空(spec §3.1 警告,不许凭 spec 推断) | T1 由红转绿;`go test ./...` **11 包全绿**;**既有 `*_test.go` 修改文件数 = 0**(`git diff --stat` 只应出现 T1 新增的那个);`content.go` < 120 行;六文件最大 < 300 行 | +| **T3** | 五个 handler 的窄接口(D-E)+ `cmd/catalog.go` 依赖面收窄(D-I) | 按 spec §3.3–§3.4。接口**定义在消费方**(handler 自己的文件内,小写包内私有),方法签名**从 service 侧逐字复制**(spec §3.3 警告:`ListPublished`/`GetPublishedDetail` 含未命名的 ETag `string` 返回值,须 `grep -n 'func (s \*ContentService) ListPublished' -A2` 取原文,**不要凭 spec 省略号推断**);`admin.go` 的 `bundle *service.BundleService` 与 `uploads.go` 的 `maxBundle, maxCover int64` 参数**保持不变** | `grep -rn 'service.ContentService' internal/handler/` → **零命中**;`cmd/catalog.go` 内 `nil` 占位与 `NewPostgresUserStore(pool)` 消失(依赖槽 4→2);**`cmd/serve.go` 零改动**(spike H2 的可核推论——若被迫改动,说明窄接口方法集抄漏了,按编译错误补全而非放宽接口);既有 handler/api 测试**断言零修改** | + +--- + +## 验收(控制者独立复跑,不采信自报) + +### 四门(dockerized Go,AGENTS.md 红线) + +```bash +docker run --rm -v "$PWD/src:/src" -w /src \ + -v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \ + -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \ + golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...' +``` + +基线(已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 最慢 ~11.5s 含真库集成)。**host Go 1.18 不可用**;缺 `GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → background + 耐心 poll。 + +### 结构指标(spec §5 表,交付时须报实测数字) + +| 指标 | 基线 | 目标 | +|---|---|---| +| `content.go` 行数 | 856 | **< 120** | +| 六文件最大行数 | 856 | **< 300** | +| `service/` 内 >400 行生产文件 | 1 | **0** | +| handler 里 `service.ContentService` 引用 | 10 处 | **0** | +| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** | +| `ContentService` 自身声明的方法数 | 25 | **0** | +| 既有 `*_test.go` 修改文件数 | — | **0** | + +### mutation 抽查(spec §5-T1 的 a/b/c,控制者独立复现,不复用实现者结果) + +- (a) 把 `CoverURL` 从 `catalog.go` 移回 `content.go` 并改接收者为 `*ContentService` → 架构守卫必须红 +- (b) 给 `ContentService` 加一个具体字段(如 `store repository.ContentStore`)→ 必须红 +- (c) 给任一子 service 加回 `now func() time.Time` → 必须红 + +每次 mutation 后 `git checkout -- ` 恢复并核 `git diff` 空。**修守卫必须两个方向都验**(对合法值不误红 + mutation 下不误绿)——P13 控制者正是只验了前者,交付了一个数学恒真的断言。 + +### 行为不变的额外证据 + +- `git diff dbf7fe5..HEAD --stat`:既有 `*_test.go` **零文件**出现在 diff 里(除 T1 新增) +- 逐函数比对:方法体应只有接收者类型变化,**无逻辑改动**(审查者用 `git diff` 核,不信报告表格) +- 错误值文本、slog 字段、仓储调用顺序、事务边界**逐字不变**(spec §3.2 规则 5 / D-G) + +--- + +## 边界(**不做**,spec §4) + +不改任何行为 · 不重构 `Approve` 内部(170 行 7 阶段,登记挂账留下一批)· 不动 `AccountService`/`AuthService`/`BundleService`/`CleanupService` · 不动 `memory_content.go`(814 行但实测是测试替身)· 不做 handler 错误映射去重(候选 C)· 不引入新依赖 · 不改任何测试断言语义。 + +**若实现者发现必须改某个既有测试才能编译 → 停下来报告,不要改。** 那本身说明拆分做错了(D-A 的设计目标就是构造点零改动)。 + +--- + +## 记账(控制者做,实现者禁动 `docs/`) + +- `crearte-server/docs/CHANGELOG.md` 新增 `## [0.18.0] - 2026-10-03`(插 `## [0.17.2]` 前) +- wrapper `docs/CHANGELOG.md` 新增 `## [0.3.7] - 2026-10-03`(插 `## [0.3.6]` 前,小节 `### Done / 完成`) +- `docs/ROADMAP.md`:新增 P14 行(P13 行后)+ 文档索引 P14 行(P13 索引行后);**哈希引用 merge commit** +- **不 bump 任何 version 文件**(Go 服务无 package.json 类版本文件) +- 挂账新增:`Approve` 170 行 7 阶段内部重构、`memory_content.go` 测试替身规模、handler 错误映射去重(候选 C)、`AccountService`/`CleanupService` 的 `now` 字段使用情况复核(本批只确证 `ContentService.now` 死) +- 格式:同条目英文行紧跟中文行**无空行**、不同条目**空一行**、双语小节标题 +- **纪律**:显式 `git add `,**禁 `git add -A`**;wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage** +- 合并:inner repo 提交前 `git branch --show-current` 确认不在 master;`--no-ff` 建合并提交;推送**禁管道**;对账用 `git ls-remote` 且**必须校验变量非空**(空变量会让 `[ "$L" = "$R" ]` 假 MATCH) + +--- + +## 审查阶梯(P11–P13 惯例,不可省) + +1. **任务级审查**(只读):spec 全文为权威,独立核 §3 逐条落地 + 结构指标 + 自设 mutation(**不照抄**上面的 a/b/c)+ 红线(`docs/`、`go.mod`、其它仓、既有测试断言) +2. **全分支终审**(只读):spec 全文自洽性 + 跨任务缝隙(T2 子 service 与 T3 窄接口的方法集是否逐字对应)+ 审查控制者的裁定工作 + 独立复现 mutation +3. 每道门的 notes **逐条裁定后方可合并**(`sdd-pre-merge-review` §3:未裁定的 note 阻塞合并;「PASS with notes」不等于门过了) +4. **P11 先例:全分支终审裁定过「需修复后合并」**(N1 一条 `slog.Warn` hint 仍在推荐刚被判不安全的配置、N2 spec 五处未随 re-pin 更新)——这道门不是橡皮章 diff --git a/docs/specs/2026-10-03-p14-content-service-split-design.md b/docs/specs/2026-10-03-p14-content-service-split-design.md new file mode 100644 index 0000000..5150adf --- /dev/null +++ b/docs/specs/2026-10-03-p14-content-service-split-design.md @@ -0,0 +1,357 @@ +# P14 设计:拆分 `ContentService` god object(服务架构维度) + +- **日期**:2026-10-03 +- **仓**:`crearte-server`(**仅此一仓**;crearte / crearte-deploy 零改动) +- **base**:`dbf7fe5`(0.17.2,master) +- **性质**:**纯结构重构,零行为变更**。验收的核心是「四门全绿 + 既有测试断言零修改 + 公开 API 语义不变」。 +- **取证账本**:`crearte-server/.superpowers/sdd-p14/survey.md`(全部数字实测,含 Route C spike 输出与三次归属分析的自我纠错) +- **目标版本**:crearte-server **0.18.0**(服务层结构调整,minor);wrapper **0.3.7** + +--- + +## §0 现状与动机 + +`internal/service/content.go` = **856 行 / 30 个函数**,是 `internal/service/` 唯一的异常值: + +| 文件 | 行数 | +|---|---| +| **content.go** | **856** | +| content_test.go | 376 | +| account_test.go | 274 | +| auth.go | 234 | +| auth_test.go | 229 | +| validate.go | 196 | + +`service/` 目录合计 3884 行,content.go 独占 **22%**;是次大生产文件 `auth.go` 的 **3.7 倍**。 + +它承载**五条互不相干的职责线**(目录读 / 反应 / 上传 / 投稿 CRUD / 审核发布),且: + +- struct 仅 5 个字段,**全是共享仓储依赖、无可变内部状态** → 没有「必须放在一起」的状态理由; +- **五个 handler 各只用一条线**(零交叉),却各自声明对**22 个公开方法**的依赖; +- `cmd/catalog.go`(静态目录导出)**显式传 `nil` 给 bundle 参数**,注释「bundle 不参与导出(list/detail 只用 versions)」——god object 迫使一个只需 2 个方法的命令构造携带 22 方法 / 4 依赖槽的服务,并用 `nil` 占位绕开不需要的依赖; +- 单函数最大 **`Approve` 170 行**(605–774)。 + +**为什么现在做**:六维度循环中「服务架构」维度自 P11(服务端安全硬化)后未再触碰;P13(UI/UX)刚交付, crearte 仓空闲但本批不需要它。此项证据最强(见 §1 三条决定性发现),且**风险可被既有 11 包测试完全兜住**。 + +--- + +## §1 取证:三条决定性发现(全部实测) + +### 1.1 跨职责线调用 = 7 处,**全部指向包级 helper 函数** + +python AST 级扫描 `content.go` 内部调用图:同线内部调用 9 处,**跨线 7 处**,逐条: + +| 调用方(线) | 被调(线) | 行 | +|---|---|---| +| `CreateBundleUpload`(upload) | `randomToken`(helper) | 239 | +| `CreateCoverUpload`(upload) | `randomToken`(helper) | 261 | +| `UpdateSubmission`(submission) | `ParseSubmissionEnvelope`(helper) | 486 | +| `Approve`(moderation) | `ParseSubmissionEnvelope`(helper) | 616 | +| `Approve`(moderation) | `versionFromUpload`(helper) | 711, 726 | +| `Approve`(moderation) | `pendingKeys`(helper) | 766 | + +**零个「方法 → 方法」跨线调用**:`s.CoverURL(` / `s.BundleURL(` 在 content.go 内**零命中**(只被 handler 从外部调);`reactionTarget` 3 处全在反应线内;`checkSubmissionRules` / `checkUploadRefs` 4 处全在投稿线内。 + +→ **五条线只通过包级 helper 共享代码,helper 无 struct 依赖**(实测 `helper` 线用到的 struct 字段为空)。拆分后 helper 保持包级即可被各子 service 直接调用,**不产生任何跨 service 回调、不产生循环依赖**。这是可拆性的最强证据。 + +### 1.2 handler 依赖面 = 恰好一线,零交叉 + +| handler | 现构造签名 | 用的方法(实测) | 线 | +|---|---|---|---| +| `games.go:23` | `NewGames(svc *service.ContentService)` | ListPublished, GetPublishedDetail, CoverURL, BundleURL | **catalog** | +| `reactions.go:20` | `NewReactions(svc *service.ContentService)` | SetFavorite, RateWork, UnrateWork, UserReaction, MyReactions | **reaction** | +| `uploads.go:21` | `NewUploads(svc *service.ContentService, maxBundle, maxCover int64)` | CreateBundleUpload, CreateCoverUpload | **upload** | +| `submissions.go:21` | `NewSubmissions(svc *service.ContentService)` | CreateSubmission, ListMine, GetSubmission, UpdateSubmission, DeleteSubmission | **submission** | +| `admin.go:20` | `NewAdmin(svc *service.ContentService, bundle *service.BundleService)` | ListQueue, Approve, Reject, Unpublish, Republish, UpdateWorkFeatures | **moderation** | + +### 1.3 构造点爆炸半径:19 个测试调用点里 **11 个只构造不调方法** + +- 生产 2 处:`cmd/serve.go:64`(全量依赖 → 喂 5 个 handler)、`cmd/catalog.go:62`(**传 `nil` bundle**,只喂 `handler.NewGames`)。 +- 测试 **14 文件 / 19 调用点**,其中 **11 个文件「只构造、不直接调方法」**(把 svc 交给 handler/router 走 HTTP 层测试);仅 3 个直接调: + - `content_hosted_test.go`:1 调用点、1 线(submission) + - `content_test.go`:4 调用点、2 线(catalog + moderation) + - `namespace_test.go`:2 调用点、2 线(submission + upload) + +### 1.4 各线规模与依赖(实测,决定子 service 构造签名) + +| 线 | 行数 | 函数数 | 用到的 struct 字段 | +|---|---|---|---| +| **catalog** | 86 | 4 | `store`, `objects` | +| **reaction** | 82 | 6(含私有 `reactionTarget`) | `store` | +| **upload** | 78 | 2 | `store`, `users`, `objects`, `bundle` | +| **submission** | 267 | 7(含私有 `checkSubmissionRules`/`checkUploadRefs`) | `store`, `objects` | +| **moderation** | 255 | 6 | `store`, `users`, `objects` | +| **helper**(包级函数) | 49 | 4 | 无 | + +### 1.5 Route C spike:Go 嵌入提升满足消费方窄接口(`golang:1.24-alpine` 实测,`go vet` 净) + +玩具类型与真仓同构(子 service 持子仓储接口、组合根嵌入指针、消费方定义窄接口)。四个假设**全部编译通过且运行正确**: + +- **H1** 组合根嵌入 `*ReactionService` + `*UploadService`(指针字段 + 指针接收者)后,`c.SetFavorite(...)` / `c.CreateUpload(...)` 直接可用(方法提升)。 +- **H2** ⭐ `var rp reactionPort = c` / `var up uploadPort = c` 成立 → **提升后的方法集使组合根满足任何窄接口**,故 19 个测试构造点与 `serve.go` 五处装配**零改动**。 +- **H3** `NewReactionService(store)` 签名里根本没有 bundle → **`cmd/catalog.go` 的 `nil` 占位可消失**。 +- **H4** 组合根与子 service 都能喂给收窄后的 handler → 两路线可并存、可渐进。 + +嵌入提升的前提在真仓复核:25 个方法名 **零重名**(`sort | uniq -d` = 0)→ 无 ambiguous selector;3 个私有方法(`reactionTarget`/`checkSubmissionRules`/`checkUploadRefs`)均只在本线内使用、不参与提升。 + +**仓内已有先例**(拆分与惯例一致,非外来重构口味):`NewAccountService(users, content, objects)` 与 `NewBundleService(versions, uploads, keks, activeKEK)` 都直接收**子仓储接口**而非大 store;`ContentStore` 已按职责分子仓储(`Works()`/`Versions()`/`Submissions()`/`Uploads()`/`Reactions()`);`cmd/catalog.go:79` 有 `catalogRenderer` 接口 = **消费方定义窄接口**的先例;`admin_users.go` 用自由函数。 + +### 1.6 拆分安全性:三个风险已实测证伪 + +1. **包内直接读 ContentService 私有字段** = **零**。`account.go`/`auth.go` 里的 `s.store`/`s.users` 属于**它们自己的 struct**;`AccountService` 持的是 `repository.ContentStore`(仓储接口)而非 `*ContentService`,**完全不受拆分影响**。 +2. **ContentService 被 interface 化 / 类型断言 / 方法值使用** = **零**(`type ContentService struct` 全仓仅一处定义)。 +3. **共享声明的包外依赖** = 10 个(见 §3-D),全部保持导出即可,无签名变更。 + +### 1.7 新发现:`ContentService.now` 是**死字段** + +`content.go` 内 `s.now` **零引用**;全文件只有两行提到 `now`——line 31 字段声明、line 35 构造器赋值 `now: time.Now`。测试里也无 `.now =` 注入(全仓 `.now =` 只命中 `cleanup.go:26` 的 `SetNow` setter)。 + +对照组证明它是照抄兄弟 service 的模式而从未使用:`auth.go:159` 真读 `s.now()`、`cleanup.go` 有 `SetNow(fn)` test seam、`account.go` 也有 `now` 字段(其使用情况不属本批范围)。 + +→ **本批删除该死字段**。因为它在拆分爆炸半径内,不删就得决定它归哪条线。删除**不影响任何调用点**(`NewContentService` 的 4 参数签名不变,`now: time.Now` 是构造器内部赋值)。 + +--- + +## §2 决策表 + +| # | 主题 | 决策 | 依据 | +|---|---|---|---| +| **D-A** | 拆分路线 | **Route C:组合根嵌入五个子 service + handler 侧收窄接口**。`ContentService` 保留为**组合根**(5 个嵌入指针字段、自身零方法),五个子 service 各持自己需要的依赖;五个 handler 的 `svc` 字段类型改为**消费方定义的窄接口** | §1.5 spike H1–H4 实测;H2 使 19 测试点 + serve.go 五处装配零改动,H3 使 catalog.go 的 nil 占位消失。**facade 与窄接口两条路线的收益同时拿到,不是二选一** | +| **D-B** | 文件布局 | `content.go`(组合根 + 共享声明)+ `catalog.go` + `reaction.go` + `upload.go` + `submission.go` + `moderation.go`,共 **6 个文件**;命名与仓内惯例一致(`account.go`→`AccountService`、`bundle.go`→`BundleService`、`auth.go`→`AuthService`、`cleanup.go`→`CleanupService`) | §1.4 五条线 + §5c 归属映射 | +| **D-C** | 子 service 构造签名 | `NewCatalogService(store, objects)`、`NewReactionService(store)`、`NewUploadService(store, users, objects, bundle)`、`NewSubmissionService(store, objects)`、`NewModerationService(store, users, objects)`。**每个只收它实测用到的字段**(§1.4),不多收 | §1.4 依赖映射;先例 `NewBundleService` 只收两个子仓储 | +| **D-D** | 私有 helper 归属 | `randomToken` → `upload.go`;`versionFromUpload` + `pendingKeys` → `moderation.go`;`ParseSubmissionEnvelope` + `SubmissionEnvelope` → **留 `content.go`(SHARED,且 handler 依赖须保持导出)**;`ErrUnsupportedMediaType` + `allowedCoverTypes` → `upload.go`(**独占**,实测仅 256/258 使用);其余 11 个共享错误/类型 → 留 `content.go` | §5c 归属映射(含我对两处误归属的纠正记录) | +| **D-E** | handler 窄接口 | 五个 handler 各自在**自己的文件内**声明消费方接口(`catalogPort`/`reactionPort`/`uploadPort`/`submissionPort`/`moderationPort`,小写=包内私有),只列它实测调用的方法;struct 字段与构造参数类型改为该接口 | §1.2 零交叉;先例 `cmd/catalog.go:79 catalogRenderer`。**接口定义在消费方**(Go 惯例),不放 service 包 | +| **D-F** | `Approve` 170 行 | **本批不拆**。`moderation.go` 255 行在可接受范围;`Approve` 的 7 阶段内部重构(① 加载+状态校验 606–619 ② owner 查取 622–628 ③ 上传解析 630–644 ④ 重复性检查 switch 646–659 ⑤ 对象 Copy 661–681 ⑥ `WithTx` 事务 switch 683–759 ⑦ pending key 清理 + slog 766–772)**登记挂账**,留下一批(代码优雅维度) | 一批一个关注点。混入会让 diff 与审查面翻倍;P13 经验证明小而聚焦的批次审查质量更高。7 阶段映射已取证固化,下批可直接用 | +| **D-G** | 行为不变契约 | 纯结构重构:**零行为变更**。验收 = ① 四门全绿(gofmt/vet/build/`go test ./...` 11 包)② **既有测试断言零修改**(只允许因构造函数/类型变化而改装配代码,不允许改任何 `assert`/`expect` 语义)③ 公开 API 语义不变(22 个方法签名逐字不变,10 个共享声明保持导出)④ HTTP 行为逐字节不变 | 重构的定义。既有 11 包测试就是本批的守卫 | +| **D-H** | 死字段 | 删除 `ContentService.now`(§1.7)。`NewContentService` 的 **4 参数签名不变** → 19 个测试构造点与 2 个生产构造点**零改动** | §1.7 实测零引用 | +| **D-I** | `cmd/catalog.go` 依赖面 | 改为直接构造 `service.NewCatalogService(store, objects)` 喂 `handler.NewGames(...)`,**删除 `nil` bundle 占位与不需要的 `users` 依赖**(依赖槽 4 → 2) | §1.5 H3;现状注释「bundle 不参与导出」正是 god object 代价的自白 | +| **D-J** | 架构守卫(RED 先行) | 新增 `internal/service/architecture_test.go`,用 **reflect 钉住拆分结构**:① `ContentService` 恰有 5 个字段、**全部匿名(嵌入)、全部指针**、类型名恰为五个子 service ② 每个子 service 的**导出方法名集合**逐字等于其职责线的预期集合 ③ `ContentService` **无名为 `now` 的字段** | 防「方法迁回组合根」「重新长出具体字段」「死字段复活」。P12/P13 先例:守卫任务提前为 TDD 第一步,RED 输出本身即「有牙」证明 | +| **D-K** | 派发形态 | **一个实现者子代理**做完 T1→T3。所有任务同动 `crearte-server` 单一工作树与 git index | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13 同款 | + +### §2.1 被否方案 + +| 方案 | 否决理由 | +|---|---| +| **A:只拆文件、不拆类型**(`ContentService` 保持一个大 struct,方法分散到 6 个文件) | 消除不了 god object:每个 handler 仍声明对 22 方法的依赖,`catalog.go` 仍需 `nil` 占位。只解决「文件太长」这一个症状,不解决架构问题 | +| **B:删除 `ContentService`,五个 handler 各收子 service** | 真消除 god object,但 **19 个测试构造点 + 2 个生产构造点全部要改**,diff 与审查面显著变大,而 Route C 用嵌入拿到同样的架构收益且构造点零改动(spike H2/H4 实测)。B 可作为 Route C 之后的**渐进收尾**(若将来组合根不再被任何调用方需要) | +| **C:把五条线拆成五个独立包** | 过度。它们共享 `ContentStore` 与 11 个共享错误/类型,跨包会迫使这些声明全部导出并制造包间依赖;仓内惯例是 service 包内多文件多 struct(`account.go`/`bundle.go`/`auth.go`/`cleanup.go` 全在 `internal/service`) | +| **D:本批一并重构 `Approve` 170 行** | 见 D-F。两个关注点混在一批会让「行为不变」的验收判断变难 | + +--- + +## §3 实现要求(逐条可核) + +### 3.1 `internal/service/content.go`(拆分后 = 组合根 + 共享声明) + +保留:`ErrContentNotFound`;`ContentService` struct(改为 5 个嵌入指针);`NewContentService`(4 参数签名不变,内部改为组装五个子 service,**不再赋 `now`**);11 个共享声明(`ErrSubmissionConflict`、`ErrWorkIDTaken`、`ErrVersionExists`、`ErrUploadUnavailable`、`ErrForbidden`、`submissionKinds`、`SubmissionEnvelope`、`ParseSubmissionEnvelope`、`SubmissionInput`、`SubmissionUpdate`,以及 `ErrUnsupportedMediaType` 迁走后其余保持原位)。 + +```go +// 组合根:五个职责线的嵌入组合。自身不声明任何方法——全部由嵌入提升。 +// 保留它的唯一理由是让 19 个测试构造点与 serve.go 的装配零改动(spec §1.5 H2); +// 若将来无调用方需要「全量服务」,可整块删除(方案 B)。 +type ContentService struct { + *CatalogService + *ReactionService + *UploadService + *SubmissionService + *ModerationService +} + +func NewContentService(store repository.ContentStore, users repository.UserRepository, objects storage.ObjectStorage, bundle *BundleService) *ContentService { + return &ContentService{ + CatalogService: NewCatalogService(store, objects), + ReactionService: NewReactionService(store), + UploadService: NewUploadService(store, users, objects, bundle), + SubmissionService: NewSubmissionService(store, objects), + ModerationService: NewModerationService(store, users, objects), + } +} +``` + +> ⚠️ **`now` 字段必须删除**(D-H)。删除后若 `time` 包在 content.go 内不再被使用,**必须同步删掉 import**(否则 `go build` 报 imported and not used)。实测 `content.go` 内 `time` 仅出现在 `now func() time.Time` 与 `now: time.Now` 两处 —— 实现者须自行 `grep -n 'time\.' internal/service/content.go` 确认后处置,**不要凭本句推断**。 + +### 3.2 五个子 service 文件 + +每个文件的形态(以 `reaction.go` 为例,最小依赖线): + +```go +package service + +// ReactionService = 反应职责线(收藏 / 评分)。只依赖 store(spec §1.4 实测)。 +type ReactionService struct { + store repository.ContentStore +} + +func NewReactionService(store repository.ContentStore) *ReactionService { + return &ReactionService{store: store} +} + +// reactionTarget 是私有 helper,仅本线内使用(spec §1.1:3 处调用全在反应线) +func (s *ReactionService) reactionTarget(...) error { ... } + +func (s *ReactionService) SetFavorite(...) (...) { ... } +// RateWork / UnrateWork / UserReaction / MyReactions +``` + +**机械迁移规则**(逐条可核): + +1. 方法接收者 `s *ContentService` → `s *Service`;**方法体逐字不动**(字段访问 `s.store`/`s.users`/`s.objects`/`s.bundle` 在子 service 上仍然有效,因为各子 service 有自己的同名字段)。 +2. 私有 helper 随其唯一使用线迁移(`reactionTarget`→reaction、`checkSubmissionRules`/`checkUploadRefs`→submission、`randomToken`→upload、`versionFromUpload`/`pendingKeys`→moderation)。 +3. `ErrUnsupportedMediaType` + `allowedCoverTypes` → `upload.go`(独占,§5c)。 +4. 每个新文件的 import 块**只列该文件实际用到的包**(`goimports` 语义)。禁止照抄 content.go 的 16 个 import。 +5. **不得改动任何方法签名、错误值文本、slog 字段、SQL/仓储调用顺序**。这是 D-G 行为不变契约的机械保证。 + +### 3.3 五个 handler 的窄接口(D-E) + +每个 handler 文件内声明自己需要的接口,**方法签名逐字照抄 service 侧**(不得简化参数或返回值): + +```go +// games.go +// catalogPort 是本 handler 实际消费的最小面(spec §1.2:4 个方法)。 +// 定义在消费方(Go 惯例;仓内先例 cmd/catalog.go:79 catalogRenderer)。 +type catalogPort interface { + ListPublished(ctx context.Context, ...) ([]model.WorkWithVersion, map[string]model.WorkReaction, string, error) + GetPublishedDetail(ctx context.Context, ...) (model.WorkWithVersion, model.WorkReaction, string, error) + CoverURL(coverKey string) string + BundleURL(v model.WorkVersion) string +} + +type Games struct { + svc catalogPort +} + +func NewGames(svc catalogPort) *Games { + return &Games{svc: svc} +} +``` + +> ⚠️ **`ListPublished` / `GetPublishedDetail` 的完整签名必须从 `content.go` 逐字复制**(含中间那个未命名 `string` 返回值 = ETag)。不要凭 spec 里的省略号推断。实现者须先 `grep -n 'func (s \*ContentService) ListPublished' -A2 internal/service/content.go` 取原文。 + +其余四个同理:`reactionPort`(5 方法)、`uploadPort`(2 方法)、`submissionPort`(5 方法)、`moderationPort`(6 方法)。`admin.go` 的 `bundle *service.BundleService` 参数**保持不变**(它不是 ContentService 的一部分)。`uploads.go` 的 `maxBundle, maxCover int64` 同样不变。 + +### 3.4 `cmd/serve.go` 与 `cmd/catalog.go`(D-I) + +- `serve.go`:五处 `handler.NewXxx(contentSvc, ...)` **零改动**(spike H2:提升后的组合根满足各窄接口)。 +- `catalog.go`:把 + ```go + contentSvc := service.NewContentService( + repository.NewPostgresContentStore(pool), repository.NewPostgresUserStore(pool), objects, nil) + count, err := exportCatalog(cmd.Context(), handler.NewGames(contentSvc), out, pretty) + ``` + 改为直接构造 catalog 线(**删除 `nil` bundle 占位与 `NewPostgresUserStore(pool)`**): + ```go + catalogSvc := service.NewCatalogService(repository.NewPostgresContentStore(pool), objects) + count, err := exportCatalog(cmd.Context(), handler.NewGames(catalogSvc), out, pretty) + ``` + 并同步更新那条「bundle 不参与导出」的注释——**依赖面已从 4 槽降到 2 槽,`nil` 占位不再存在**。若 `repository.NewPostgresUserStore` 在该文件内因此不再被使用,须检查 import / 变量是否残留。 + +--- + +## §4 边界(本批**不做**) + +- **不改任何行为**:不动校验规则、错误语义、状态机、事务边界、slog 字段、HTTP 状态码映射。 +- **不重构 `Approve` 内部**(D-F,登记挂账)。 +- **不动 `AccountService` / `AuthService` / `BundleService` / `CleanupService`**(它们不持有 `*ContentService`,§1.6)。 +- **不动 `memory_content.go`**(814 行,但实测是**测试替身**:除自身外零个非测试文件引用 `NewMemoryContentStore`,只 6 个 `_test.go` 用;属测试基建规模问题,非服务架构问题)。 +- **不做 handler 错误映射去重**(`WriteError` 92 处,但 `errors.Is(err, service.ErrForbidden)` 仅 2 处 → 重复度不足以证明收益,候选 C,低于本批)。 +- **不引入新依赖、不改 `go.mod`**。 +- **不改任何测试的断言语义**(D-G)。若某个测试因构造函数签名变化而无法编译,**这本身说明拆分做错了**(D-A 的设计目标就是构造点零改动)。 + +--- + +## §5 测试计划 + +### T1 —— 架构守卫(**RED 先行**):`internal/service/architecture_test.go`(新增) + +用 reflect 钉住拆分结构(D-J)。三类断言: + +1. **组合根形态**:`reflect.TypeOf(service.ContentService{})` 恰有 **5 个字段**,每个 `Anonymous == true`(嵌入)、`Kind == reflect.Ptr`,且 `Elem().Name()` 的集合恰为 `{CatalogService, ReactionService, UploadService, SubmissionService, ModerationService}`。 +2. **各线导出方法名集合**(逐字,防方法迁移): + - `CatalogService` = `{ListPublished, GetPublishedDetail, CoverURL, BundleURL}` + - `ReactionService` = `{SetFavorite, RateWork, UnrateWork, UserReaction, MyReactions}` + - `UploadService` = `{CreateBundleUpload, CreateCoverUpload}` + - `SubmissionService` = `{CreateSubmission, ListMine, GetSubmission, UpdateSubmission, DeleteSubmission}` + - `ModerationService` = `{ListQueue, Approve, Reject, Unpublish, Republish, UpdateWorkFeatures}` +3. **死字段不复活**:`ContentService` 与五个子 service **都不得有名为 `now` 的字段**(D-H)。 + +**RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/` 必须以**编译错误**失败(`undefined: CatalogService` 等)。实现者须把该输出**逐字存证**到 `crearte-server/.superpowers/sdd-p14/impl-evidence/t1-red.txt`。Go 的 RED 不如 TS 那样能给出断言级消息,故**编译错误本身就是 RED 证据**——但必须存档,不能只在报告里描述。 + +**mutation 自查(实现者必做并附输出)**:守卫写完后,逐个验证它有牙: +- (a) 把某个方法(如 `CoverURL`)从 `catalog.go` 移回 `content.go` 并改接收者为 `*ContentService` → 断言 2 必须红; +- (b) 给 `ContentService` 加一个具体字段(如 `store repository.ContentStore`)→ 断言 1 必须红; +- (c) 给任一子 service 加回 `now func() time.Time` → 断言 3 必须红。 +每次 mutation 后 `git checkout -- ` 恢复并核 `git diff` 空。 + +### T2 —— 提取五个子 service(T1 由红转绿) + +按 §3.1–§3.2 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**(`git diff --stat` 里不应出现任何 `*_test.go`,除 T1 新增的那个)。 + +### T3 —— 收窄五个 handler + 消除 catalog.go 的 nil 占位 + +按 §3.3–§3.4 执行。**验收硬指标**: +- `grep -rn 'service.ContentService' internal/handler/` → **零命中**(五个 handler 全部改用窄接口); +- `cmd/catalog.go` 内 **`nil` 占位消失**、`NewPostgresUserStore` 不再被该构造使用; +- `cmd/serve.go` **零改动**(spike H2 的可核推论;若被迫改动,说明窄接口方法集抄漏了); +- 既有 handler 测试(`admin_test.go`/`games_test.go`/`reactions_test.go`/`uploads_test.go`/`integration_test.go` 等)**断言零修改**。 + +### 四门验收(dockerized Go,AGENTS.md 红线) + +```bash +docker run --rm -v "$PWD/src:/src" -w /src \ + -v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \ + -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \ + golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...' +``` + +基线(P14 起点,已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 包最慢 ~11.5s,含真库集成)。**host Go 1.18 不可用**;`proxy.golang.org` 从本机不可达,缺 `-e GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → 用 background + 耐心 poll。 + +### 结构指标(交付时须报实测数字) + +| 指标 | 基线 | 目标 | +|---|---|---| +| `content.go` 行数 | 856 | **< 120**(组合根 + 共享声明) | +| 六个文件最大行数 | 856 | **< 300**(submission.go 预计 ~290) | +| `service/` 目录内 >400 行的生产文件 | 1(content.go) | **0** | +| handler 里 `service.ContentService` 引用 | 10 处 | **0** | +| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** | +| `ContentService` 自身声明的方法数 | 25 | **0**(全部提升) | +| 既有 `*_test.go` 修改文件数 | — | **0** | + +--- + +## §6 记账 + +- `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.18.0] - 2026-10-03`**(插 `## [0.17.2]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。 +- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.7] - 2026-10-03`**(插 `## [0.3.6]` 前),小节 `### Done / 完成`。 +- `docs/ROADMAP.md`:新增 P14 行(P13 行之后)+ 文档索引表新增 P14 行(P13 索引行之后)。**哈希引用 merge commit**(P12/P13 惯例:ROADMAP 引合并提交、记账 commit 另列)。 +- **`go.mod` / 任何 version 文件不 bump**(Go 服务无 package.json 类版本文件;仓内版本约定只体现在 CHANGELOG)。 +- 挂账新增:`Approve` 170 行的 7 阶段内部重构(D-F)、`memory_content.go` 814 行测试替身规模、handler 错误映射去重(候选 C)、`AccountService`/`CleanupService` 的 `now` 字段使用情况复核(本批只确证 `ContentService.now` 死,未审其它)。 +- **纪律**:显式 `git add `,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。 + +--- + +## §7 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| **嵌入提升在某处不满足窄接口** → 编译失败 | `go build ./...` 即验证。玩具 spike 已证机制(H2/H4),真签名的验证交给编译器;若失败,说明 §3.3 的方法集抄漏,按编译错误补全而非放宽接口 | +| **方法迁移时漏改字段名或误改方法体** | D-G + §3.2 规则 5「方法体逐字不动」。审查者用 `git diff` 逐函数比对;`go test ./...` 11 包(含 api 层真库集成 11.5s)兜住行为 | +| **import 块照抄导致 `imported and not used`** | §3.2 规则 4:每个文件只列实际用到的包。`go build` 会强制暴露 | +| **`time` import 在 content.go 变悬空**(删 `now` 后) | §3.1 已点名,但**要求实现者自行 grep 确认**而非凭 spec 推断 | +| **测试被迫修改 = 设计失败的信号** | §4 末条 + T3 验收硬指标「既有 `*_test.go` 修改文件数 = 0」。若实现者发现必须改测试,**停下来报告**而不是改 | +| **架构守卫本身写成败 assertion** | T1 的三条 mutation 自查(a/b/c)必须附红/绿输出。P13 教训:`max(a,b) ≥ k` 形态恒真、positive control 只钉一个子树 → **守卫自身要受同等审视** | + +--- + +## §8 实现纪律(P11–P13 累计教训,逐条来自真实事故) + +1. **不推断,只实测。** 任何「应该是 / 大概 / 按惯例」都要跑一条命令确认。P13 控制者在勘查期栽两次(用运行时注入验证构建期烘焙、用 `APPLY.toString()`+`new Function` 序列化注入),复核期写错断言/grep/锚点 **13 次**。 +2. **断言/grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P13 实例:核不得回退项时两条 grep 返 0 而实际零变更(漏了中间三个类名 / 类序不同);查产物死 utility 时手写反斜杠转义返 0 而它真在(应用 `grep -F`);做归属分析时把**声明行**算成使用行。 +3. **修守卫必须两个方向都验**:对合法值不误红 **且** 在 mutation 下不误绿。P13 控制者第一次纠正 scrim 判据时只验了前者,交付了一个**数学恒真**的断言(`max(填充侧,边框侧) ≥ 3`,下界 √16.50=4.0621>3);同一毛病在 alpha 兜底分支重演(只对当前浏览器输出验,漏了它声称支持的 CSS Color 4 形态)。 +4. **`max(a,b) ≥ k` 是恒真断言高发区**:当 a、b 互补时 max 有非平凡下界,阈值低于该下界即永不可能失败。 +5. **全称声明需要全称范围的证据。** P13 的 spec 写「accent 是全仓唯一作背景的地方」为假——spike 0 的 grep 与新守卫都只覆盖 `app/`,而 `runtime/` 与之**同级**。本批同理:任何「全仓零引用」「唯一使用点」的断言,grep 范围必须覆盖 `cmd/` + `internal/` 全部子树,且**区分声明行与使用行**。 +6. **链式命令里别放会因「无匹配」而退 1 的 grep**(`grep -c` 输出 0 即退 1、`grep -v` 无剩余即退 1),会静默中断 `&&` 链导致后续步骤(如 commit)未执行。P13 因此丢过一次 commit。 +7. **相对路径 `cd` 在循环里会漂移。** P13 收口对账时 `for r in ...; do cd $r; ...; cd ..; done` 让后三轮跑进错误目录,输出一堆 `fatal` 并**误报一个仓 porcelain=15**。用绝对路径 + 每仓独立子 shell。 +8. **一仓一写者**(`sdd-parallel-dispatch` §1):所有任务同动 `crearte-server` 单一工作树,故交**一个**子代理,不并行争用 `.git/index.lock`。 +9. **登记挂账前先 grep 既有测试与文档**,否则会开出「补一条已存在的测试」这类伪工作(P13 实例:`router_test.go:62` 早已钉住某边界,而我在 plan 里建议「补反面钉桩」)。 +10. **合并纪律**:inner repo 提交前必须 `git branch --show-current` 确认不在 master;合并用 `--no-ff`;推送**禁管道**(P13 遇过 push 管道假绿);对账用 `git ls-remote` 且**必须校验变量非空**(空变量会让 `[ "$L" = "$R" ]` 假 MATCH)。