Files
crearte-monorepo/docs/specs/2026-10-03-p15a-approve-phases-design.md
T
XingfenD 02188eb3e0 docs: re-pin both P15 specs after task-level review adjudication
Twenty-four pinned corrections across the two specs and their plans, every one
traced to a measured finding. Both task-level reviewers overturned claims I had
written into the specs, and I reproduced each against the base tree before
accepting it (git show, never the working tree, which already carries the fix).

P15-A spec gains two sections. T1.4 records the reviewer's candidate guard for
phase-independence: the spec called "do not merge phases four and six" the most
important constraint in the batch, yet its only stated mitigation was the
byte-level comparison, which is one-shot evidence — after the report is filed,
nothing reddens when someone merges them. The reviewer built CG1/CG2/CG3 and
verified them both ways: green on the legal tree, red on four distinct merge and
degeneration forms, all assertion-red rather than compile-red, while the three
shipped guards stayed green throughout. T4 records the adjudication of nine
findings, including two that undercut the guard the batch itself added: a
half-finished dir parameter that redirects which files are counted but not which
are read, so a scan pointed elsewhere can satisfy its own precondition while the
169-line function it exists to catch stays invisible; and a span scanner keyed to
line-initial func, which means a 125-line package-level closure passes all four
gates and all three guards. Also pinned: the gofmt gate in the four-gate recipe.
gofmt -l lists unformatted files and still exits zero, so the recipe everyone ran
reported gofmt_exit=0 as evidence of formatting cleanliness. Implementer and I
shared that recipe, which is why the same blind spot was computed twice and
caught by neither.

Phase intervals are corrected to the real base-tree line numbers. The prior text
told the implementer to move lines 212-213 into a helper; 213 is the slog.Info
the same spec requires to stay in the caller, so following it would have swapped
what moves with what stays. Phases six and seven also overlapped, each claiming
the transaction error mapping. The skeleton now passes a pointer where its own
note eighteen lines later demanded a pointer receiver, and the transaction
helper's signature carries submissionID, which removes the batch's dependence on
a cross-repository equality argument for the only acceptance criterion it has.

P15-B spec corrects the artifact criterion I had backwards. I wrote that deleting
a token should change the var(--color-*) count; that count measures usage, the
token had zero usage across seven utility suffixes, and it stayed at 75. Had it
moved, that would have meant something referenced the token, contradicting the
dead-token premise. Definition-side and usage-side are separate measures and the
spec now says so.

Leg five is re-pinned as critical. It was the sole enforcement point for the
non-reactive injection decision, and it only pinned literal form: two
type-legal variants that establish the dependency elsewhere in the computed body
pass all ten legs with vue-tsc clean, and the reviewer proved by effectScope
evaluation counting that both really do make the iframe src flip on a theme
change, reloading a running game. The narrowing was introduced by the
implementer's own fix, to avoid a trap comment that spells out the forbidden
literal; but comment masking already neutralizes that comment, so the narrowing
was unnecessary and the second line of defense created the gap. Same shape as
the P14 final review finding a hole in the first round's fix.

Also pinned: the rejected deviation five, with the corrected root cause (all four
freeze attempts used page.route, which does not intercept service worker
registration, worker-issued requests, or navigations synthesized by respondWith;
context.route holds the loading screen past 25 seconds); the missing
dark-system-times-light-hash grid that one mutation slipped past all ten source
legs and all five e2e legs simultaneously; the fourth tautology class the spec's
three warnings omitted, where emptying a loop's driving collection makes it
vacuously true; legs seven and eight, which respectively substring-searched a
hex that occurs three times in the file and matched only double-quoted style
attributes; the copyable code block's comment, which now avoids the three
literals that would false-redden a correct implementation, with the annotation
moved outside the block; and three new discipline entries covering untested
method scope, measurement units, and retaining one-off verification scripts.

Every correction was checked by a script asserting both directions — the pinned
text present and the superseded text gone — plus table column integrity, fence
pairing, and code-block cleanliness. Three earlier attempts at this re-pin failed
on my own errors and were fixed before commit; the working tree was never left in
a half-edited state.
2026-10-03 19:48:48 +08:00

399 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。
**目标函数**:`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 共用。**实测比对后确认不可合并**,四处实质差异:
| 差异 | 阶段④(87–100) | 阶段⑥(136–186) |
|---|---|---|
| **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, plan approvePlan, submissionID, 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, plan, submissionID, 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–85 行。返回 `(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`**(未导出方法):
- 逐字搬 87–100 行。**保持用 `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`**(包级函数):
- 搬 **126–200** 行(`WithTx` 闭包体)。⚠️ **`:201` 是 `})`,`:202–207` 的事务错误映射属阶段⑦,都不得搬进 helper**(照抄旧区间「126–205」会把 `})` 与闭包外半截搬进去 → 编译红或行为变更)。签名收 `(ctx, tx repository.ContentRepository, plan approvePlan, submissionID, 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 = **77 行**)搬进 `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 -- <file>`,**绝不用 `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` 内唯一命中 `:196`;而 `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 <pattern>` 会匹配到自己的命令行**(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 <file>`,**禁 `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"。