Registers the next batch before implementation starts, so the design is versioned and reviewable rather than living only on disk. Scope: crearte-server only. content.go is 856 lines / 30 functions, the sole outlier in internal/service (next largest production file auth.go is 234 lines; content.go alone is 22% of the directory) and carries five unrelated responsibility lines in one struct whose five fields are all shared repository dependencies with no mutable internal state. Three survey findings make the split low-risk rather than aspirational: - the seven cross-line calls all target package-level helper functions, with zero method-to-method cross-line calls, so splitting creates no cross-service callbacks and no import cycle; - each of the five handlers uses exactly one line's methods with zero overlap, so each dependency face narrows from 22 methods to its own 2-6 with no adapter; - 11 of the 19 test construction sites only construct and never call methods, and a Go embedding spike (H1-H4, run in golang:1.24-alpine, vet clean) confirmed the promoted method set satisfies consumer-defined narrow interfaces — so the composite root keeps every construction site and all five serve.go wirings unchanged while handlers still narrow. The survey also found ContentService.now is a dead field: zero s.now references in the file, no test seam injecting it, while the sibling services that share the pattern do use theirs (auth.go reads s.now(), cleanup.go has SetNow). It is removed as part of the split rather than being assigned a line. Two risks were falsified by measurement before designing: no code inside the package reads ContentService's private fields (AccountService holds repository.ContentStore, not *ContentService, so it is untouched), and the type is never interface-ised, type-asserted, or used as a method value. Deliberately out of scope: the 170-line Approve function (its seven phases are mapped and logged for a later batch — one concern per batch), memory_content.go (814 lines but a test double with zero non-test references), and handler error-mapping dedup (92 WriteError sites but only 2 errors.Is checks, so the duplication does not justify itself). Verification plan: four gates in the dockerized toolchain plus structural metrics (content.go under 120 lines, largest of the six files under 300, zero service.ContentService references left in handlers, zero existing test files modified) and three mutations the new architecture guard must fail on.
358 lines
30 KiB
Markdown
358 lines
30 KiB
Markdown
# 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 归属映射(含我对两处误归属的纠正记录) |
|
||
| **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 输出本身即「有牙」证明 |
|
||
| **D-K** | 派发形态 | **一个实现者子代理**做完 T1→T3。所有任务同动 `crearte-server` 单一工作树与 git index | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13 同款 |
|
||
|
||
### §2.1 被否方案
|
||
|
||
| 方案 | 否决理由 |
|
||
|---|---|
|
||
| **A:只拆文件、不拆类型**(`ContentService` 保持一个大 struct,方法分散到 6 个文件) | 消除不了 god object:每个 handler 仍声明对 22 方法的依赖,`catalog.go` 仍需 `nil` 占位。只解决「文件太长」这一个症状,不解决架构问题 |
|
||
| **B:删除 `ContentService`,五个 handler 各收子 service** | 真消除 god object,但 **19 个测试构造点 + 2 个生产构造点全部要改**,diff 与审查面显著变大,而 Route C 用嵌入拿到同样的架构收益且构造点零改动(spike H2/H4 实测)。B 可作为 Route C 之后的**渐进收尾**(若将来组合根不再被任何调用方需要) |
|
||
| **C:把五条线拆成五个独立包** | 过度。它们共享 `ContentStore` 与 11 个共享错误/类型,跨包会迫使这些声明全部导出并制造包间依赖;仓内惯例是 service 包内多文件多 struct(`account.go`/`bundle.go`/`auth.go`/`cleanup.go` 全在 `internal/service`) |
|
||
| **D:本批一并重构 `Approve` 170 行** | 见 D-F。两个关注点混在一批会让「行为不变」的验收判断变难 |
|
||
|
||
---
|
||
|
||
## §3 实现要求(逐条可核)
|
||
|
||
### 3.1 `internal/service/content.go`(拆分后 = 组合根 + 共享声明)
|
||
|
||
保留:`ErrContentNotFound`;`ContentService` struct(改为 5 个嵌入指针);`NewContentService`(4 参数签名不变,内部改为组装五个子 service,**不再赋 `now`**);11 个共享声明(`ErrSubmissionConflict`、`ErrWorkIDTaken`、`ErrVersionExists`、`ErrUploadUnavailable`、`ErrForbidden`、`submissionKinds`、`SubmissionEnvelope`、`ParseSubmissionEnvelope`、`SubmissionInput`、`SubmissionUpdate`,以及 `ErrUnsupportedMediaType` 迁走后其余保持原位)。
|
||
|
||
```go
|
||
// 组合根:五个职责线的嵌入组合。自身不声明任何方法——全部由嵌入提升。
|
||
// 保留它的唯一理由是让 19 个测试构造点与 serve.go 的装配零改动(spec §1.5 H2);
|
||
// 若将来无调用方需要「全量服务」,可整块删除(方案 B)。
|
||
type ContentService struct {
|
||
*CatalogService
|
||
*ReactionService
|
||
*UploadService
|
||
*SubmissionService
|
||
*ModerationService
|
||
}
|
||
|
||
func NewContentService(store repository.ContentStore, users repository.UserRepository, objects storage.ObjectStorage, bundle *BundleService) *ContentService {
|
||
return &ContentService{
|
||
CatalogService: NewCatalogService(store, objects),
|
||
ReactionService: NewReactionService(store),
|
||
UploadService: NewUploadService(store, users, objects, bundle),
|
||
SubmissionService: NewSubmissionService(store, objects),
|
||
ModerationService: NewModerationService(store, users, objects),
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **`now` 字段必须删除**(D-H)。删除后若 `time` 包在 content.go 内不再被使用,**必须同步删掉 import**(否则 `go build` 报 imported and not used)。实测 `content.go` 内 `time` 仅出现在 `now func() time.Time` 与 `now: time.Now` 两处 —— 实现者须自行 `grep -n 'time\.' internal/service/content.go` 确认后处置,**不要凭本句推断**。
|
||
|
||
### 3.2 五个子 service 文件
|
||
|
||
每个文件的形态(以 `reaction.go` 为例,最小依赖线):
|
||
|
||
```go
|
||
package service
|
||
|
||
// ReactionService = 反应职责线(收藏 / 评分)。只依赖 store(spec §1.4 实测)。
|
||
type ReactionService struct {
|
||
store repository.ContentStore
|
||
}
|
||
|
||
func NewReactionService(store repository.ContentStore) *ReactionService {
|
||
return &ReactionService{store: store}
|
||
}
|
||
|
||
// reactionTarget 是私有 helper,仅本线内使用(spec §1.1:3 处调用全在反应线)
|
||
func (s *ReactionService) reactionTarget(...) error { ... }
|
||
|
||
func (s *ReactionService) SetFavorite(...) (...) { ... }
|
||
// RateWork / UnrateWork / UserReaction / MyReactions
|
||
```
|
||
|
||
**机械迁移规则**(逐条可核):
|
||
|
||
1. 方法接收者 `s *ContentService` → `s *<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 侧**(不得简化参数或返回值):
|
||
|
||
```go
|
||
// games.go
|
||
// catalogPort 是本 handler 实际消费的最小面(spec §1.2:4 个方法)。
|
||
// 定义在消费方(Go 惯例;仓内先例 cmd/catalog.go:79 catalogRenderer)。
|
||
type catalogPort interface {
|
||
ListPublished(ctx context.Context, ...) ([]model.WorkWithVersion, map[string]model.WorkReaction, string, error)
|
||
GetPublishedDetail(ctx context.Context, ...) (model.WorkWithVersion, model.WorkReaction, string, error)
|
||
CoverURL(coverKey string) string
|
||
BundleURL(v model.WorkVersion) string
|
||
}
|
||
|
||
type Games struct {
|
||
svc catalogPort
|
||
}
|
||
|
||
func NewGames(svc catalogPort) *Games {
|
||
return &Games{svc: svc}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **`ListPublished` / `GetPublishedDetail` 的完整签名必须从 `content.go` 逐字复制**(含中间那个未命名 `string` 返回值 = ETag)。不要凭 spec 里的省略号推断。实现者须先 `grep -n 'func (s \*ContentService) ListPublished' -A2 internal/service/content.go` 取原文。
|
||
|
||
其余四个同理:`reactionPort`(5 方法)、`uploadPort`(2 方法)、`submissionPort`(5 方法)、`moderationPort`(6 方法)。`admin.go` 的 `bundle *service.BundleService` 参数**保持不变**(它不是 ContentService 的一部分)。`uploads.go` 的 `maxBundle, maxCover int64` 同样不变。
|
||
|
||
### 3.4 `cmd/serve.go` 与 `cmd/catalog.go`(D-I)
|
||
|
||
- `serve.go`:五处 `handler.NewXxx(contentSvc, ...)` **零改动**(spike H2:提升后的组合根满足各窄接口)。
|
||
- `catalog.go`:把
|
||
```go
|
||
contentSvc := service.NewContentService(
|
||
repository.NewPostgresContentStore(pool), repository.NewPostgresUserStore(pool), objects, nil)
|
||
count, err := exportCatalog(cmd.Context(), handler.NewGames(contentSvc), out, pretty)
|
||
```
|
||
改为直接构造 catalog 线(**删除 `nil` bundle 占位与 `NewPostgresUserStore(pool)`**):
|
||
```go
|
||
catalogSvc := service.NewCatalogService(repository.NewPostgresContentStore(pool), objects)
|
||
count, err := exportCatalog(cmd.Context(), handler.NewGames(catalogSvc), out, pretty)
|
||
```
|
||
并同步更新那条「bundle 不参与导出」的注释——**依赖面已从 4 槽降到 2 槽,`nil` 占位不再存在**。若 `repository.NewPostgresUserStore` 在该文件内因此不再被使用,须检查 import / 变量是否残留。
|
||
|
||
---
|
||
|
||
## §4 边界(本批**不做**)
|
||
|
||
- **不改任何行为**:不动校验规则、错误语义、状态机、事务边界、slog 字段、HTTP 状态码映射。
|
||
- **不重构 `Approve` 内部**(D-F,登记挂账)。
|
||
- **不动 `AccountService` / `AuthService` / `BundleService` / `CleanupService`**(它们不持有 `*ContentService`,§1.6)。
|
||
- **不动 `memory_content.go`**(814 行,但实测是**测试替身**:除自身外零个非测试文件引用 `NewMemoryContentStore`,只 6 个 `_test.go` 用;属测试基建规模问题,非服务架构问题)。
|
||
- **不做 handler 错误映射去重**(`WriteError` 92 处,但 `errors.Is(err, service.ErrForbidden)` 仅 2 处 → 重复度不足以证明收益,候选 C,低于本批)。
|
||
- **不引入新依赖、不改 `go.mod`**。
|
||
- **不改任何测试的断言语义**(D-G)。若某个测试因构造函数签名变化而无法编译,**这本身说明拆分做错了**(D-A 的设计目标就是构造点零改动)。
|
||
|
||
---
|
||
|
||
## §5 测试计划
|
||
|
||
### T1 —— 架构守卫(**RED 先行**):`internal/service/architecture_test.go`(新增)
|
||
|
||
用 reflect 钉住拆分结构(D-J)。三类断言:
|
||
|
||
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)。
|
||
|
||
**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 必须红。
|
||
每次 mutation 后 `git checkout -- <file>` 恢复并核 `git diff` 空。
|
||
|
||
### T2 —— 提取五个子 service(T1 由红转绿)
|
||
|
||
按 §3.1–§3.2 执行。**完成后 `go test ./...` 必须 11 包全绿且既有测试文件零修改**(`git diff --stat` 里不应出现任何 `*_test.go`,除 T1 新增的那个)。
|
||
|
||
### T3 —— 收窄五个 handler + 消除 catalog.go 的 nil 占位
|
||
|
||
按 §3.3–§3.4 执行。**验收硬指标**:
|
||
- `grep -rn 'service.ContentService' internal/handler/` → **零命中**(五个 handler 全部改用窄接口);
|
||
- `cmd/catalog.go` 内 **`nil` 占位消失**、`NewPostgresUserStore` 不再被该构造使用;
|
||
- `cmd/serve.go` **零改动**(spike H2 的可核推论;若被迫改动,说明窄接口方法集抄漏了);
|
||
- 既有 handler 测试(`admin_test.go`/`games_test.go`/`reactions_test.go`/`uploads_test.go`/`integration_test.go` 等)**断言零修改**。
|
||
|
||
### 四门验收(dockerized Go,AGENTS.md 红线)
|
||
|
||
```bash
|
||
docker run --rm -v "$PWD/src:/src" -w /src \
|
||
-v crearte_gomod:/go/pkg/mod -e GOCACHE=/gocache -v crearte_gocache:/gocache \
|
||
-e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
|
||
golang:1.24-alpine sh -c 'gofmt -l . && go vet ./... && go build ./... && go test ./...'
|
||
```
|
||
|
||
基线(P14 起点,已实测):`gofmt -l .` 空 · `go vet` 净 · `go build` exit 0 · `go test ./...` **11 包 ok**(api 包最慢 ~11.5s,含真库集成)。**host Go 1.18 不可用**;`proxy.golang.org` 从本机不可达,缺 `-e GOPROXY=https://goproxy.cn,direct` 会让下载挂死并杀死代理。冷构建 1–3 min → 用 background + 耐心 poll。
|
||
|
||
### 结构指标(交付时须报实测数字)
|
||
|
||
| 指标 | 基线 | 目标 |
|
||
|---|---|---|
|
||
| `content.go` 行数 | 856 | **< 120**(组合根 + 共享声明) |
|
||
| 六个文件最大行数 | 856 | **< 300**(submission.go 预计 ~290) |
|
||
| `service/` 目录内 >400 行的生产文件 | 1(content.go) | **0** |
|
||
| handler 里 `service.ContentService` 引用 | 10 处 | **0** |
|
||
| `cmd/catalog.go` 的 `nil` 占位 | 1 | **0** |
|
||
| `ContentService` 自身声明的方法数 | 25 | **0**(全部提升) |
|
||
| 既有 `*_test.go` 修改文件数 | — | **0** |
|
||
|
||
---
|
||
|
||
## §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/b/c)必须附红/绿输出。P13 教训:`max(a,b) ≥ k` 形态恒真、positive control 只钉一个子树 → **守卫自身要受同等审视** |
|
||
|
||
---
|
||
|
||
## §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)。
|