# 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 归属映射(含我对两处误归属的纠正记录)。**RE-PIN 补记**(审查者 M-8 实测):`ErrSubmissionConflict` 除投稿/审核两线与 `handler/submissions.go` 外,还被**两线之外的 `AccountService`(`account.go:136`,账号注销的 DeletionReport 路径)**使用——把它降级为小写即 `account.go:136:29: undefined: ErrSubmissionConflict` 编译红。故其 SHARED 判定**不仅正确而且必要** | | **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 输出本身即「有牙」证明 | > ⚠️ **RE-PIN(2026-10-03,任务级审查 F1 important)**:原 D-J 的**三条断言与其自述目的不匹配**——三项里的「防方法迁回组合根」**没有任何一条断言覆盖**(断言①只数字段、②只看子 service、③只查字段名)。实测(审查者 M-3b + 控制者独立复现):在组合根上新增方法后**三断言全 PASS、`go build`/`go vet` 绿、全量 11 包测试也全绿** → god object 可静默回潮,零机械信号。另实测 `go vet` **不报告**组合根方法对提升方法的遮蔽(M-9,`vet_exit=0`)。 > > **D-J 增订为七类断言**(④–⑦ 为 re-pin 后补,已由 commit `e195be0` 交付):④ `ContentService` 的**指针**方法名集合恰为五线导出方法之并(22 个)——抓「组合根新增方法」⑤ **源码扫描**:包内非测试 `.go` 里不得存在任何以 `ContentService` 为接收者的方法声明——**这是唯一能抓「遮蔽提升方法」的机械手段**(遮蔽后反射方法名集合不变、`go vet` 沉默)⑥ 五个子 service 的**字段名集合**逐字等于其依赖面(§1.4)——把「无可变内部状态」这个 §0 赖以论证可拆分的不变量变成机器契约(原断言③只禁字面名 `now`,故 `clock`/`logger`/`cache` 任何名字都能静默通过:钉的是名字而非不变量)⑦ 实例化 `NewContentService(nil, nil, nil, nil)` 后断言五个嵌入指针**均非 nil**——原断言①是纯静态类型检查,故构造器漏装某条线时 build/vet/守卫全绿,故障以运行时 nil 解引用 panic 出现(M-7)。 > > **两条被 Go 提升规则 spike 实测否决的修法**(勿再尝试,理由已写入 `architecture_test.go` 注释):(i) 「断言 `reflect.TypeOf(ContentService{}).NumMethod() == 0`」**不成立**——嵌入的是 `*T`,其方法**同时进入值类型与指针类型的方法集**,故合法树上值类型 NumMethod 已是 22,该断言**恒假**(P13 恒真断言的镜像形态)。(ii) 「比 `Method(i).Func` 的同一性以侦测遮蔽」**不成立**——提升方法本身是 forwarding wrapper,合法树上 `root.CoverURL.Func`(5153408) 与 `CatalogService.CoverURL.Func`(5148192) **已不同**,遮蔽后为 5148288,两侧都 false → **无法区分遮蔽与否**。 | **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)。**七类断言**(①–③ 为原设计、④–⑦ 为 RE-PIN 后补,见 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)。 4. **组合根方法集恰为提升之并**:`reflect.TypeOf(&ContentService{})` 的方法名集合逐字等于断言 2 五个集合的并(22 个)。**必须用指针类型**(子 service 方法全是指针接收者)。抓「组合根新增方法」。 5. **源码不得声明组合根方法**:扫描包内非测试 `.go`,任何匹配 `^func\s+\(\s*\w+\s+\*?ContentService\s*\)` 的行都是违规。**这是唯一能抓「遮蔽提升方法」的手段**(遮蔽后断言 4 的名字集合不变、`go vet` 也沉默)。仓内有先例:`bundle/vector_test.go`、`cmd/catalog_test.go` 都用 `os.ReadFile`。 6. **各线字段名集合**:五个子 service 的字段名逐字等于 §1.4 的依赖面——`CatalogService={objects,store}`、`ReactionService={store}`、`UploadService={bundle,objects,store,users}`、`SubmissionService={objects,store}`、`ModerationService={objects,store,users}`。抓「重新长出内部状态」与「依赖面被悄悄放宽」。 7. **构造器不漏装**:`NewContentService(nil, nil, nil, nil)` 后五个嵌入指针均非 nil。传 `nil` 依赖是安全的:构造器只做纯组装、不解引用参数(既有测试已有多处以 `nil` bundle 构造,已证安全)。 **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 必须红。 - (d) 在组合根**新增**一个方法(不移除任何子 service 方法)→ 断言 4 **与** 断言 5 必须红; - (e) 在组合根**遮蔽**一个提升方法(**保留**子 service 原件)→ 断言 5 必须红、**断言 4 必须保持绿**(这正是断言 5 不可省的证明); - (f) 给任一子 service 加一个**非 `now`** 的字段(如 `clock func() int64`)→ 断言 6 必须红、断言 3 必须保持绿; - (g) 给某条线**放宽依赖面**(如给 `CatalogService` 塞 `users`)→ 断言 6 必须红; - (h) 从 `NewContentService` 删掉某条线的组装行 → 断言 7 必须红。 每次 mutation 后 `git checkout -- ` 恢复并核 `git diff` 空。 > ⚠️ **RE-PIN(审查者 §12-1):mutation (a) 的原设计有一条绕行路径。** (a) 之所以会红,是因为它同时把方法从 `CatalogService` **拿走**了(断言 2 的集合缺员),**不是因为断言侦测到组合根上多出方法**。若把它改成「**复制**一份到组合根而保留 `catalog.go` 原件」(即 (e) 的遮蔽形态),(a) 的设计就会**误绿**——这是我 spec 里 positive control 自身的一个未被发现的盲区,由 (d)(e) 补上。 > > ⚠️ **RE-PIN(控制者自伤教训):mutation 验证脚本的 `restore()` 绝不能用 `git checkout -- .`。** 若 fix-forward 编辑**尚未提交**,全量回滚会把它一起抹掉,导致后续各轮**全在测一棵没有修复的树**(本会话实测:第一轮后四条新断言被抹掉、`architecture_test.go` 回到 118 行,后四轮报「绿=3」= 原始三断言数,汇总显示四条「未按预期」)。修法:`restore()` 只回滚 mutation **触及的那几个源文件**,且每轮恢复后断言守卫文件的 `func Test` 计数仍为预期值,否则 **FATAL 终止**——让脚本在被自毁时大声失败,而不是静默产出「未按预期」的假结论。 > > ⚠️ **遮蔽型 mutation 的签名必须逐字取原文。** 控制者第一版 (e) 用正则 `^func \(s \*CatalogService\) (Approve\(.*?\))` 抽签名,非贪婪 `.*?` 把返回值 `(error)` 截断了 → 生成的遮蔽体 `return nil` 与空返回值冲突 → **编译红而非断言红**,脚本却把它报成「守卫拦住了」。mutation 没编译过 ≠ 守卫有牙。 ### 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。 ### 结构指标(交付时须报实测数字) > **RE-PIN(审查者 F5):行数口径统一为 `wc -l`**(POSIX:数换行符),与 §0 的基线 856、`auth.go` 234 同口径——已实测核对:`git show dbf7fe5:src/internal/service/content.go | wc -l` = **856**、`wc -l auth.go` = **234**。**不要用 `len(text.split('\n'))`**:文件以换行结尾时它会多算一个空段(本批实测 content.go `wc -l`=82 而 split=83,六文件最大 `wc -l`=298 而 split=299,控制者据此报错过一轮数字)。本批两种口径都远低于目标,无实际影响;但若将来某文件恰落在边界(如 299 vs 300),口径歧义会直接决定 pass/fail。 | 指标 | 基线 | 目标 | 实测(`wc -l` 口径) | |---|---|---|---| | `content.go` 行数 | 856 | **< 120**(组合根 + 共享声明) | **82** ✅ | | 六个文件最大行数 | 856 | **< 300**(submission.go 预计 ~290) | **298**(`moderation.go`,非 spec 预估的 submission.go)✅ | | `service/` 目录内 >400 行的生产文件 | 1(content.go) | **0** | **0** ✅ | | handler 里 `service.ContentService` 引用 | 10 处 | **0** | **0** ✅ | | `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** | **0** ✅(`NewPostgresUserStore` 亦归零) | | `ContentService` 自身声明的方法数 | 25 | **0**(全部提升) | **0** ✅ | | 既有 `*_test.go` 修改文件数 | — | **0** | **0** ✅(仅新增 `architecture_test.go`) | 六文件实测行数(`wc -l`):`content.go` 82 · `catalog.go` 110 · `reaction.go` 102 · `upload.go` 99 · `submission.go` 291 · `moderation.go` 298。 --- ## §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–h)必须附红/绿输出。P13 教训:`max(a,b) ≥ k` 形态恒真、positive control 只钉一个子树 → **守卫自身要受同等审视**。**P14 实证了这条风险的两种形态**:(i) 恒真(P13)与恒假(本批被否决的 `NumMethod()==0` 修法)都要防;(ii) **守卫覆盖不全**比恒真更隐蔽——本批三条断言各自都真有牙(M-1b/M-2/M-5 双向验证实证),但合起来漏掉了「组合根长方法」整个维度,且 spec 自己的 positive control (a) 有绕行路径(见 §5-T1 的 RE-PIN) | | **RE-PIN:组合根上直接访问依赖会编译失败(Route C 的额外性质,非缺陷)** | 组合根 `ContentService` **零具名字段**,故 `s.store` / `s.users` / `s.objects` / `s.bundle` 在组合根上都是 **ambiguous selector**(`catalog`/`upload`/`submission`/`moderation` 四个子 service 都有 `objects`,Go 提升规则下深度相同即歧义)。要访问须限定为 `s.CatalogService.objects` 这种形式。**这是比架构守卫更早的一道防线**:往组合根直接摸依赖的新代码编译期即失败。反过来也解释了为何 mutation (b) 加**具体字段**后守卫是唯一防线——加具体字段本身在 Go 里可编译(不与嵌入冲突) | | **RE-PIN:`go vet` 不报告组合根方法对提升方法的遮蔽** | 审查者 M-9 实测 `vet_exit=0`:在组合根上写一个与提升方法同名同签名的方法(Go 深度规则下 depth 0 胜出)会**静默接管**全部调用,lint 层零信号。故断言 5(源码扫描)不可省——反射方法名集合在遮蔽后不变、断言 4 会保持绿(已由 mutation (e) 实证)。**这两条同属「组合根一旦长出代码就静默出问题」的同一风险面**,也是 §2.1 方案 B(将来若删除组合根)的收尾条件之一:只要组合根存在,就必须有断言 4+5 守着它 | --- ## §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)。