Files
crearte-monorepo/docs/specs/2026-10-03-p14-content-service-split-design.md
T
XingfenD 33c2d8d4b8 docs: re-pin the P14 spec after branch-review adjudication
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.
2026-10-03 12:26:55 +08:00

41 KiB
Raw Blame History

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 沉默),也是唯一能抓「未导出的组合根方法」的手段(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

机械迁移规则(逐条可核):

  1. 方法接收者 s *ContentService → s *<Line>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 侧(不得简化参数或返回值):

// 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:把
    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)):
    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*\) 的行都是违规。接收者名必须是可选分组(全分支终审 FR-1,important,已由 commit e04694b 修正):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 红)。
  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 -- <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.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 <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 累计教训,逐条来自真实事故)

  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)。