The branch review approved with notes but overturned two universal claims I had already committed to the spec. Both overturns were independently reproduced before acting on them, and both were correct. FR-1 (important): assertion 5's source scan required a NAMED receiver, but Go permits omitting an unused one, and a shadowing body is exactly the case that often needs none. Under func (*ContentService) CoverURL(...) the old pattern matched nothing, so build, vet and all seven assertions stayed green while the shadow was effective. The regex literal in §5-T1.5 is corrected to the optional group now in the code, and the claim that assertion 5 is the only mechanical way to catch shadowing is scoped: it was false before the fix, and after it the assertion's reach is wider than the original text said, because NumMethod() exposes only exported names — so an unexported root method is also caught by the source scan alone. FR-5: my adjudication of F4 said the two layers 'cover completely'. That is a universal claim and an orphan private helper on the wrong line is its counterexample (assertion 2 filters on IsExported, and Go does not report unused methods). The wording is narrowed to what the layers actually cover, with the reason the verdict still holds: the third layer can only ever produce dead code. Both are recorded as instances of the same defect I keep committing — stating a conclusion before establishing its range. §8 rule 5 already required universal claims to have universal-range evidence; these were two places I did not follow my own rule. The lesson is written into the spec rather than only the ledger: when claiming a guard is the only thing that catches a failure mode, enumerate the forms first (named/unnamed receiver, value/pointer, exported/unexported), or the claim becomes the next reviewer's counterexample.
41 KiB
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占位绕开不需要的依赖;- 单函数最大
Approve170 行(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(传nilbundle,只喂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 拆分安全性:三个风险已实测证伪
- 包内直接读 ContentService 私有字段 = 零。
account.go/auth.go里的s.store/s.users属于它们自己的 struct;AccountService持的是repository.ContentStore(仓储接口)而非*ContentService,完全不受拆分影响。 - ContentService 被 interface 化 / 类型断言 / 方法值使用 = 零(
type ContentService struct全仓仅一处定义)。 - 共享声明的包外依赖 = 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沉默),也是唯一能抓「未导出的组合根方法」的手段(NumMethod()只暴露导出名,故断言④看不见它们)。⚠️ RE-PIN(全分支终审 FR-1,important):④–⑦ 原由e195be0交付,但⑤的正则当时把接收者名写成必需分组(\s*\w+\s+),而 Go 允许省略不用的接收者名——遮蔽体恰好常不需它(func (*ContentService) CoverURL(...)),故该形态下 build/vet/七断言全绿而遮蔽确实生效(终审实测:经组合根调用返被接管值、直调子 service 返原值)。已由e04694b改为可选分组(?:\w+\s+)?并双向验证(合法树 7/7 绿;匿名指针/匿名值/有名三形态均 A5 红;负控制不过度捕获ContentServiceFoo)。⑥ 五个子 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 迁走后其余保持原位)。
// 组合根:五个职责线的嵌入组合。自身不声明任何方法——全部由嵌入提升。
// 保留它的唯一理由是让 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 为例,最小依赖线):
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
机械迁移规则(逐条可核):
- 方法接收者
s *ContentService→s *<Line>Service;方法体逐字不动(字段访问s.store/s.users/s.objects/s.bundle在子 service 上仍然有效,因为各子 service 有自己的同名字段)。 - 私有 helper 随其唯一使用线迁移(
reactionTarget→reaction、checkSubmissionRules/checkUploadRefs→submission、randomToken→upload、versionFromUpload/pendingKeys→moderation)。 ErrUnsupportedMediaType+allowedCoverTypes→upload.go(独占,§5c)。- 每个新文件的 import 块只列该文件实际用到的包(
goimports语义)。禁止照抄 content.go 的 16 个 import。 - 不得改动任何方法签名、错误值文本、slog 字段、SQL/仓储调用顺序。这是 D-G 行为不变契约的机械保证。
3.3 五个 handler 的窄接口(D-E)
每个 handler 文件内声明自己需要的接口,方法签名逐字照抄 service 侧(不得简化参数或返回值):
// 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:把改为直接构造 catalog 线(删除contentSvc := service.NewContentService( repository.NewPostgresContentStore(pool), repository.NewPostgresUserStore(pool), objects, nil) count, err := exportCatalog(cmd.Context(), handler.NewGames(contentSvc), out, pretty)nilbundle 占位与NewPostgresUserStore(pool)):并同步更新那条「bundle 不参与导出」的注释——依赖面已从 4 槽降到 2 槽,catalogSvc := service.NewCatalogService(repository.NewPostgresContentStore(pool), objects) count, err := exportCatalog(cmd.Context(), handler.NewGames(catalogSvc), out, pretty)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 错误映射去重(
WriteError92 处,但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 的增订说明):
- 组合根形态:
reflect.TypeOf(service.ContentService{})恰有 5 个字段,每个Anonymous == true(嵌入)、Kind == reflect.Ptr,且Elem().Name()的集合恰为{CatalogService, ReactionService, UploadService, SubmissionService, ModerationService}。 - 各线导出方法名集合(逐字,防方法迁移):
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}
- 死字段不复活:
ContentService与五个子 service 都不得有名为now的字段(D-H)。 - 组合根方法集恰为提升之并:
reflect.TypeOf(&ContentService{})的方法名集合逐字等于断言 2 五个集合的并(22 个)。必须用指针类型(子 service 方法全是指针接收者)。抓「组合根新增方法」。 - 源码不得声明组合根方法:扫描包内非测试
.go,任何匹配^func\s+\(\s*(?:\w+\s+)?\*?ContentService\s*\)的行都是违规。接收者名必须是可选分组(全分支终审 FR-1,important,已由 commite04694b修正):Go 允许省略不使用的接收者名,而遮蔽体恰好常常不需要它(func (*ContentService) CoverURL(k string) string { return "X" });旧写法把名字当必需,故该形态下 build/vet/七条断言全绿而遮蔽确实生效。本扫描是词法级的:块注释内部以func (…ContentService)开头的行也会命中(FR-2 实测假红)——这是刻意的过严,不要为此放宽正则;若真被规约注释误伤,改法是剥掉/* */与//后再扫,而不是删掉这条断言。仓内有先例:bundle/vector_test.go、cmd/catalog_test.go都用os.ReadFile。- ⚠️ 范围限定(终审 FR-1 推翻了我原先的全称声明):原文曾写「这是唯一能抓『遮蔽提升方法』的手段」。在 FR-1 修正之前这句为假(匿名接收者形态连它也抓不到)。修正后它在导出与未导出方法的全部接收者形态上成立,且范围比原文更宽:因
reflect.Type.NumMethod()只暴露导出方法,断言 4 的视野仅 22 个导出名,故未导出的组合根方法也只有本断言能抓(四轮 mutation 仅变方法名大小写即实测坐实:未导出两轮 A4 绿/A5 红,导出两轮 A4 红/A5 红)。
- ⚠️ 范围限定(终审 FR-1 推翻了我原先的全称声明):原文曾写「这是唯一能抓『遮蔽提升方法』的手段」。在 FR-1 修正之前这句为假(匿名接收者形态连它也抓不到)。修正后它在导出与未导出方法的全部接收者形态上成立,且范围比原文更宽:因
- 各线字段名集合:五个子 service 的字段名逐字等于 §1.4 的依赖面——
CatalogService={objects,store}、ReactionService={store}、UploadService={bundle,objects,store,users}、SubmissionService={objects,store}、ModerationService={objects,store,users}。抓「重新长出内部状态」与「依赖面被悄悄放宽」。 - 构造器不漏装:
NewContentService(nil, nil, nil, nil)后五个嵌入指针均非 nil。传nil依赖是安全的:构造器只做纯组装、不解引用参数(既有测试已有多处以nilbundle 构造,已证安全)。
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 -- <file>恢复并核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 红线)
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.go234 同口径——已实测核对:git show dbf7fe5:src/internal/service/content.go | wc -l= 856、wc -l auth.go= 234。不要用len(text.split('\n')):文件以换行结尾时它会多算一个空段(本批实测 content.gowc -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)。- 挂账新增:
Approve170 行的 7 阶段内部重构(D-F)、memory_content.go814 行测试替身规模、handler 错误映射去重(候选 C)、AccountService/CleanupService的now字段使用情况复核(本批只确证ContentService.now死,未审其它)。 - 纪律:显式
git add <file>,禁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 守着它。⚠️ RE-PIN(终审 FR-1):这句曾是全称声明而范围为假——断言 5 原正则要求接收者有名,故匿名接收者形态(func (*ContentService) M(...))连它也抓不到,而遮蔽确实生效。现已由 e04694b 改为可选分组,该全称声明在全部接收者形态上成立;另因 NumMethod() 只暴露导出名,断言 4 对未导出的组合根方法是盲的,那部分同样只能靠断言 5。教训:「唯一手段」这类全称声明写下时就要枚举形态(有名/匿名、值/指针、导出/未导出),否则它会成为下一个审查者的反例靶子 |
§8 实现纪律(P11–P13 累计教训,逐条来自真实事故)
- 不推断,只实测。 任何「应该是 / 大概 / 按惯例」都要跑一条命令确认。P13 控制者在勘查期栽两次(用运行时注入验证构建期烘焙、用
APPLY.toString()+new Function序列化注入),复核期写错断言/grep/锚点 13 次。 - 断言/grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。 P13 实例:核不得回退项时两条 grep 返 0 而实际零变更(漏了中间三个类名 / 类序不同);查产物死 utility 时手写反斜杠转义返 0 而它真在(应用
grep -F);做归属分析时把声明行算成使用行。 - 修守卫必须两个方向都验:对合法值不误红 且 在 mutation 下不误绿。P13 控制者第一次纠正 scrim 判据时只验了前者,交付了一个数学恒真的断言(
max(填充侧,边框侧) ≥ 3,下界 √16.50=4.0621>3);同一毛病在 alpha 兜底分支重演(只对当前浏览器输出验,漏了它声称支持的 CSS Color 4 形态)。 max(a,b) ≥ k是恒真断言高发区:当 a、b 互补时 max 有非平凡下界,阈值低于该下界即永不可能失败。- 全称声明需要全称范围的证据。 P13 的 spec 写「accent 是全仓唯一作背景的地方」为假——spike 0 的 grep 与新守卫都只覆盖
app/,而runtime/与之同级。本批同理:任何「全仓零引用」「唯一使用点」的断言,grep 范围必须覆盖cmd/+internal/全部子树,且区分声明行与使用行。 - 链式命令里别放会因「无匹配」而退 1 的 grep(
grep -c输出 0 即退 1、grep -v无剩余即退 1),会静默中断&&链导致后续步骤(如 commit)未执行。P13 因此丢过一次 commit。 - 相对路径
cd在循环里会漂移。 P13 收口对账时for r in ...; do cd $r; ...; cd ..; done让后三轮跑进错误目录,输出一堆fatal并误报一个仓 porcelain=15。用绝对路径 + 每仓独立子 shell。 - 一仓一写者(
sdd-parallel-dispatch§1):所有任务同动crearte-server单一工作树,故交一个子代理,不并行争用.git/index.lock。 - 登记挂账前先 grep 既有测试与文档,否则会开出「补一条已存在的测试」这类伪工作(P13 实例:
router_test.go:62早已钉住某边界,而我在 plan 里建议「补反面钉桩」)。 - 合并纪律:inner repo 提交前必须
git branch --show-current确认不在 master;合并用--no-ff;推送禁管道(P13 遇过 push 管道假绿);对账用git ls-remote且必须校验变量非空(空变量会让[ "$L" = "$R" ]假 MATCH)。