Files
crearte-monorepo/docs/plans/2026-10-03-p14-content-service-split.md
XingfenD ba1fe3ecb0 docs: P14 design + plan for splitting the ContentService god object
Registers the next batch before implementation starts, so the design is
versioned and reviewable rather than living only on disk.

Scope: crearte-server only. content.go is 856 lines / 30 functions, the sole
outlier in internal/service (next largest production file auth.go is 234 lines;
content.go alone is 22% of the directory) and carries five unrelated
responsibility lines in one struct whose five fields are all shared repository
dependencies with no mutable internal state.

Three survey findings make the split low-risk rather than aspirational:
- the seven cross-line calls all target package-level helper functions, with
  zero method-to-method cross-line calls, so splitting creates no cross-service
  callbacks and no import cycle;
- each of the five handlers uses exactly one line's methods with zero overlap,
  so each dependency face narrows from 22 methods to its own 2-6 with no adapter;
- 11 of the 19 test construction sites only construct and never call methods,
  and a Go embedding spike (H1-H4, run in golang:1.24-alpine, vet clean)
  confirmed the promoted method set satisfies consumer-defined narrow
  interfaces — so the composite root keeps every construction site and all five
  serve.go wirings unchanged while handlers still narrow.

The survey also found ContentService.now is a dead field: zero s.now references
in the file, no test seam injecting it, while the sibling services that share
the pattern do use theirs (auth.go reads s.now(), cleanup.go has SetNow). It is
removed as part of the split rather than being assigned a line.

Two risks were falsified by measurement before designing: no code inside the
package reads ContentService's private fields (AccountService holds
repository.ContentStore, not *ContentService, so it is untouched), and the type
is never interface-ised, type-asserted, or used as a method value.

Deliberately out of scope: the 170-line Approve function (its seven phases are
mapped and logged for a later batch — one concern per batch), memory_content.go
(814 lines but a test double with zero non-test references), and handler
error-mapping dedup (92 WriteError sites but only 2 errors.Is checks, so the
duplication does not justify itself).

Verification plan: four gates in the dockerized toolchain plus structural
metrics (content.go under 120 lines, largest of the six files under 300, zero
service.ContentService references left in handlers, zero existing test files
modified) and three mutations the new architecture guard must fail on.
2026-10-03 08:02:53 +08:00

93 lines
8.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 -- <file>` 恢复并核 `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 <file>`,**禁 `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 更新)——这道门不是橡皮章