# P15-A 设计:拆分 `Approve` 169 行七阶段(crearte-server) - **日期**:2026-10-03 - **仓**:`crearte-server`(Go module 根在 `src/`) - **分支**:`chore/p15a-approve-phases`(AGENTS.md:18 只允许 `{feat|fix|docs|chore}/`) - **base**:`fe810dd`(P14 merge,master) - **维度**:代码优雅(USER.md 六维度循环) - **前置**:P14 已交付(`ContentService` 拆分 + 七条架构守卫)。**本批不得改动 `architecture_test.go`**,除非某条断言因新 helper 变红(见 §5-T1 的 mutation (g))。 --- ## §0 基线(已实测,2026-10-03 12:50) **四门**(dockerized `golang:1.24-alpine`,AGENTS.md 硬红线;host Go 1.18 不可用):**`test -z "$(gofmt -l .)"` exit 0**(⚠️ **不能用 `gofmt -l . ; echo $?`**:`gofmt -l` 的语义是「列出需要格式化的文件」,**列出时仍 exit 0**,只在解析失败时 exit 2 → 旧配方的 `gofmt_exit=0` 不是证据。审查者 F3 实测。同时须把 `gofmt -l .` 的**原始输出**一并存证以证明为空)· `go vet ./...` exit 0 · `go build ./...` exit 0 · `go test ./... -count=1` **11 包 ok**(`internal/api` ~11.0s、`internal/service` ~3.7s)。⚠️ **这两个耗时是 mock 路径**:未设 `TEST_DATABASE_URL` 时真库测试**静默 SKIP**(实测 `internal/api` 4 个、全仓 `--- SKIP` 行 26 条)。受该变量门控的 Test 函数**全仓共 11 个**:`cmd` 2(`TestRewrapRotatesVersionsInPostgres`、`TestUserSetRoleCommand`)· `api` 4(`TestFullPublishChainOnPostgres`、`TestHostedPublishChainOnPostgres`、`TestAccountDeletionChainOnPostgres`、`TestAdminConsoleEndpointsOnPostgres`)· `repository` 2(`TestPostgresReactionGated`、`TestUserUsernameBackfillMigration`)· `service` 3(`TestApproveConcurrentOnlyOneWins`、`TestApproveSameWorkIDConcurrent`、`TestApproveOptionalFieldsRoundTrip`)。**「11 包 ok」不等于真库路径被验过**:本批实现者、任务级审查者、fix-forward 与控制者的**所有**跑批都 SKIP 了它们(§1.6 点名的两个并发/TOCTOU 守卫正在其中),直到全分支终审另起 `postgres:16-alpine` 对 base 与 HEAD 各跑全量 `-p 1 -v` 才补上这一层:**base `fe810dd` 194 PASS / 0 FAIL vs HEAD `b15c2a8` 199 PASS / 0 FAIL**(+5 恰为本批新增守卫),状态多重集完全一致、5 个 DB 守卫两树均真跑 PASS —— 这是「零行为变更」在 postgres 生产路径上的第一份运行时证明(此前所有证据都是静态的:T3 语句级比对、needle/标识符计数、mock 路径测试)。**合并前验证清单须含 DB-enabled 对照,否则下一批仍会静默 SKIP。** **目标函数**:`internal/service/moderation.go:47-215` = **169 行**(`wc -l` 口径,P14 spec §5 已钉该口径)。 **`service/` 包内非测试文件的函数长度分布**(78 个函数,实测普查): | 阈值 | 超过的函数数 | 清单 | |---|---|---| | >100 行 | **1** | `*ModerationService.Approve`(169) | | >80 行 | 2 | + `*AccountService.deleteAccountLocked`(83) | | >70 行 | 4 | + `*SubmissionService.UpdateSubmission`(76)、`ValidateWorkFields`(75) | | >60 行 | 7 | + `*ImportService.ImportDir`(70)、`*CleanupService.Run`(70)、`*SubmissionService.checkSubmissionRules`(66) | → **`Approve` 是包内唯一 >100 行的函数**,且比次大者(83)大一倍。这就是本批的选型依据:不是"文件太长",是**单个函数承担了七件事**。 **行数口径**:一律 `wc -l`。**不要用 `len(text.split('\n'))`**——文件以换行结尾时后者多算一个空段(P14 F5:控制者与任务级审查者为此报出 83/299 与 82/298 两套数字)。 --- ## §1 取证(决定设计,全部实测) ### 1.1 七阶段边界(`moderation.go` 真实行号;P14 挂账里 base 树的 606–772 **已作废**) | 阶段 | 行 | 内容 | 返回的错误 | 是否碰 `tx` | |---|---|---|---|---| | ① 加载 + 状态校验 + 解析 | 48–62 | `Submissions().GetByID` → `ErrNotFound` 映射 `ErrContentNotFound`;`sub.Status != Pending` → `ErrSubmissionConflict`;`ParseSubmissionEnvelope(sub.Payload)`;`p := env.Work` | `ErrContentNotFound` / `ErrSubmissionConflict` / envelope 解析错 | 否 | | ② owner 查取 | 64–70 | **仅** `Kind == NewWork` 时 `s.users.GetByID(sub.SubmitterID)` | `service: resolve submitter: %w` | 否 | | ③ 上传解析 | **72–86** | `BundleUploadID`/`CoverUploadID` 各 `Uploads().GetByID`,并校验 `up.ConsumedBy == sub.ID` | `ErrUploadUnavailable` | 否 | | ④ **乐观预检** switch | **88–101** | `NewWork`:`Works().GetByID` 已存在 → `ErrWorkIDTaken`;`NewVersion`:`Versions().Get` 已存在 → `ErrVersionExists`;其它 kind 无 case | `ErrWorkIDTaken` / `ErrVersionExists` / **`service: approve precheck: %w`** | 否(用 `s.store`) | | ⑤ 对象 Copy | 102–123 | `finalBundleKey = "bundles/"+sub.WorkID+"/"+p.Version+".bin"`;`finalCoverKey = "covers/"+sub.WorkID+"/"+coverUpload.SHA256+path.Ext(...)`;各 `s.objects.Copy` | `ErrUploadUnavailable`(`storage.ErrObjectNotFound`)/ `service: copy bundle\|cover: %w` | 否(用 `s.objects`) | | ⑥ **`WithTx` 事务** | **125–201** | `GetByIDForUpdate` 锁行 → **重校** `Status == Pending` → **悲观重检** switch(三分支)→ 建 Work / Version / Touch / UpdateMetadata → `MarkReviewed` | 见 §1.2 | **是**(`tx repository.ContentRepository`) | | ⑦ 事务错误映射 + pending 清理 + slog | **202–214** | 事务错误映射(`repository.ErrConflict` → `ErrSubmissionConflict`);`pendingKeys(bundleUpload, coverUpload)` 逐个 `s.objects.Delete`(**只 `slog.Error` 不返回**,best-effort);`slog.Info("approve", …)`;`return nil` | `ErrSubmissionConflict` / 裸 `err` | 否 | **`WithTx` 签名**(`internal/repository/content.go:38`):`WithTx(ctx context.Context, fn func(ContentRepository) error) error`。 ### 1.2 🔴 阶段④与阶段⑥的 switch **不同形,禁止合并**(本批最重要的约束) 两者都 `switch sub.Kind`、都查 `Works().GetByID` / `Versions().Get`、都可能返回 `ErrWorkIDTaken`/`ErrVersionExists`,故看上去可以抽成一个"重复性检查" helper 共用。**实测比对后确认不可合并**,四处实质差异: | 差异 | 阶段④(88–101) | 阶段⑥(136–199) | |---|---|---| | **kind 覆盖** | 只有 `NewWork` / `NewVersion` 两个 case | 三个 case:`NewWork` / `NewVersion` / **`MetadataChange`** | | **`NewVersion` 的额外校验** | 只查 `Versions().Get` 是否已存在 | **还查** `work.Runtime != model.RuntimeVirtual \|\| bundleUpload == nil` → `ErrSubmissionConflict`;且 `Versions().Create` 后**还调** `tx.Works().Touch` | | **`NewWork` 的动作** | 只查冲突,不建实体 | `PayloadToWork(p)` + 设 `OwnerID`/`CoverKey` + `tx.Works().Create`(`ErrConflict` → `ErrWorkIDTaken`)+ 条件建 Version | | **错误文案** | 非 `ErrNotFound` 的错误包成 **`service: approve precheck: %w`** | **裸返 `err`**,无包装 | **语义差异**(不可合并的根本原因):④是**乐观快失败**——无锁、在**任何对象 Copy 之前**,失败时副作用为零;⑥是**悲观重检**——持 `SELECT … FOR UPDATE` 行锁、在对象**已 Copy 之后**,失败时须靠阶段⑦清理 pending 对象。合并成一个 helper 会让"检查"与"写入"的边界模糊,且**改变错误文案**(`service: approve precheck: …` 会消失或蔓延到事务内),属行为变更。 → **裁定:④与⑥各自独立拆 helper,不共享代码。** 表面重复是**有意的双层校验**(TOCTOU 防护:预检减少无谓 Copy,重检保证正确性),不是可消除的冗余。spec 里写明这条,防实现者"顺手 DRY"。 ### 1.3 helper 形态:**未导出方法与包级函数都不触架构守卫**(实测,推翻 P15 账本 §8.1 的说法) 账本原写"新 helper 应是包级 `func`,因为方法会改动 `expectedLineFields`/断言 2 的方法集并触发守卫红"。**这句是错的**,已用两轮 mutation 实测推翻: | 探针 | 追加到 `moderation.go` 的声明 | `go build` | 七断言 | |---|---|---|---| | A | `func (s *ModerationService) approvePrecheckLocked(ctx context.Context) error { return nil }`(未导出**方法**) | exit 0 | **7 PASS / 0 FAIL** | | B | `func approvePrecheck(store repository.ContentStore, sub model.Submission) error { return nil }`(**包级**函数) | exit 0 | **7 PASS / 0 FAIL** | **机制**:断言 2 `TestLineExportedMethods` 用 `m.IsExported()` 过滤,断言 4 遍历的 `reflect.Type.NumMethod()` **本身只暴露导出方法** → 未导出方法根本不在两条断言的视野内。断言 6 只看**字段**,不看方法。 → **形态选择依据是"是否需要接收者字段",不是守卫约束**: - 需要 `s.store` / `s.objects` / `s.users` 的 → **未导出方法**(体例:`account.go:78` 的 `func (s *AccountService) deleteAccountLocked(ctx, user) (DeletionReport, error)`,注释「共享内核,调用方已确认账号存在且未注销」,两处调用) - 只需 `tx` + 已备好的数据 → **包级函数**(体例:`moderation.go:283` `versionFromUpload(workID, version, objectKey, up)`、`:290` `pendingKeys(uploads ...*model.Upload)`) ⚠️ **不得给 `ModerationService` 加字段**(断言 6 会红:`expectedLineFields["ModerationService"] = {objects, store, users}`);**不得在 `ContentService` 上加任何方法**(断言 5 源码扫描会红)。 ### 1.4 `ContentStore` 内嵌 `ContentRepository`(决定事务 helper 的签名) ```go // internal/repository/content.go:28-39 type ContentRepository interface { Works() WorkRepository Versions() WorkVersionRepository Submissions() SubmissionRepository Uploads() UploadRepository Reactions() ReactionRepository } type ContentStore interface { ContentRepository WithTx(ctx context.Context, fn func(ContentRepository) error) error } ``` → **`ContentStore` 是 `ContentRepository` 的超集**。故收 `repository.ContentRepository` 的 helper **既能接 `s.store`(阶段④)也能接 `tx`(阶段⑥)**。但**本批刻意不用这个能力去合并④⑥**(见 §1.2);它只用于让事务体 helper 的签名收 `tx`。 ### 1.5 `ParseSubmissionEnvelope` 的返回类型(决定 plan 结构体字段类型) ```go // internal/service/content.go:54-60 type SubmissionEnvelope struct { Work WorkPayload `json:"work"` BundleUploadID string `json:"bundle_upload_id,omitempty"` CoverUploadID string `json:"cover_upload_id,omitempty"` } func ParseSubmissionEnvelope(raw []byte) (SubmissionEnvelope, error) // 值返回,非指针 ``` `Approve` 内 `p := env.Work`(`WorkPayload` 值)。**plan 结构体应持有 `p WorkPayload` 值而非 `env` 指针**——与现有代码逐字一致,避免引入 nil 判定分支(那会是行为变更)。 ### 1.6 既有测试覆盖:**充分,不需要先补 characterization 测试** `Approve` 有 5 个直接调用点(`internal/service/content_test.go:91,205,268,284,359`)与 13 个相关测试函数: | 测试 | 覆盖的阶段/分支 | |---|---| | `content_test.go:18 TestApproveConcurrentOnlyOneWins` | ⑥ 的行锁 + 重校状态(并发只有一个赢) | | `content_test.go:126 TestApproveSameWorkIDConcurrent` | ④/⑥ 的 `ErrWorkIDTaken`(断言文本:`loser error = %v, want ErrWorkIDTaken or ErrSubmissionConflict`) | | `content_test.go:237 TestApproveMetadataChangeFeaturesSemantics` | ⑥ 的 `MetadataChange` 分支 + **`Features` 三态语义**(用 `meta-absent` 与 `,"features":{}` 两个 payload 精确区分 nil=保留 / 空 map=清空) | | `content_test.go:293 TestApproveOptionalFieldsRoundTrip` | ①② 的 envelope 解析与字段往返 | | `content_hosted_test.go:76 TestMetadataChangeRuntimeImmutable` | ⑥ 的 Runtime 不可变约束 | | `namespace_test.go:132 TestNewVersionMetadataChangeOwnership` | ⑥ 的 OwnerID 归属 | | `api/admin_test.go:83,131,149,197,231`(5 个) | HTTP 层:发布、Forbidden+DoubleApprove、同 slug 异命名空间、NewVersion、MetadataChange | | `api/integration_test.go:27 TestFullPublishChainOnPostgres`、`api/hosted_postgres_test.go:28 TestHostedPublishChainOnPostgres` | ⑤⑥ 的完整发布链(真库) | → **既有测试就是本批的行为守卫**(与 P14 D-G 同构)。验收硬指标:**既有 `*_test.go` 修改数 = 0**。若实现者发现必须改测试,**停下来报告**而不是改(P14 §4 末条)。 ### 1.7 调用面:`Approve` 只有一个非测试调用点 `internal/handler/admin.go:62`:`h.svc.Approve(c.Request.Context(), CurrentUser(c).ID, c.Param("id"), body.Note)`(经 `moderationPort` 窄接口)→ **新 helper 无须导出**,且 `Approve` 的**签名与错误语义必须逐字不变**(`moderationPort` 由 P14 断言 2 钉住 6 个方法名,签名变更会打破 `serve.go` 的接口满足性 → 编译红)。 --- ## §2 设计决策 | # | 决策点 | 裁定 | 依据 | |---|---|---|---| | **D-A** | 拆分粒度 | **按七阶段拆 5 个 helper**(①②③ 合为一个"输入装载"、④ 一个、⑤ 一个、⑥ 一个(内部再拆 3 个 kind 分支)、⑦ 一个),不逐行拆 | §1.1 的阶段边界是**语义边界**(副作用发生点),不是任意切点。①②③ 都只读、都产出 plan 的字段、失败时零副作用 → 合为一个 helper 不损失可读性;④⑤⑥⑦ 各自有独立副作用(预检/Copy/事务/清理)→ 必须分开 | | **D-B** | plan 结构体 | 新增**包级私有 struct** `approvePlan`,字段:`sub model.Submission`、`p WorkPayload`、`owner model.User`、`bundleUpload *model.Upload`、`coverUpload *model.Upload`、`finalBundleKey string`、`finalCoverKey string` | 七阶段之间传递的就是这七样东西(实测自函数体)。用 struct 而非 7 个返回值:Go 的多返回值超过 4 个即难读,且 ⑤ 要往 plan 里写 `finalBundleKey`/`finalCoverKey` 供 ⑥⑦ 用 | | **D-C** | helper 形态 | ①②③④⑤⑦ → **未导出方法**(需 `s.store`/`s.users`/`s.objects`);⑥ 事务体 → **包级函数** `applyApprovalInTx(ctx, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string) error`;⑥ 内三个 kind 分支 → **包级函数** | §1.3 实测两种形态都不触守卫;选择依据是"是否需要接收者字段"。事务体不碰 `s.*`(只用 `tx`)→ 包级函数更纯,且签名自证"事务内不得访问 store 外的东西"。体例分别同 `deleteAccountLocked` 与 `versionFromUpload` | | **D-D** | ④与⑥**不合并** | 各自独立 helper,不共享代码 | §1.2:四处实质差异 + 乐观/悲观语义不同 + 错误文案不同。表面重复是**有意的 TOCTOU 双层校验** | | **D-E** | 事务边界与 Copy 顺序 | **一行不动**:⑤ 的两次 `s.objects.Copy` 必须在 ⑥ `WithTx` **之外**(故 ⑦ 需要清理);⑥ 的全部写入必须在 `WithTx` **之内**;`MarkReviewed` 必须是事务内**最后一个**调用 | 改变顺序即行为变更:Copy 进事务会让长耗时 I/O 持锁;`MarkReviewed` 提前会让"实体建成但状态未改"的中间态可被并发观察到 | | **D-F** | `Features` 三态语义 | `if p.Features == nil { work.Features = existing.Features }` **连注释一起搬**进 MetadataChange helper,**不得"简化"** | 该注释原文:「旧提交(features 采集上线前创建)的 payload 无 features 键:保留现值,避免审批通过时静默清空已发布作品的运行权限;显式 `{}` 才是清空」。三态(nil=保留 / 空 map=清空 / 有值=覆盖)由 `content_test.go:237` 精确钉住,简化即红 | | **D-G** | 零行为变更 | 纯结构重构:**方法体逐字迁移**,只改接收者/参数传递与缩进。`Approve` 的签名、返回的每个错误值与错误文案、`slog` 的两条日志(键名与顺序)**全部逐字不变** | P14 同款契约。验收:既有测试零修改 + 四门绿 + 字节级方法体比对 | | **D-H** | 结构指标钉什么 | **钉「最大单函数行数」,不钉文件行数** | helper 仍在 `moderation.go`(298 行)内,拆完文件**可能不降反升**。用文件行数当指标会逼实现者把 helper 塞进新文件,那是无意义的文件增殖 | | **D-I** | 派发形态 | **一个实现者子代理**做完 T1→T3(同动 crearte-server 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款。**P15-B 在 crearte 仓,与本批可并行**(两仓各自单写者) | ### §2.1 被否方案 | 方案 | 否决理由 | |---|---| | **A:把④⑥的 switch 抽成共享 helper(DRY)** | §1.2 实测四处差异 + 乐观/悲观语义不同。合并会改变错误文案(`service: approve precheck: %w` 消失或蔓延)与 `NewVersion` 分支的校验集 → **行为变更**,违反 D-G | | **B:用 `repository.ContentRepository` 参数统一④⑥(机械上可行)** | §1.4 证明 `ContentStore` 内嵌 `ContentRepository`,故技术上能让一个 helper 同时接 `s.store` 与 `tx`。但"能"不等于"该":它把①的乐观预检与⑥的持锁重检伪装成同一件事,**读者无法从签名看出锁语义差别** → 比 A 更隐蔽的行为语义损失 | | **C:把七阶段拆到七个新文件** | 文件增殖。`moderation.go` 298 行本身不超标(P14 目标 <300),拆文件不解决"单函数太长"这个真问题,且 D-H 已判定不用文件行数当指标 | | **D:先补 characterization 测试再重构** | §1.6 实测既有 13 个测试函数已覆盖全部七阶段与三态语义(含并发、真库发布链)。补测试是**额外成本而非额外保障**,且会违反"既有 `*_test.go` 修改数 = 0"这个更硬的验收判据 | | **E:本批一并拆 `deleteAccountLocked`(83) / `UpdateSubmission`(76)** | D-F「一批一个关注点」(P14 同款)。三个函数分属 account/submission/moderation 三条线,混在一批会让"行为不变"的验收判断面翻三倍。已挂账 | | **F:把 `Approve` 改成状态机/策略模式** | 过度设计。七个阶段是**线性**的(无分支跳转、无回退),`switch sub.Kind` 已经是策略分派。引入状态机会把 169 行变成更多的行 + 一个新抽象层,且**改变错误的产生位置**(行为变更) | --- ## §3 实现要求 ### 3.1 目标形态(骨架,**签名为示意,实现者须按 §1.1 的真实类型逐字对齐**) ```go func (s *ModerationService) Approve(ctx context.Context, adminID, submissionID, note string) error { plan, err := s.loadApprovePlan(ctx, submissionID) // ①②③ if err != nil { return err } if err := s.precheckApprove(ctx, plan); err != nil { // ④(乐观、无锁、Copy 之前) return err } if err := s.copyApprovalObjects(ctx, &plan); err != nil { // ⑤(写 plan.finalBundleKey/finalCoverKey) return err } err = s.store.WithTx(ctx, func(tx repository.ContentRepository) error { return applyApprovalInTx(ctx, tx, submissionID, plan, adminID, note) // ⑥ }) if err != nil { if errors.Is(err, repository.ErrConflict) { return ErrSubmissionConflict } return err } s.cleanupApprovalUploads(ctx, plan) // ⑦(best-effort,只 slog.Error) slog.Info("approve", "submission", submissionID, "work", plan.sub.WorkID, "kind", plan.sub.Kind, "admin", adminID) return nil } ``` ⚠️ **注意 `copyApprovalObjects` 必须能写回 plan**:`plan` 是值类型,故该方法签名须为 `(ctx context.Context, plan *approvePlan) error`(收指针),或返回新的 plan。**选前者**——与 ⑤ 原地修改 `finalBundleKey`/`finalCoverKey` 的现有语义一致,且避免"返回新 plan 但调用方忘了接"的静默丢值。 ### 3.2 逐阶段要求 **①②③ `loadApprovePlan`**(未导出方法): - 逐字搬 **48–86** 行。返回 `(approvePlan, error)`。 - 三处错误映射逐字保留:`errors.Is(err, repository.ErrNotFound)` → `ErrContentNotFound`;`sub.Status != model.SubmissionStatusPending` → `ErrSubmissionConflict`;`fmt.Errorf("service: load submission: %w", err)`;`fmt.Errorf("service: resolve submitter: %w", err)`;两处 `ErrUploadUnavailable`(含 `up.ConsumedBy != sub.ID` 判定)。 - ② 的 `if sub.Kind == model.SubmissionKindNewWork` 条件**必须保留**(其它 kind 不查 owner,`plan.owner` 为零值;⑥ 的 `NewWork` 分支才用 `owner.ID`)。 - ③ 的 `bundleUpload = &up` 取地址语义**必须保留**(`up` 是循环内局部变量,取其地址是本函数刻意的写法;改成存值会让 `plan.bundleUpload != nil` 判定失效)。 **④ `precheckApprove`**(未导出方法): - 逐字搬 **88–101** 行。**保持用 `s.store`**(不是 `tx`)。 - 错误文案 `fmt.Errorf("service: approve precheck: %w", err)` **逐字保留**(两处)。 - **不得**增加 `MetadataChange` case(④ 现在没有,加了会改变 `MetadataChange` 提交的失败时机)。 **⑤ `copyApprovalObjects`**(未导出方法,收 `*approvePlan`): - 逐字搬 102–123 行。key 拼接公式**逐字保留**(`"bundles/"+sub.WorkID+"/"+p.Version+".bin"`、`"covers/"+sub.WorkID+"/"+coverUpload.SHA256+ext`,`ext := path.Ext(coverUpload.ObjectKey)`)。 - `if bundleUpload != nil` / `if coverUpload != nil` 的**条件 Copy 语义保留**(无上传时 `finalXxxKey` 保持 `""`,⑥ 的 `MetadataChange` 分支靠 `if finalCoverKey != ""` 判定是否覆盖)。 - `storage.ErrObjectNotFound` → `ErrUploadUnavailable` 的映射逐字保留;`service: copy bundle|cover: %w` 两条文案逐字保留。 **⑥ `applyApprovalInTx`**(包级函数): > ⚠️ **RE-PIN 2026-10-04(第二次,闭合全分支终审 N1–N4)**:本 spec 里凡可执行的技术细节(形参顺序、行号区间、needle 命中行)一律以**代码实体**为准 —— `applyApprovalInTx` 的形参序是 `ctx context.Context, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string`(`submissionID` 在 `plan` 之前);base 树 `fe810dd` 的阶段区间为 ①②③`48–86` / ④`88–101` / ⑥闭包体`126–200`(75 行)/ ⑥整体`125–201`(77 行)/ ⑥内 switch`136–199`;HEAD 上 CG2 needle 唯一命中 `:201`。首次 re-pin 时控制者凭记忆写了 4 处不一致(N1 形参序 3 处、N2/N3 区间 staleness、N4 行号与算术),全分支终审逐条实测抓出。**教训已入 §8 纪律 11:可执行细节必须从代码实体 grep 出来粘贴,不得手写。** - 搬 **126–200** 行(`WithTx` 闭包体)。⚠️ **`:201` 是 `})`,`:202–207` 的事务错误映射属阶段⑦,都不得搬进 helper**(照抄旧区间「126–205」会把 `})` 与闭包外半截搬进去 → 编译红或行为变更)。签名收 `(ctx, tx repository.ContentRepository, submissionID string, plan approvePlan, adminID, note string) error`(**含 `submissionID`,见 F9 裁定**)。 - **`GetByIDForUpdate` 锁行 + 重校 `Status == Pending` 必须在最前**(125–134),且 `errors.Is(err, repository.ErrNotFound)` → `ErrSubmissionConflict`(**注意与①不同**:① 映射 `ErrContentNotFound`,⑥ 映射 `ErrSubmissionConflict`——这个差异是刻意的,事务内消失意味着并发删除,语义是冲突而非未找到)。 - 三个 kind 分支各自拆包级函数(`createWorkInTx` / `createVersionInTx` / `applyMetadataChangeInTx`),或保留为一个 switch——**由实现者按可读性定**,但 `switch` 的分支顺序与 `MarkReviewed` 在末尾的位置**不得变**。 - `MetadataChange` 分支的 `Features` 三态**连注释一起搬**(D-F)。 - 事务内错误**裸返**,不加 `service: approve precheck` 之类包装(§1.2)。 **⑦ `cleanupApprovalUploads`**(未导出方法): - 搬 **208–212** 行(pending 循环)。⚠️ **`:213` 的 `slog.Info` 与 `:214` 的 `return nil` 留在 `Approve` 本体,不得搬走**(旧区间「212–213」会把该搬走的和该留下的正好搞反)。`for _, key := range pendingKeys(plan.bundleUpload, plan.coverUpload)` + `s.objects.Delete` + `if err != nil && !errors.Is(err, storage.ErrObjectNotFound) { slog.Error("approve: pending delete failed", "key", key, "error", err) }` **逐字保留**。 - **不得返回 error**(best-effort 语义:清理失败不能让已成功的审批变失败)。 - ⚠️ `slog.Info("approve", …)` 与 `return nil` **留在 `Approve` 本体**,不进 helper——helper 名是 `cleanup`,把成功日志塞进去会让职责不清。 ### 3.3 不得触碰 - `architecture_test.go`(P14 的七条守卫)——除非 mutation (g) 证明某条因新 helper 变红,那时**停下来报告**,不要自行改守卫。 - `moderationPort`(`handler/admin.go`)与 `serve.go`。 - `versionFromUpload` / `pendingKeys` 的**签名**(可调用,不可改)。 - 任何 `*_test.go`。 - `docs/`、`go.mod`/`go.sum`(AGENTS.md 红线:实现者不得碰)。 --- ## §4 边界 | 项 | 在范围内 | 不在范围内 | |---|---|---| | 函数 | `Approve` 及其拆出的 helper | `Reject`/`Unpublish`/`Republish`/`UpdateWorkFeatures`/`ListQueue`(同文件其它方法)、`deleteAccountLocked`(83)、`UpdateSubmission`(76) | | 文件 | `internal/service/moderation.go` | 其它 service 文件(除非编译需要,须报告) | | 行为 | 零变更(D-G) | 任何错误文案/日志键名/错误产生时机的调整 | | 测试 | 无(既有测试即守卫) | 新增测试、修改既有测试 | | 守卫 | 无 | 改 `architecture_test.go` | **挂账**(本批发现但刻意不做):`deleteAccountLocked`(83 行,account 线)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`CleanupService.Run`(70)、`checkSubmissionRules`(66) —— 六个 >60 行函数,各自独立成批。 --- ## §5 测试计划 ### T1 —— 结构守卫(**RED 先行**):扩展或新增函数长度断言 P14 的七条断言钉的是**类型形态**,没有一条钉**函数长度**。本批的验收核心是"`Approve` 从 169 行降到 <60",若没有机械守卫,下一个贡献者可以把它加回去而无人拦截。 **新增 `internal/service/function_length_test.go`**(**新文件,不改 `architecture_test.go`**): 1. **`TestNoOversizedFunction`**:解析 `internal/service/` 下所有非测试 `.go`,统计每个 `func` 的行数(`^func ` 起,括号深度归零止),断言**没有任何函数 >100 行**。 - **阈值 100 的依据(实测,非拍脑袋)**:§0 普查显示当前包内 >100 行的函数**只有 `Approve`(169) 一个**,次大是 83 → 阈值 100 在**修前恰好红一条**(`Approve`)、**修后全绿**,且给次大者(83)留 17 行余量不至于误伤。 - ⚠️ **不得用 60 当阈值**:那会让 `deleteAccountLocked`(83)、`UpdateSubmission`(76)、`ValidateWorkFields`(75)、`ImportDir`(70)、`Run`(70)、`checkSubmissionRules`(66) 六个**本批范围外**的既有函数立刻报红,把"重构 `Approve`"变成"重构整个包"(违反 D-F/E)。 2. **`TestApproveIsSmall`**:断言 `Approve` 本身 **<60 行**(本批的直接目标;169 → <60)。 3. **`TestApproveHelpersExist`**:断言 §3.1 骨架里的 helper 名存在(`loadApprovePlan`/`precheckApprove`/`copyApprovalObjects`/`applyApprovalInTx`/`cleanupApprovalUploads`)——防实现者只把代码挪进一个匿名闭包了事。 - ⚠️ **这条断言的实现方式必须是源码文本匹配**(`os.ReadDir` + 正则),**不能用 reflect**:未导出函数在 reflect 里不可见(§1.3 同一机制)。 **RED 相位要求**:T1 在 T2 之前提交时,`go test ./internal/service/ -run 'TestNoOversizedFunction|TestApproveIsSmall|TestApproveHelpersExist'` 必须**红**,且红的原因是**断言失败而非编译错误**(`Approve` 169 行 > 100、helper 名不存在)。**这与 P14 T1 的 RED 形态不同**(P14 是 `undefined: CatalogService` 编译红)——本批 T1 引用的都是既有类型,故必须是断言红。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15a/impl-evidence/t1-red.txt`。 **mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 非编译红 + 恢复证明")**: - (a) 把 `Approve` 的某阶段**内联回去**(helper 存在但 `Approve` 不调它,改为原地展开)→ `TestApproveIsSmall` 必须红 - (b) 给某个 helper 塞代码使 `Approve` 仍 <60 但**某函数 >100**(例如把⑥的三个 kind 分支全塞进 `applyApprovalInTx` 而不拆)→ `TestNoOversizedFunction` 必须红 > ⚠️ **(b) 的可行性须先实测**:若⑥整体(126–200 = **75 行**)搬进 `applyApprovalInTx` 而不再拆 → **不会红**;**更宽的实测盲区:仅合并④⑥(正是 §2.1 被否方案 A 的形态)= 91 行,仍不红**;真红下界实测 >100 行。这是**阈值 100 的已知盲区**,不是守卫失效:它钉的是"不再有 169 行的巨函数",不是"每个 helper 都短"。实现者须在报告里写明这一点,**不得声称 (b) 覆盖了所有塞代码形态**。若要在 (b) 上取得真红,须构造 >100 行的 helper(例如把 ④⑥ 两个 switch 都塞进一个 helper)。 - (c) 删掉一个 helper(把其内容合并进 `Approve`)→ `TestApproveHelpersExist` 必须红 - (d) 把 helper 名改成别的(如 `loadApproveInputs`)→ `TestApproveHelpersExist` 必须红(**这条是 (c) 的对照**:证明断言钉的是名字集合而非"存在任意 5 个函数") - (e) 把 `TestNoOversizedFunction` 的扫描范围改成一个空目录 → 必须红(**防"扫 0 文件也报 0 违规"的假信心**,P13 审查者 M5 的同族教训;断言内须含"至少扫到 N 个 `.go` 文件"的前提检查) - (f) 把阈值从 100 改成 1000 → 必须红(同 (e),防阈值被放宽;断言须自证阈值字面量) - (g) **给 `ModerationService` 加字段**(如 `clock func() time.Time`)→ P14 的断言 6 `TestLineFields` 必须红(**跨批回归检查**:证明本批新增文件没有削弱 P14 守卫) - (h) **在 `ContentService` 上加方法** → P14 的断言 5 必须红(同 (g),且**须同时测有名与匿名两种接收者形态**——P14 FR-1 的教训:只测有名会漏掉 `func (*ContentService) M()`) - ⚠️ **(RE-PIN 2026-10-03)spec 的 (a)–(h) 对审查者发现的缺口全部无牙**,fix-forward 轮须复跑审查者自己的轮次:**RV-A / RV-B / RV-C / RV-J**(四条合并形态,修后应由 CG 守卫红)· **RV-I / RV-S**(修后应由正向存在性前提红)· **RV-E**(修后应给出合理错误而非 `read memory.go: no such file`)· **RV-O2**(修后应由 F2 新断言红)· **RV-P / RV-Q**(修后应由自证钉红)· **RV-R**(修后**仍应全绿** = 固有上限,**须在报告里明写「未能防住,靠 review 兜」,不得声称已修**)· **RV-N**(泛型形态修后应命中)· **RV-F / RV-G / RV-H**(跨批回归,应仍与实现者报告一致) 每次 mutation 后**只回滚触及的文件**(`git checkout -- `,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚会抹掉未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件 `func Test` 计数不变。 ### T1.4 —— ④⑥ 独立性**常驻**守卫(⚠️ RE-PIN 2026-10-03,审查者 F10 裁定采纳) spec §7 风险表第 1 行原写「T3 字节级比对会暴露合并」——**但 T3 是一次性证据,报告归档后下一个贡献者合并 ④⑥ 时没有任何东西会红**。审查者实测确认这个状态不可接受(§1.2 把 ④⑥ 不合并列为「本批最重要的约束」,却零常驻机械信号)。 **新增 `internal/service/approve_phases_test.go`**(新文件;不动 `architecture_test.go`),三条子断言: - **CG1**:`fmt.Errorf("service: approve precheck: %w"` 只能出现在 `precheckApprove` 的 span 内 - **CG2**:`.Submissions().GetByIDForUpdate(` 只能出现在 `applyApprovalInTx` 的 span 内 > ⚠️ needle **必须收紧成 `.Submissions().GetByIDForUpdate(`**:`moderation.go` 内唯一命中 `:201`;而 `account.go:46`、`auth.go:122` 是 **`users.GetByIDForUpdate`**(不同接收者)。不收紧则将来 `moderation.go` 新增别的 `GetByIDForUpdate` 调用会误红。 - **CG3**:两个 helper 体内各自含 `switch …Kind`,且**互不调用** **关键设计**:语料**先过 `stripComments`(只掩注释、保留字符串字面量)**。`moderation.go:141` 与 `:191` 的注释里**合法地**提到 precheck 文案 → 不掩注释会假红;而字符串字面量必须保留,否则 `fmt.Errorf("...")` 这个 needle 根本匹配不到。span 复用 `function_length_test.go` 的 `scanFuncs`(共享同一分词器,也就共享 F2 的闭包盲区,但闭包形态与 ④⑥ 合并无关)。 **审查者已双向验证**:合法树 **绿**;**4 种合并/退化形态全红**且全部 `compile_red=False`(是断言红不是编译红),三条 shipped 守卫在这些形态下**全绿**: | 轮 | 形态 | shipped 三条 | CG 守卫 | |---|---|---|---| | RV-A | **活的** ④⑥ 语义合并:两个 helper 都委托给同一个 `checkKindConflicts(ctx, store repository.ContentRepository, plan)`(= §2.1 被否方案 **B** 的实现;比实现者 (b-iii) 的静态拼接更强——它真的会跑) | 全绿 | **红**(2 条) | | RV-B | 掏空 ④:`precheckApprove` → `return nil`(声明保留,名字守卫满足) | 全绿 | **红** | | RV-C | 把 ⑥ 的 `GetByIDForUpdate` 搬进 ④(摧毁 TOCTOU 分层,**标识符计数完全不变**) | 全绿 | **红**(CG2 越界) | | RV-J | 「最自然的 DRY」:④ 的两个 case 折进 ⑥ 的 switch、⑥ 持有 precheck 文案、④ 变空壳 | 全绿 | **红**(CG1 越界 + CG3) | > ⚠️ **RV-C 同时推翻了控制者的核实方法**:把 `GetByIDForUpdate` 从 ⑥ 搬进 ④,标识符计数不变、错误文案不变,而 TOCTOU 分层被摧毁(乐观预检变成持锁预检,失败时机与锁语义全变)。**「计数一致」证明不了控制流没变**——必须配 **顺序** 与 **位置** 证明(T3 的 order-violation 检查 + 审查者的 per-destination 单调性检查)。 **⚠️ 已知边界(须原样写进守卫注释;纪律 #4:不写全称声明)**:若合并者把 precheck 文案**留在** `precheckApprove` 内、同时把 ⑥ 的逻辑**复制**进去(复制而非移动),CG1 不红——此时需要靠「两处 `switch …Kind` 的 case 集合不同」这类更强断言。**该形态未验证,故不声称 CG1–CG3 覆盖所有合并形态。** ### T2 —— 执行拆分(T1 由红转绿) 按 §3 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**。 ### T3 —— 字节级方法体保真证明(加做项,P14 同款) 写脚本对 base 树(`fe810dd`)的 `Approve` 函数体与新树的 `Approve` + 5 个 helper 做**语句级比对**:base 的每一行(归一化缩进与接收者前缀后)必须在新树中出现,且**顺序保持**(阶段①→⑦ 的相对顺序不得变)。产出三张清单:`verbatim`(逐字一致)、`changed`(刻意变更,须逐条给理由)、`missing`(**必须为 0**)。 ⚠️ **脚本的已知坑(P14 两位审查者都栽过)**: - 提取声明块时**不能"剥行注释后数括号"**——字符串字面量里的 `//`(如 `strings.HasPrefix(k, "https://")`)会被当注释起点吃掉右括号。须用**状态机分词器**跟踪 `"` / `` ` `` / `'` / `//` / `/* */`,把字面量与注释内容替换为**等长空格**后再数括号(P14 任务级审查者 F6 的修法)。 - **单行 `var X = errors.New(...)` 须有提前终止分支**,否则会把后续声明并入同一块(P14 F6 的残留局限)。 - 若工具给出意外值(如某方法报 CHANGED),**先怀疑自己的模式**,不要据此指控实现者(P14 F6:第一版工具误报 `CoverURL` 为 CHANGED,若照它写报告会产生一条完全虚假的 critical finding)。 ### T4 —— 审查裁定后的守卫加固(fix-forward 轮,⚠️ RE-PIN 2026-10-03) 任务级审查(`.superpowers/sdd-p15a/task-review-report.md`,18 轮自设 mutation)裁决 **PASS with notes**,控制者裁定合并前修 6 项(`.superpowers/sdd-p15a/adjudication.md` §二): | # | finding | 改什么 | 审查者的实测证据 | |---|---|---|---| | **F1** 🔴 | `readNonTestSources` 的 `dir` 参数是**半成品**:`os.ReadDir(dir)` 但 `os.ReadFile(name)` → `dir` 只重定向「数哪些文件」,不重定向「读哪些内容」 | `os.ReadFile(filepath.Join(dir, name))` + import `path/filepath` | **RV-I**:base 树(`Approve` 169 行)+ `dir="zzscan"`(12 个同名文件、不含 `moderation.go`)→ 前提满足、`TestNoOversizedFunction` **PASS**,**而它唯一存在的理由(拦住 169 行 `Approve`)完全失效**。这恰好是守卫自己注释声称能防住的形态 | | **F2** 🔴 | `scanFuncs` 只对 `^func` 开 span → **`var f = func(...) {…}` 的函数体永不被测量** | 新断言:service 包内 `^var .* = func(` 形态的**包级闭包数量 = 0**(现状 0) | **RV-O2**:真实可编译、gofmt 干净的包内文件含 **125 行闭包** → 四门全绿、三条守卫全 PASS(`scanned 16 files / 86 funcs`——文件被数进去但一个函数都没测出来)。**比实现者自曝的 (b) 盲区更宽:那是「≤100 行的合并形态」,这是「任意长度的非 `func` 形态」** | | **F3** 🔴 | **四门的 gofmt 门是退出码盲的**:`gofmt -l .` 列出未格式化文件时**仍 exit 0**(只在解析失败时 exit 2) | 配方改 `test -z "$(gofmt -l .)"`,并同时存证原始输出为空 | 构造含缩进顶层声明的文件:`gofmt -l .` **列出** `main.go` 而 `gofmt_l_exit=0`。**控制者与实现者共用旧配方 → 同一盲点被复算两次而没被发现**(记为控制者错误 #32)。**与 F2 叠加后缩进逃逸路径完全敞开**:` func …` Go 编译器接受、`scanFuncs` 扫不到、三条守卫全绿,唯一防线就是这个不会红的 gofmt 门。**这是 P14「管道吞退出码」教训的镜像形态:命令本身不产生非零退出码,比管道吞码更隐蔽,因为 `$?` 看起来可信** | | **F4** 🔴 | 三个守卫常量里**两个无自证钉**,有钉的那个**只防单边改** | (a) **必修**:前提检查从「文件数 ≥ N」改成**正向存在性断言** `if _, ok := srcs["moderation.go"]; !ok { t.Fatalf(...) }`(不依赖魔法数字,同时治 RV-I 与 RV-S 的根);(b) 给 `approveLineBudget`/`minScannedFiles` 补自证钉;(c) 注释写明「同步改值 + 同步改钉」是**固有上限**、只能靠 review 兜 | **RV-P**(`approveLineBudget` 60→**600**)全绿、日志 `Approve = 24 lines (< 600)`,**静默失去全部牙齿**;**RV-Q**(`minScannedFiles` 12→**0**)全绿,前提变 `len(srcs) < 0` **恒假 → 永不触发**;**RV-R**(**同步**改阈值 100→1000 **且**同步改自证钉)全绿 → **自证钉防不住阈值放宽**;**RV-S**(RV-Q + `dir="zzempty"` 0 个 .go)→ **PASS**、日志 `scanned 0 files / 0 funcs`,**精确复现守卫注释声称能防住的「扫 0 文件也报 0 违规的假信心」** | | **F7** 🟡 | helper 正则要求名字后**紧跟 `(`** → **泛型声明形态** `HELPER[T any](` 不命中 | `HELPER\(` → `HELPER(?:\[[^\]]*\])?\(` | RV-N 实测三个 helper 均 `false`。**失败方向是红(过严)不是绿(绕过)**,故不是安全洞而是脆弱性;但与 F2 的缩进逃逸**同源**:都源于 `^func` / `HELPER\(` 这类**行首锚定的词法匹配** | | **F9** 🟢 | `applyApprovalInTx` 签名不含 `submissionID`,实现者用 `plan.sub.ID` 替代(附跨两个 repository 实现的同值论证) | **加 `submissionID` 形参**,⑥内两处(`GetByIDForUpdate`、`MarkReviewed`)恢复逐字用 `submissionID` | 审查者独立复核**同值成立**、并发安全验证通过;但本批唯一验收判据是「零行为变更」,而该替换让判据**依赖一条跨实现论证** → 加形参后这两处逐字一致、**彻底消除依赖**,且 T3 的 changed 从 **15 降到 13**(证据更强) | | **F6** 🟡 | 实现者报告 §6 的 changed 分类表**与它自己的证据对不上账**(stated `9+3+2+2 = 16`,证据标签合计 **15**) | 更正为 `多值返回 7 · 声明折叠 2 · 零值折叠 2 · 赋值形态 1 · =→:= 1 · 实参来源 2 = 15` | **实质无问题**(15 条逐条有理由、162 = 147+15+0 对账闭合、审查者独立 differ 精确复现归属),是**汇总表算术/誊写错误**非证据造假。但 USER.md 明写「报数字要报实跑输出」,且 P14 有过「控制者报出 83/299 与 82/298 两套数字」的先例 | **DEFER(另开批 + 挂账)**:**F5** —— P14 断言 5 有一条未封死的绕过:**类型别名接收者**(`type X = ContentService` + `func (c *X) CoverURL(...)`)。审查者 RV-M 硬证据:**遮蔽确实生效**(组合根返回 `RV-M-SHADOW`),而**用与 `architecture_test.go` 逐字相同的正则**实测命中数 = **0**;RV-K:全部 **10 条守卫 + vet + build 全绿**。与 P14 FR-1(匿名接收者)**同族**:FR-1 的修法把接收者名改成可选分组,但仍要求 `ContentService` 这个字面 token 出现在 `(` 之后,别名形态恰好把这个 token 换掉了。→ `architecture_test.go:173` 的「**唯一**能侦测遮蔽提升方法的机械手段」这个全称声明**在别名形态下被推翻**(纪律 #4)。**pre-existing(P14 遗留),不算本批扣分项**;且修它必须动 `architecture_test.go`(本批禁触)→ 另开批。最小修法:禁止包内出现指向 `ContentService` 的类型别名,一条正则 `^type\s+\w+\s*=\s*\*?ContentService\b`(合法树 0 命中、RV-K/RV-M 红);更强修法:改用 `go/ast` 判定接收者类型是否 resolve 到 `ContentService`(含别名),**一次性解决 F2/F5/F7 三条的词法根因**。 **偏离 1 的裁定(审查者 §10)**:`5fd12e5` 给 `readNonTestSources` 加 `dir` 参数——**动机成立**(spec §5 要求每条 mutation 测「预期红 + 其余绿」,共享扫描入口无法隔离)、**「RED 输出前后逐字同款」验证成立**(两份存证失败内容完全一致,只有行号整体 +3)、**但实现有 bug(F1)且换来的收益是虚的**(审查者:「为了演示而给生产代码加参数、且加出 bug,是净损失」)。审查者倾向回退;**控制者裁定保留参数化并按 F1+F4(a) 修**——理由:(e) 的隔离演示有独立价值(它是「预期红 + **其余绿**」这个硬要求的唯一实现路径),F1 是一行修,而 F4(a) 的正向存在性断言比回退更能治根。 ### 四门验收 ```bash cd crearte-server && 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 . ; test -z "$(gofmt -l .)" ; echo gofmt_gate=$? ; go vet ./... ; go build ./... ; go test ./... -count=1' ``` ⚠️ **`-count=1` 强制实跑**(禁缓存)。⚠️ **落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向**(容器只挂 `/src` 写不了 `.superpowers/`;exec session 一断容器就被杀——P14 实现者与任务级审查者都踩过)。⚠️ **`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。⚠️ **`pkill -f ` 会匹配到自己的命令行**(P14 控制者把自己 SIGKILL)→ 用 `[p]attern`。 ### 结构指标(交付时须报实测数字,`wc -l` 口径) | 指标 | 基线 | 目标 | |---|---|---| | `Approve` 行数 | **169** | **< 60** | | `service/` 包内 >100 行的函数数 | **1** | **0** | | `service/` 包内最大单函数行数 | **169** | **< 100**(实测次大者 83,故目标应落在 83–100 之间) | | `moderation.go` 行数 | 298 | **不作指标**(D-H:helper 同文件,可能不降反升;报了即可,不设目标) | | 既有 `*_test.go` 修改文件数 | — | **0** | | `architecture_test.go` 修改行数 | — | **0** | | `Approve` 的签名 | `(ctx context.Context, adminID, submissionID, note string) error` | **逐字不变** | | 字节级比对 `missing` | — | **0** | --- ## §6 记账 - `crearte-server/docs/CHANGELOG.md` 新增 **`## [0.19.0] - 2026-10-03`**(插 `## [0.18.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语(`### Changed / 变更`、`### Tests / 测试`、`### Context / 背景`)。 - wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。 - `docs/ROADMAP.md`:第三波表新增 **P15-A 行**(P14 行之后)+ 文档索引表新增一行。**哈希引 merge commit**(P12/P13/P14 惯例),记账 commit 另列。 - **挂账新增**:① 六个 >60 行函数(`deleteAccountLocked` 83 / `UpdateSubmission` 76 / `ValidateWorkFields` 75 / `ImportDir` 70 / `CleanupService.Run` 70 / `checkSubmissionRules` 66)② **阈值 100 的盲区扩写为「≤100 行的任何合并形态」**(实测:⑥整体搬进 `applyApprovalInTx` = **77 行**不红;**仅合并④⑥ = 91 行仍不红**;真红下界 >100 行)③ **`TestApproveIsSmall` 的镜像盲区**:须内联四个阶段才把 `Approve` 推到 79 ≥ 60;单内联⑦(28 行)时三条守卫全绿(由 (c)/(d) 的名字集合断言兜)→ 守卫钉的是「编排体小 + 五个名字存在 + 无巨函数」,**不钉「每个阶段必须经由 helper」** ④ **非 `func` 形态的任意长度逃逸**(F2:125 行包级闭包四门全绿)⑤ **三个字面量常量的自证缺口**(F4:RV-S 复现「0 文件 0 违规 PASS」)⑥ **F3:四门配方的 gofmt 门退出码盲**(影响**所有**批次,不只 P15-A)⑦ **F5:P14 断言 5 的类型别名接收者绕过**(pre-existing) - **`go.mod` / 任何 version 文件不 bump**。 - **纪律**:显式 `git add `,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` 三个未跟踪文件**非本批产物,绝不 stage**。 --- ## §7 风险与缓解 | 风险 | 缓解 | |---|---| | **实现者"顺手 DRY"合并④⑥的 switch** | §1.2 已写明四处实质差异 + 语义差别(乐观/悲观)+ 错误文案差别;D-D 明令禁止;T3 字节级比对会暴露合并(⚠️ **但 T3 是一次性证据,报告归档后下一个贡献者合并 ④⑥ 时没有任何东西会红** → 由 `approve_phases_test.go` 的 **CG1–CG3 常驻断言**拦截,见 §5-T1.4)(base 的 `service: approve precheck: %w` 文案会消失或移位);既有 `TestApproveSameWorkIDConcurrent` 断言的错误集合会变 | | **`plan` 值传递导致 ⑤ 的 `finalXxxKey` 静默丢失** | §3.1 已点名:`copyApprovalObjects` 必须收 `*approvePlan`。`TestApproveOptionalFieldsRoundTrip` 与 api 层发布链测试会抓到(key 为空 → 作品无 bundle) | | **② 的 `owner` 零值被误当成"未加载"** | §3.2 已写明 `if sub.Kind == NewWork` 条件必须保留;`namespace_test.go:132 TestNewVersionMetadataChangeOwnership` 会抓到 OwnerID 错误 | | **③ 的 `bundleUpload = &up` 取地址语义被改成存值** | §3.2 已点名;改了会让 `plan.bundleUpload != nil` 恒真或恒假 → ⑤⑥⑦ 全链路错,api 层发布链测试必红 | | **⑦ 的 `slog.Info` 被搬进 cleanup helper** | §3.2 已明令留在 `Approve` 本体(职责清晰)。日志键名/顺序变更由 D-G 约束;若实现者搬了,代码审查阶段抓 | | **新守卫 `TestNoOversizedFunction` 写成恒真/恒假** | mutation (e)(f) 双向验证(改扫描范围→红、改阈值→红)+ P13/P14 教训:`max(a,b) ≥ k` 是恒真高发区、`NumMethod()==0` 是恒假形态。**本批新增镜像形态须防:断言里若含"至少扫到 N 个文件"的前提检查,N 写太小会让前提恒成立而主体断言失去意义** | | **未导出 helper 让 reflect 类断言失效** | §1.3 已实测:未导出方法不在断言 2/4 视野内 → 这是**特性不是缺陷**(重构私有实现不该触守卫)。但 T1.3 的 `TestApproveHelpersExist` 因此**必须用源码文本匹配而非 reflect** | | **本批削弱 P14 守卫** | mutation (g)(h) 跨批回归检查(加字段→断言 6 红;加组合根方法→断言 5 红,**且须测有名与匿名两种接收者形态**,P14 FR-1 教训) | --- ## §8 实现纪律(P11–P14 累计教训,逐条来自真实事故) 1. **不推断,只实测。** 任何"应该是/大概/按惯例"都要跑一条命令确认。P14 控制者在勘查期与裁定期共犯 28 次断言/grep/锚点/区间/路径/正则错误。 2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者五次因这条纪律避免了指控子代理的假缺陷。 3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = 4.0621 > 3,50653 采样暴力验证)。 4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过);写"两层覆盖完整"被推翻(孤儿私有方法)。**写"唯一/全部/任何"之前先枚举形态**(有名/匿名接收者、值/指针、导出/未导出)。 5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 控制者 spike 否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)就是恒假形态。 6. **管道会吞掉真退出码。** `go test ./... | tail -30; echo $?` 取的是 `tail` 的退出码。P14 终审者踩过(`full_exit=0` 而实际套件 FAIL)。 7. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"。** P14 终审者 R0 轮整轮结论无证据支撑,自查后在真仓用绝对路径重跑;控制者的 `bg-surface=0` 同形态(`cd` 到仓根却写 `app/...`,真根是 `src/app/...`)。**任何"零命中"结论都要先证明扫描范围非空。** 8. **长时命令落盘证据用 `setsid nohup` 脱离 + 宿主侧重定向。** exec session 一断容器就被杀,P14 实现者的 `t3-gates.txt` 首跑只写 1/12 包。 9. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在(`func Test` 计数),否则 FATAL 终止——让脚本在被自毁时大声失败,而不是静默产出"未按预期"的假结论。 10. **测试被迫修改 = 设计失败的信号。** 若必须改既有测试,**停下来报告**而不是改。本批的验收硬指标就是"既有 `*_test.go` 修改数 = 0"。