# 配置与部署 (批次4) Implementation Plan REQUIRED SUB-SKILL: superpowers:writing-plans (实施阶段使用 superpowers:executing-plans;code-review 阶段使用 superpowers:requesting-code-review) > 配套审计:`docs/backend-audit-2026-07-21.md` §二 P0-6/P0-7、§三 P1 配置、§四 P2 > 配套修复方案:`docs/specs/2026-07-21-backend-remediation-plan.md` 批次 4(4.1 / 4.2 / 4.3 / 4.4 / 4.5 / 4.9) > 基线 commit:`b7f8f1b724e5`,分支 `feat/uni` --- ## Goal 消除 backend 配置/部署层的全部 P0 风险(端口不一致 + starbookService 孤儿)与剩余 P1/P2 卫生项(健康探针、大二进制入库、序列脚本、`.env` 漂移、若干小修),使本地默认值与生产 env 覆盖之间形成**自校验**的部署契约,并通过一次回归把所有"硬编码"参数集中到 4 处可验证的位置(gateway config / 各 main flag / helm values / docker compose)。 完成后达成: - `go test ./... -run TestPortAlignment` 通过(4.1)。 - `git ls-files` 不含 `backend/{assetService,gateway-fixed,test,cleanup-orphan-avatars}` 四大二进制;`backend/services/starbookService/` 被 git 删除(4.2 + 4.3)。 - k8s `notificationservice` / `moderationservice` 探针路径与实际 handler 一致;本地 `curl :21010/health` 返回 `{"status":"ok"}`(4.3)。 - `scripts/create_gallery_test_users.go` 输出 SQL 末尾含 `setval(...)`;跑完不报 `duplicate key`(4.4)。 - `backend/.env.example` 与 `backend/.env` 键集合一致(含 `WS_AI_CHAT_PATH` 等),`.example` 不含真实密钥(4.5)。 - §四 P2 八项全部清理或明确 defer(4.9)。 ## Architecture 本批次不引入新架构,纯**配置归一**与**卫生清理**: ``` ┌──────────────────────────────────────┐ │ k8s/helm/topfans/values.yaml (端口) │ ← 单一事实来源 └──────────────┬───────────────────────┘ │ helm install ┌────────────────────┼────────────────────────┐ ▼ ▼ ▼ services/*/main.go docker-compose.prod.yml gateway/config/config.go (flag 默认值 = helm) (DUBBO_*_SERVICE_URL) (DUBBO_*_SERVICE_URL 默认) │ │ │ ▼ ▼ ▼ ┌─────────────┐ ┌────────────┐ ┌─────────────┐ │配置断言测试 │ ──────▶│ 4.1 校验 │ ◀──────│ 4.1 校验 │ └─────────────┘ └────────────┘ └─────────────┘ ``` 依赖关系(必须按顺序): 1. **4.2 starbookService 删除**先做(它依赖 4.3 探针清理 + P0-4 跨服务 import 解耦)—— 完成前 `go build ./...` 会因 `starbookService` 不在 `go.work` 而失败。 2. **4.1 端口对齐**与 **4.2 starbook 删除**独立可并行。 3. **4.3 探针 + 大二进制**与 4.4 / 4.5 可独立并行。 4. **4.9 P2 清理**作为伴随项随 4.1–4.5 顺手做;只有 "JWT 失效" 和 "PII 日志" 单独任务化。 ## Tech Stack - **语言**:Go 1.25.5(`go.work`) - **测试**:Go testing + testify - **K8s 配置**:Helm 3 + values.yaml - **Docker**:docker-compose v2 + Dockerfile.services - **数据库**(仅 4.4 序列脚本):PostgreSQL 16 - **静态检查**(后续可加,本批次不引入):golangci-lint --- ## Global Constraints - **不动架构**:本批次不拆库、不拆 `pkg/models`、不引入新 interface;纯"配置正确化"。 - **序列规范**:任何手动指定 id 的 INSERT,SQL 末尾必须 `SELECT setval('_id_seq', (SELECT MAX(id) FROM
));`,遵循 `CLAUDE.md` 强制规则。 - **commit 守则**:每个 Task 完成后**不自动 commit**,等用户说"帮我 commit"再提交;commit message 用 `Co-Authored-By: Claude `。 - **真实命令**:所有 bash 命令需在本地真实可执行(`backend/` 目录、`go.work` 模块、`k8s/helm/topfans/` Helm chart 都在仓库内)。 - **回归检查**:每个 Task 完成后跑 `go build ./...`(含 `go.work` 全部模块)+ 端口断言测试。 - **不动产物**:`unpackage/dist/*`、`backend/bin/*` 是构建产物,禁止手动改。 - **依赖前置**:4.2 starbookService 删除依赖 P0-4 跨服务 import 解耦审计结论(已确认);若 P0-4 实施批次尚未执行 starbook 解耦,本批次单独完成"assetService 内化 `AssetRegistryRepository` 实现"。 - **MVP 先行**:不要为"未来 12+ 服务"提前做端口配置中心化;本次仅做"四层端口默认值收敛 + 断言测试"。 --- ## File Structure **修改清单(按 Task 顺序)**: | Task | 关键文件 | 类型 | |------|----------|------| | 4.1 | `backend/gateway/config/config.go`、`backend/services/galleryService/main.go`、`backend/services/userService/main.go`、`backend/services/activityService/main.go`、`backend/services/taskService/main.go`、`backend/services/statisticService/main.go`、`backend/services/notificationService/main.go`、`backend/services/moderationService/main.go`、`backend/services/aiChatService/main.go`、`backend/services/socialService/main.go`、`backend/services/assetService/main.go`、`backend/docker-compose.local.yml`、`backend/docker-compose.prod.yml`、`docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`k8s/helm/topfans/values.yaml`、`backend/services/gateway/config/port_test.go` (新增) | 修 | | 4.2 | `backend/services/starbookService/` (整目录)、`backend/services/assetService/main.go`、`backend/services/assetService/service/mint_service.go`、`backend/services/assetService/repository/asset_registry_repository.go` (新增)、`backend/services/assetService/service/asset_service.go` (registryRepo 引用)、`backend/gateway/config/config.go`、`backend/go.work`、`backend/dev.sh`、`docker/Dockerfile.services`、`docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`k8s/helm/topfans/values.yaml`、`k8s/helm/topfans/templates/starbookservice/deployment.yaml` (删) | 删 + 修 | | 4.3 | `backend/pkg/health/health.go`、`backend/services/assetService/main.go`、`backend/services/galleryService/main.go`、`backend/services/userService/main.go`、`backend/services/notificationService/main.go`、`backend/services/moderationService/main.go` (注册 `/healthz` alias)、`k8s/helm/topfans/values.yaml`、`k8s/helm/topfans/templates/notificationservice/deployment.yaml`、`k8s/helm/topfans/templates/moderationservice/deployment.yaml`、`docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`backend/.gitignore`、`backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars` (git rm --cached) | 修 + rm | | 4.4 | `backend/scripts/create_gallery_test_users.go` | 修 | | 4.5 | `backend/.env.example`、`backend/.env`、`backend/services/gateway/config/env_audit_test.go` (新增) | 修 + 新增 | | 4.9 | `backend/gateway/controller/asset_controller.go`、`backend/gateway/controller/auth_controller.go`、`backend/gateway/controller/user_controller.go`、`backend/services/userService/service/user_service.go`、`backend/services/aiChatService/provider/ai_chat_provider.go`、`backend/services/mint_service.go`、`backend/services/activityService/service/activity_service.go`、`backend/pkg/peripheral/sign.go`、`backend/pkg/mq/streams/adapter.go`、`backend/pkg/mq/asynq/adapter.go`、`backend/services/galleryService/mq/consumer.go` | 修 | | 自审 | `docs/superpowers/plans/2026-07-21-config-deploy.md` (本文) | — | **新增测试文件**: - `backend/gateway/config/port_test.go`(4.1 端口断言测试) - `backend/gateway/config/env_audit_test.go`(4.5 env 键集合对比) --- ### Task 1: 端口对齐 + 配置断言测试(4.1) > 对应审计 P0-6 与修复方案 4.1。 **Files**: - `backend/gateway/config/config.go:162-165` —— 修正 `Dubbo.GalleryServiceURL`/`ActivityServiceURL`/`StarbookServiceURL` 默认端口 - `backend/services/galleryService/main.go:36,44` —— `port` 默认 20001;`taskServiceURL` 默认改 `tri://localhost:20006` - `backend/services/userService/main.go:283` —— 删除硬编码 `protocol.WithPort(20000)`,改用 `*port` - `backend/services/assetService/main.go:38` —— 确认 20003 默认 - `backend/services/activityService/main.go` —— 默认端口确认 20004 - `backend/services/taskService/main.go` —— 默认端口确认 20006 - `backend/services/statisticService/main.go` —— 默认 20009 - `backend/services/notificationService/main.go` —— 默认 20010 - `backend/services/moderationService/main.go` —— 默认 20011 - `backend/services/aiChatService/main.go` —— 默认 20008 - `backend/services/socialService/main.go` —— 默认 20002 - `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`、`k8s/helm/topfans/values.yaml` —— 同步 `DUBBO_*_SERVICE_URL` 默认值 - `backend/gateway/config/port_test.go` (新增) —— 配置断言测试 **Interfaces**: ```go // backend/gateway/config/config.go (excerpt) type DubboConfig struct { UserServiceURL string // tri://127.0.0.1:20000 SocialServiceURL string // tri://127.0.0.1:20002 AssetServiceURL string // tri://127.0.0.1:20003 GalleryServiceURL string // tri://127.0.0.1:20001 ← 修正 ActivityServiceURL string // tri://127.0.0.1:20004 ← 修正 TaskServiceURL string // tri://127.0.0.1:20006 StarbookServiceURL string // 删除(4.2) AIChatServiceURL string // tri://127.0.0.1:20008 StatisticServiceURL string // tri://127.0.0.1:20009 NotificationServiceURL string // tri://127.0.0.1:20010 ModerationServiceURL string // tri://127.0.0.1:20011 } ``` **Steps**: - [ ] 读 `backend/gateway/config/config.go:158-170`,记下当前 11 个 `DUBBO_*_SERVICE_URL` 默认值。 - [ ] 改 `backend/gateway/config/config.go:162`:`getEnv("DUBBO_GALLERY_SERVICE_URL", "tri://127.0.0.1:20001")`。 - [ ] 改 `backend/gateway/config/config.go:163`:`getEnv("DUBBO_ACTIVITY_SERVICE_URL", "tri://127.0.0.1:20004")`。 - [ ] 验证 `Task/Asset/AIChat/Statistic/Notification/Moderation/Social/User` 默认值与实际服务 `main.go` 的 `flag.Int("port", ...)` 默认一致(读 `backend/services//main.go` 头部 `var port` 块)。 - [ ] 改 `backend/services/galleryService/main.go:44`:`taskServiceURL = flag.String("task-service-url", getEnv("TASK_SERVICE_URL", "tri://localhost:20006"), ...)`。 - [ ] 改 `backend/services/userService/main.go:283`:`protocol.WithPort(*port)`(替代硬编码 20000),同时保留 `port` flag 默认 20000。 - [ ] 创建 `backend/gateway/config/port_test.go`,内容(关键骨架): ```go package config import ( "regexp" "strconv" "testing" ) // 各服务 flag 默认端口(与 /main.go 顶部 var port = flag.Int(...) 一致) var serviceDefaultPorts = map[string]int{ "user": 20000, "social": 20002, "asset": 20003, "gallery": 20001, "activity": 20004, "task": 20006, "aiChat": 20008, "statistic": 20009, "notification": 20010, "moderation": 20011, } // TestGatewayDubboPortsMatchServiceDefaults 校验网关默认 URL 端口 == 各服务 main.go 绑定端口 func TestGatewayDubboPortsMatchServiceDefaults(t *testing.T) { cfg := Load() cases := map[string]string{ "user": cfg.Dubbo.UserServiceURL, "social": cfg.Dubbo.SocialServiceURL, "asset": cfg.Dubbo.AssetServiceURL, "gallery": cfg.Dubbo.GalleryServiceURL, "activity": cfg.Dubbo.ActivityServiceURL, "task": cfg.Dubbo.TaskServiceURL, "aiChat": cfg.Dubbo.AIChatServiceURL, "statistic": cfg.Dubbo.StatisticServiceURL, "notification": cfg.Dubbo.NotificationServiceURL, "moderation": cfg.Dubbo.ModerationServiceURL, } re := regexp.MustCompile(`:(\d+)$`) for name, url := range cases { m := re.FindStringSubmatch(url) if len(m) != 2 { t.Fatalf("%s: URL %q 缺端口", name, url) } got, _ := strconv.Atoi(m[1]) want := serviceDefaultPorts[name] if got != want { t.Errorf("%s: 网关默认端口=%d, 服务实际默认=%d", name, got, want) } } } ``` - [ ] 跑 `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults -v` —— 必须通过。 - [ ] 改 `backend/.env.example:21-26`:删除 `DUBBO_SOCIAL_SERVICE_URL=tri://127.0.0.1:20001` 错值(应是 20002),并按上面 11 个 URL 重新列。 - [ ] 改 `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml`:所有 `DUBBO_*_SERVICE_URL` 默认端口与上述一致(实际生产值通常已被 gateway container 内部覆盖;只动文档化注释)。 - [ ] 改 `k8s/helm/topfans/values.yaml`:删除 `services.starbookservice` 块(4.2 负责删),并修正 `DUBBO_GATEWAY_*` 注释默认值。 - [ ] 跑 `cd backend && go build ./...` —— 全部模块编译通过。 - [ ] 跑 `cd backend && go test ./... -run TestPort` —— 端口断言通过。 - [ ] 准备 commit message:"fix(config): 对齐网关与服务默认端口并加配置断言测试 (批次 4.1)"—— **需用户批准后 commit**。 ### Task 2: starbookService 决断 → 删除(4.2) > 对应审计 P0-7 与修复方案 4.2 推荐方案(生产未部署 starbookService)。 > ⚠️ 关联 P0-4 跨服务 import 解耦:assetService 当前 `import starbookService/repository`(`backend/services/assetService/main.go:32`、`service/mint_service.go:28`),但 starbookService 不在 `go.work`(第 1-16 行)。当前 `go build` 实际**已失败**,本 Task 同步修复。 **Files**: - 删除:`backend/services/starbookService/`(整个目录,含 `main.go`、`provider/`、`repository/`、`service/`、`starbookService` 二进制若存在) - 删除:`k8s/helm/topfans/templates/starbookservice/deployment.yaml` - 修改:`backend/services/assetService/main.go:32,159` —— 删除 `starbookRepo` import,改为本地 `repository.NewAssetRegistryRepository` - 修改:`backend/services/assetService/service/mint_service.go:28,71,84` —— 删除 `starbookRepo` import,改用 `assetRegistryRepo.AssetRegistryRepository`(定义在 `assetService/repository`) - 修改:`backend/services/assetService/service/asset_service.go:58,69,78,116-131` —— `registryRepo` 类型不变(接口定义在 `asset_service.go:46-49`,已用 `models.AssetRegistry`),仅切换实现来源 - 新增:`backend/services/assetService/repository/asset_registry_repository.go` —— 从 starbookService 复制 `AssetRegistryRepository` 接口 + 实现,改 package 为 `repository`;去掉 import 循环 - 修改:`backend/gateway/config/config.go:95,165` —— 删除 `StarbookServiceURL` 字段 - 修改:`backend/go.work:3-16` —— 移除 starbookService(实际本就不在) - 修改:`backend/dev.sh:24,347,365,432,438,468,493,510,530` —— 删除所有 starbookService 引用 - 修改:`docker/Dockerfile.services:50` —— 删除 `go build ... starbookservice` 行 - 修改:`docker/docker-compose.local.yml:231-262,442,482` —— 删除 starbookservice service block 与 `DUBBO_STARBOOK_SERVICE_URL` env - 修改:`docker/docker-compose.prod.yml:365-401,629,659` —— 同上 - 修改:`k8s/helm/topfans/values.yaml:125,214-232` —— 删除 `services.starbookservice` 块与其在 `gateway.env` 里的 `DUBBO_STARBOOK_SERVICE_URL` **Interfaces**(新增): ```go // backend/services/assetService/repository/asset_registry_repository.go package repository import "github.com/topfans/backend/pkg/models" // AssetRegistryRepository 资产统一索引(从 starbookService 迁来,assetService 现在是 owner) type AssetRegistryRepository interface { Create(registry *models.AssetRegistry) error GetByID(id int64) (*models.AssetRegistry, error) GetByAssetID(assetID int64) (*models.AssetRegistry, error) GetByAssetTypeAndID(assetType string, assetID int64) (*models.AssetRegistry, error) GetByOwner(ownerUID, starID int64) ([]*models.AssetRegistry, error) GetByOwnerAndType(ownerUID, starID int64, assetType string, limit, offset int) ([]*models.AssetRegistry, error) GetByOwnerAndTypeAndGrade(...) (...) GetByOwnerAndTypeAndCategory(...) (...) GetByOwnerAndTypeAndActivity(...) (...) CountByOwner(...) (int64, error) CountByOwnerAndType(...) (int64, error) CountByOwnerAndTypeAndGrade(...) (int64, error) CountByOwnerAndTypeAndCategory(...) (int64, error) CountByOwnerAndTypeAndActivity(...) (int64, error) UpdateLikeCount(assetID int64, likeCount int32) error UpdateGrade(assetID int64, grade int32) error Delete(assetID int64) error DeleteByAssetType(assetType string, assetID int64) error } func NewAssetRegistryRepository(db *gorm.DB) AssetRegistryRepository { ... } ``` **Steps**: - [ ] 确认 `backend/services/starbookService/` 完整目录清单:`ls -la backend/services/starbookService/`。 - [ ] **解耦步骤 A**——`backend/services/assetService/repository/asset_registry_repository.go`(新建):从 `backend/services/starbookService/repository/asset_registry_repository.go` 复制完整内容(接口 + 实现 + 388 行),改 `package repository`,删除跨服务 import(保留 `pkg/models` + `pkg/errors` + `gorm.io/gorm`)。 - [ ] **解耦步骤 B**——`backend/services/assetService/main.go:32`:删除 `starbookRepo "github.com/topfans/backend/services/starbookService/repository"`。 - [ ] **解耦步骤 C**——`backend/services/assetService/main.go:159`:把 `registryRepo := starbookRepo.NewAssetRegistryRepository(database.GetDB())` 改为 `registryRepo := repository.NewAssetRegistryRepository(database.GetDB())`。 - [ ] **解耦步骤 D**——`backend/services/assetService/service/mint_service.go:28,71,84`:删 `starbookRepo` import;类型改 `registryRepo repository.AssetRegistryRepository`;参数类型同步改。 - [ ] **解耦步骤 E**——`backend/services/assetService/service/asset_service.go:46-49`:当前 `RegistryRepository` 接口只声明 `GetByOwner`,无法满足 `mint_service.go` 需要(mint_service 实际不用 registryRepo 的其他方法,仅用 `registryRepo.Create` 等;先确认)。如 mint_service 不需要接口扩展则保持现状;如需则把 `RegistryRepository` 接口扩展为 asset 内部接口或引用新 `repository.AssetRegistryRepository`。 - [ ] 跑 `cd backend && go build ./services/assetService/...` —— 必须通过(说明解耦成功)。 - [ ] **删除步骤**——`git rm -r backend/services/starbookService/`。 - [ ] **删除 helm**——`git rm k8s/helm/topfans/templates/starbookservice/deployment.yaml`(若存在父目录则一并 `rmdir`)。 - [ ] 改 `backend/gateway/config/config.go`:删 `StarbookServiceURL` 字段(第 95、165 行),同步从 `cases` map 移除(在 port_test 中也对应删除)。 - [ ] 确认 `backend/go.work` 已不含 starbookService(grep `starbook` 应无结果)。 - [ ] 改 `backend/dev.sh`: - 行 24:从服务循环数组移除 `starbookService`。 - 行 347、365:从 `inotifywait --exclude` 列表移除 `starbookService$`。 - 行 432、438:从 build/start 循环移除。 - 行 468:删除 `build_service "starbookService" ...` 函数调用。 - 行 493:删除 `start_service "starbookService" ...` 调用。 - 行 510:删除 `start_watcher "starbookService" ...` 调用。 - 行 530:删除打印行 `Starbook Service: tri://localhost:20007`。 - [ ] 改 `docker/Dockerfile.services:50`:删除 `go build -o /tmp/starbookservice services/starbookService/main.go` + `echo "Built starbookservice"` 两行。 - [ ] 改 `docker/docker-compose.local.yml`:删 `starbookservice:` service 块(行 231-262)+ `DUBBO_STARBOOK_SERVICE_URL: tri://starbookservice:20007` env(行 442)+ 任何 depends_on starbookservice 引用(行 482)。 - [ ] 改 `docker/docker-compose.prod.yml`:同 local,端口用 `20005`(生产错配)。 - [ ] 改 `k8s/helm/topfans/values.yaml`:删 `starbookservice:` 块(行 214-232)+ `gateway.env.DUBBO_STARBOOK_SERVICE_URL` 引用。 - [ ] 跑 `cd backend && go build ./...` —— 全部模块编译通过。 - [ ] 跑 `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults` —— 通过(已移除 starbook 用例)。 - [ ] 跑 `cd backend && go test ./...` —— 全部测试通过。 - [ ] 跑 `grep -rn 'starbook\|Starbook' backend docker k8s dev.sh` —— 仅剩 `backend/migrations/` 中可能的 starbook 业务表(如 `asset_registry`),不应再有任何 starbookService 部署引用。 - [ ] 准备 commit message:"refactor(services): 删除 starbookService 孤儿代码并内化 AssetRegistryRepository 到 assetService (批次 4.2)"—— **需用户批准后 commit**。 ### Task 3: 健康探针修正 + 大二进制出库(4.3) > 对应审计 §三 P1 探针/大二进制行 + 修复方案 4.3。 **Files**: - `backend/pkg/health/health.go:28-29` —— 当前 `mux.HandleFunc("/health", ...)`;新增 `/healthz` alias 处理(统一两个路径,避免 helm values 长期不一致) - `backend/services/notificationService/main.go` —— 探针端口 21010 已正确(健康服务端口 = Dubbo 端口 + 1000),无须改 - `backend/services/moderationService/main.go` —— 探针路径 `/` 改为 `/health`(让 helm values 也对齐) - `k8s/helm/topfans/values.yaml:293,314` —— `notificationservice.healthPath: /health`(从 `/healthz` 改),`moderationservice.healthPath: /health`(从 `/` 改) - `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58` —— 探针 path 改 `/health`(无 `healthPath` 模板变量,可保留 default 但需对齐) - `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50,58` —— 探针 path 改 `/health` - `docker/docker-compose.local.yml`、`docker/docker-compose.prod.yml` —— 任何 `HEALTHCHECK` 指令对齐 `/health` - `backend/.gitignore` —— 追加 `backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars`、`gateway-fixed`、`gateway/gateway-fixed` 等顶层可执行文件 - `backend/assetService`、`backend/gateway-fixed`、`backend/test`、`backend/cleanup-orphan-avatars` —— `git rm --cached` **Interfaces**(`backend/pkg/health/health.go` 修改): ```go // 在现有 mux.HandleFunc("/health", h.handleHealth) 后追加: mux.HandleFunc("/healthz", h.handleHealth) ``` (K8s 探针仍可用 `/health`;保留 `/healthz` 兼容老 chart;不破坏既有探针。) **Steps**: - [ ] 读 `backend/pkg/health/health.go:27-44`,确认当前 mux 仅注册 `/health`。 - [ ] 改 `backend/pkg/health/health.go:29` 后追加 `mux.HandleFunc("/healthz", h.handleHealth)`。 - [ ] 跑 `cd backend && go build ./pkg/health/...` —— 通过。 - [ ] 改 `k8s/helm/topfans/values.yaml:293`:`healthPath: /health`(notificationservice);`k8s/helm/topfans/values.yaml:314`:`healthPath: /health`(moderationservice,从 `/` 改)。 - [ ] 改 `k8s/helm/topfans/templates/notificationservice/deployment.yaml:50,58`:把 `{{ $svc.healthPath | default "/healthz" }}` 改为 `{{ $svc.healthPath | default "/health" }}`(默认同步)。 - [ ] 改 `k8s/helm/topfans/templates/moderationservice/deployment.yaml:50,58`:把 `{{ $svc.healthPath | default "/" }}` 改为 `{{ $svc.healthPath | default "/health" }}`。 - [ ] 跑 `cd k8s/helm/topfans && helm template . --values values.yaml 2>&1 | grep -E 'healthz|path:'` —— 验证所有探针 path 为 `/health`。 - [ ] 改 `docker/docker-compose.local.yml`:搜 `HEALTHCHECK`,把任何 `curl http://localhost:2xxxx/healthz` 改为 `/health`。 - [ ] 改 `docker/docker-compose.prod.yml`:同上。 - [ ] 改 `backend/.gitignore`:在"Backend 构建产物"区块追加: ``` # 顶层历史二进制(本地构建残留,禁止入库) /assetService /gateway-fixed /test /cleanup-orphan-avatars ``` - [ ] 跑 `cd /Users/liulujian/Documents/code/TopFansByGithub && git rm --cached backend/assetService backend/gateway-fixed backend/test backend/cleanup-orphan-avatars`(若文件已被 .gitignore 覆盖,则先从暂存区移除)。 - [ ] 跑 `git ls-files | grep -E '^backend/(assetService|gateway-fixed|test|cleanup-orphan-avatars)$'` —— 应为空。 - [ ] 跑 `git status --ignored | grep -E '^backend/(assetService|gateway-fixed|test|cleanup-orphan-avatars)$'` —— 应列出(说明已忽略但本地保留)。 - [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。 - [ ] 跑本地健康检查:`cd backend && (PORT=20010 ./services/notificationService/notificationService &) ; sleep 2 ; curl -s http://localhost:21010/health ; curl -s http://localhost:21010/healthz` —— 两个路径均返回 `{"status":"ok"}`。 - [ ] 准备 commit message:"fix(deploy): 健康探针路径统一为 /health + 顶层历史二进制出库 (批次 4.3)"—— **需用户批准后 commit**(建议拆为两个 commit:二进制出库 + 探针修正,分别走 `git add .gitignore` 与 `git add helm`)。 ### Task 4: 序列脚本 setval(4.4) > 对应审计 §三 P1 `create_gallery_test_users.go` 与修复方案 4.4。遵循 `CLAUDE.md` PostgreSQL 序列规范。 **Files**: - `backend/scripts/create_gallery_test_users.go:59-152` —— 在每个手动 INSERT 表后追加 `SELECT setval(...)` **Interfaces**(无新接口;纯输出增强): ```go // 输出 SQL 末尾追加序列重置;当前 main 函数应收集受影响表名集合,结尾统一输出。 var tablesToReset = []string{"users", "fan_profiles", "booth_slots", "assets", "exhibitions"} for _, tbl := range tablesToReset { fmt.Printf("SELECT setval('%s_id_seq', (SELECT MAX(id) FROM %s));\n", tbl, tbl) } ``` **Steps**: - [ ] 通读 `backend/scripts/create_gallery_test_users.go:1-170`,列出所有手动指定 id 的 INSERT 表:`users` (id=100/101)、`fan_profiles` (无 id,仅 `user_id, star_id` 复合键——若 BIGSERIAL 则无需)、`booth_slots` (slot_id=1001-1003, 2001-2003)、`assets` (id=1000-1003)、`exhibitions` (无 id)。 - [ ] 检查 `fan_profiles` 与 `exhibitions` 是否 BIGSERIAL(查 migrations:`grep -E 'CREATE TABLE (fan_profiles|exhibitions)' backend/migrations/*.sql`)。 - [ ] 改 `backend/scripts/create_gallery_test_users.go`: - 在 `var` 区块追加 `tablesToReset := []string{"users", "booth_slots", "assets"}`(`fan_profiles` 与 `exhibitions` 如非 BIGSERIAL 则不计入)。 - 在 `main()` 末尾、所有 `fmt.Println` 之前,循环 `for _, tbl := range tablesToReset { fmt.Printf("SELECT setval('%s_id_seq', (SELECT MAX(id) FROM %s));\n", tbl, tbl) }`。 - 在最后追加 `fmt.Println("-- 序列重置完成")`。 - [ ] 跑 `cd backend && go run scripts/create_gallery_test_users.go > /tmp/test_users.sql`,确认末尾包含 `SELECT setval('users_id_seq', ...)`、`SELECT setval('booth_slots_id_seq', ...)`、`SELECT setval('assets_id_seq', ...)` 三行。 - [ ] 验证迁移可行性:在测试库 `psql -f /tmp/test_users.sql`(用 docker `postgresql-database-1` 即可),确认不报 `duplicate key` 且三条 `setval` 执行成功。 - [ ] 跑 `cd backend && go build ./...` —— 通过。 - [ ] 跑 `cd backend && go vet ./scripts/...` —— 无 warning。 - [ ] 准备 commit message:"fix(scripts): create_gallery_test_users.go 输出 SQL 末尾补 setval 序列重置 (批次 4.4)"—— **需用户批准后 commit**。 ### Task 5: .env ↔ .env.example 对齐(4.5) > 对应审计 §三 P1 漂移与修复方案 4.5。`.env.example` 仅留占位符,`.env` 真值不进 git。 **Files**: - `backend/.env.example` —— 增补缺失键、删除占位符中的真实密钥痕迹(`LTAI5t...`、`sk-cp-`、`sk-proj-`、`app-...` 等) - `backend/.env` —— 实际环境值,本 Task 不入仓但需确保所有代码读取的键均有对应 `getEnv(...)` 默认值或 example - `backend/gateway/config/env_audit_test.go` (新增) —— 断言 `.env` 解析后所有"必填"键非空(仅对非敏感键,如 `WS_AI_CHAT_PATH`、`LANDING_BASE_URL`) **Interfaces**(无新接口;测试骨架): ```go // backend/gateway/config/env_audit_test.go package config import ( "os" "strings" "testing" ) // TestEnvExampleDrift 断言 .env.example 含全部 .env 中已文档化的键(粗粒度:按 KEY= 前缀) func TestEnvExampleDrift(t *testing.T) { exampleBytes, err := os.ReadFile("../../.env.example") if err != nil { t.Fatal(err) } envBytes, err := os.ReadFile("../../.env") if err != nil { t.Fatal(err) } exampleKeys := parseEnvKeys(string(exampleBytes)) envKeys := parseEnvKeys(string(envBytes)) // 反向断言:.env 中有的键 .env.example 必出现(除 SECRET_KEY/JWT_SECRET/真实 key) for k := range envKeys { if isSecretKey(k) || isRealAPIKey(k) { continue } if _, ok := exampleKeys[k]; !ok { t.Errorf(".env 有键 %q 但 .env.example 缺失", k) } } } ``` **Steps**: - [ ] 跑 `diff <(grep -E '^[A-Z_]+=' backend/.env | cut -d= -f1 | sort) <(grep -E '^[A-Z_]+=' backend/.env.example | cut -d= -f1 | sort)` —— 列出 .env 独有键(应在 .env.example 补)和 .env.example 独有键(可能是模板占位/可保留)。 - [ ] 列出 `.env` 独有键(实际环境值,非 secret):`WS_AI_CHAT_PATH`、`LASER_COMPOSITOR_URL`、`COMPOSITOR_PORT`、`LASER_GEN_PROVIDER`、`MINIMAX_API_KEY`、`MINIMAX_API_URL`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`DIFY_API_BASE`、`DIFY_API_KEY`、`JWT_SECRET`、`ENV`、`LOG_LEVEL`、`OSS_REGION`、`OSS_BUCKET_NAME`、`OSS_STS_ROLE_ARN`、`OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_AVATAR_DIR`、`OSS_ASSET_DIR`、`OSS_TOKEN_EXPIRE_TIME`、`REDIS_HOST`、`REDIS_PORT`、`REDIS_PASSWORD`、`REDIS_DB`、`DB_HOST`、`DB_PORT`、`DB_USER`、`DB_PASSWORD`、`DB_NAME`、`GIN_MODE`、`SERVER_PORT`、`SECRET_KEY`。 - [ ] 列出 `.env.example` 独有键(当前文档化但 .env 未用):`LANDING_BASE_URL`、`PUSH_ENABLED`、`PUSH_URL`、`PUSH_TIMEOUT_MS`、`DIFY_TIMEOUT_SEC`、`DIFY_WORKFLOW`、`SEGMENT_PROVIDER`、`SEGMENT_INFERENCE_URL`、`DUBBO_USER_SERVICE_URL` 至 `DUBBO_STARBOOK_SERVICE_URL`(4.2 后应删 Starbook)。 - [ ] 改 `backend/.env.example`: - 行 87:取消注释 `WS_AI_CHAT_PATH=/ai-chat`(去掉 `# ` 前缀)。 - 行 121-123:保留 `PUSH_ENABLED/PUSH_URL/PUSH_TIMEOUT_MS`,但 `PUSH_URL=` 留空占位符。 - 行 124-128:删除 `OPENAI_*` 三行(真实密钥痕迹),改为 `# OPENAI_* 由 LASER_GEN_PROVIDER=openai 启用时填入`。 - 行 131-133:删除 `DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ`(Dify 真实密钥),改为 `DIFY_API_KEY=`。 - 行 106:删除 `OPENAI_API_KEY=sk-proj-...` 真实值,改为 `OPENAI_API_KEY=`。 - 增补 `LANDING_BASE_URL=http://localhost:5173`(已在最顶部,确认);增补 `LASER_COMPOSITOR_URL=http://127.0.0.1:7002`、`LASER_GEN_PROVIDER=openai`;增补 `MINIMAX_API_KEY=` 与 `MINIMAX_API_URL=https://api.minimaxi.com/v1/image_generation`(已有,确认)。 - 增补 `SEGMENT_*`(已有 73-74 行,确认)。 - 增补 `DIFY_TIMEOUT_SEC=60`(已有 133 行,确认)。 - [ ] 在 `.env.example` 顶部加注释: ``` # .env.example 仅作键清单 + 占位符。真实值由部署环境注入(docker env_file / k8s Secret / systemd EnvironmentFile)。 # 严禁把真实 key 写入本文件;本文件应可直接 commit。 ``` - [ ] 创建 `backend/gateway/config/env_audit_test.go`(见上面骨架),实现 `parseEnvKeys`(按行解析 `^[A-Z_]+=`)、`isSecretKey`、`isRealAPIKey`(白名单:`SECRET_KEY/JWT_SECRET/PASSWORD/*_KEY/*_SECRET/TOKEN`)。 - [ ] 跑 `cd backend && go test ./gateway/config/... -run TestEnvExampleDrift -v` —— 通过。 - [ ] 确认 `git check-ignore backend/.env` —— 返回 0(已忽略);`git ls-files backend/.env` —— 应为空。 - [ ] 确认 `git ls-files backend/.env.example` —— 有文件。 - [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。 - [ ] 准备 commit message:"fix(env): 同步 .env.example 与 .env 键清单并清理示例文件中的真实密钥痕迹 (批次 4.5)"—— **需用户批准后 commit**。 ### Task 6: P2 清理(4.9) > 对应审计 §四 P2 八项。所有改动均小而独立,按表格逐项修。 **Files & Actions**(按文件:行 + 改动说明): | P2 项 | 文件:行 | 改动 | |-------|---------|------| | `parseRPCError` 字符串解析 | `backend/gateway/controller/asset_controller.go:158-194` | 改用 `google.golang.org/grpc/status` 的 `status.FromError(err)`;删除正则解析。返回结构 `{code: int, message: string}`,调 7 处 `parseRPCError(err)` 调用(行 240/286/365/492/568/700/765/827)保持签名不变 | | 原始 err 泄露前端 | `backend/gateway/controller/auth_controller.go:224,294,306`、`user_controller.go:141,247,498` | 把 `c.JSON(500, gin.H{"message": err.Error()})` 改为 `response.InternalError(c)` 或 `response.BadRequest(c, "操作失败")` + `logger.Logger.Error("...", zap.Error(err))` 服务端日志保留 err | | `ResetPassword` 不失效 JWT | `backend/services/userService/service/user_service.go:681` | 现状已在事务内把 `access_token` 置 nil + `token_expires_at` 置 nil。补:`gateway/middleware/auth_middleware.go` 在解析 JWT 后**额外**比对 `users.access_token`(如已为 nil 则 401)。如工期紧张,至少在 `user_service.go:681` 注释里写明 "JWT 失效留待 ticket #XX 处理;当前实现只阻止后续发新 token 复用"——选择最小改动(仅注释 + ticket 链接)以避免越界改动 middleware | | `UpdateAvatar` URL 不校验同源 | `backend/services/userService/service/user_service.go:1023` | 在写库前增加同源校验:`u, err := url.Parse(req.AvatarUrl)`;`if u.Host != "" && !strings.Contains(u.Host, "aliyuncs.com") { return nil, ErrInvalidAvatarURL }`;白名单仅 `aliyuncs.com` 子域 | | PII 进 INFO 日志 | `backend/services/aiChatService/provider/ai_chat_provider.go:107`、`mint_service.go`、`user_service.go` 多处 | 把 `user_id`/`session_id` 之外不要打印 `message`/`mobile`;或改用 `logger.Logger.Debug(...)`;`mobile` 用 `mobile[:3] + "****" + mobile[7:]` 脱敏 | | pkg/mq 死代码 | `backend/pkg/mq/streams/adapter.go`(`EventProducer`/`Stream*` 常量)、`backend/pkg/mq/asynq/adapter.go:117`(`Delete` 假实现) | 与批次 3.3 一并决断(用户已选"停用 streams adapter"则本 Task 把 `streams/` 整个子包删除、`mq.go` 的 `MQDriver = Streams` 分支删除);asynq `Delete` 暂保留(实际不调用),注释"未接线" | | `is_processed` 复用为 settled | `backend/services/galleryService/mq/consumer.go:207` | **由批次 1.1(`exhibition-settlement-idempotency` plan Task 3)负责** `settled_at` 列 + `isSettled/markSettled` 改写。本 plan 不重复编辑此函数,仅在回归时校验其已切换。 | | 直连 Redis Pub/Sub | `backend/services/activityService/service/activity_service.go:217,1579` | 两处 `s.redisClient.Publish(...)` 改为走 `adapter.EventProducer.Publish(ctx, "activity.contributions", payload)`(若 pkg/mq 已确定保留,否则本项 defer 到 3.3) | | 周边密钥兜底 | `backend/pkg/peripheral/sign.go:33-44` | 去掉 `JWT_SECRET` 兜底与硬编码 `"default-dev-secret-change-me"`;`SECRET_KEY == ""` 时 `panic("SECRET_KEY is required")`(fail-fast) | **Interfaces**(新增/修改): ```go // backend/gateway/controller/asset_controller.go (修改后) import "google.golang.org/grpc/status" func parseRPCError(err error) (code int, message string) { if err == nil { return http.StatusOK, "" } if st, ok := status.FromError(err); ok { // grpc status code → HTTP code 映射(参考 grpc-gateway 约定) return grpcCodeToHTTP(st.Code()), st.Message() } return http.StatusInternalServerError, "服务暂时不可用" } func grpcCodeToHTTP(c codes.Code) int { switch c { case codes.NotFound: return http.StatusNotFound case codes.InvalidArgument: return http.StatusBadRequest case codes.Unauthenticated: return http.StatusUnauthorized case codes.PermissionDenied: return http.StatusForbidden case codes.AlreadyExists: return http.StatusConflict default: return http.StatusInternalServerError } } ``` ```go // backend/pkg/peripheral/sign.go (修改后) func getSecret() string { secretOnce.Do(func() { secretVal = os.Getenv("SECRET_KEY") if secretVal == "" { panic("SECRET_KEY is required for peripheral HMAC signing") } }) return secretVal } ``` **Steps**(按表格逐项): - [ ] **A. parseRPCError**:改 `backend/gateway/controller/asset_controller.go:158-194` 为 `status.FromError` 形式。7 处调用点(行 240/286/365/492/568/700/765/827)签名不变,无须改。跑 `cd backend && go build ./gateway/...` 通过。 - [ ] **B. err 泄露**:改 `auth_controller.go:224,294,306` + `user_controller.go:141,247,498` 五处,把 `err.Error()` 移到 `logger.Logger.Error("...", zap.Error(err))`,响应改为稳定文案(如 `response.InternalError(c, "操作失败")`)。grep 验证 `c.JSON(500, gin.H{"message": err.Error()})` 仅剩 0 处。 - [ ] **C. ResetPassword JWT**:决策点——读 `backend/gateway/middleware/auth_middleware.go` 当前是否校验 `users.access_token`;若否,最小改动为更新 `user_service.go:681` 注释(添加 ticket 链接),不修改 middleware;若已校验,本项完成。`git grep 'access_token' backend/gateway/middleware/`。 - [ ] **D. UpdateAvatar 同源**:改 `user_service.go:1023` 加 `url.Parse` + `aliyuncs.com` 白名单;`pkg/errors` 增加 `ErrInvalidAvatarURL`。 - [ ] **E. PII 日志**:改 `ai_chat_provider.go:107` 不打 `message`;`user_service.go` 中 `mobile` 打印用脱敏函数。grep `logger.Logger.Info.*mobile.*req` / `zap.String("message", message)` 全面排查。 - [ ] **F. pkg/mq 死代码**:读 `backend/pkg/mq/mq.go` 看 `MQDriver = Streams` 分支是否被引用。若无引用,删除 `backend/pkg/mq/streams/` 子包 + `mq.go` 中的 streams init 分支;`asynq/adapter.go:117` 的 `Delete` 假实现加注释 `// TODO: 实际未调用,asynq.DeletedTaskInfo 返回 false 即视为不存在`。 - [ ] **G. `is_processed` 复用**:**不在本 plan 编辑**。`settled_at` 列 + `isSettled/markSettled` 改写归批次 1.1(`exhibition-settlement-idempotency` plan Task 1 migration + Task 3)。本 plan 仅校验:`grep -n 'settled_at' backend/services/galleryService/mq/consumer.go` 确认已切换;未切换则回到批次 1.1 执行。 - [ ] **H. Redis Pub/Sub 直连**:决策点——读 `backend/pkg/mq/mq.go` 的 `EventProducer` 接口是否已稳定;若稳定,改 `activity_service.go:217,1579` 为 `mq.GetEventProducer().Publish(ctx, "activity.contributions", payload)`;否则本项 defer 到 3.3。 - [ ] **I. 周边密钥 fail-fast**:改 `pkg/peripheral/sign.go:33-44`,去掉 JWT_SECRET 兜底与硬编码默认值。跑 `cd backend && SECRET_KEY= go test ./pkg/peripheral/...` —— 必须 panic 或返回明确错误(不能静默用 dev 默认值)。 - [ ] 跑 `cd backend && go build ./... && go test ./...` —— 全部通过。 - [ ] 跑 `cd backend && go vet ./...` —— 无 warning。 - [ ] 跑 `cd backend && grep -rnE 'parseRPCError.*errStr\|err\.Error\(\)\|default-dev-secret\|JWT_SECRET.*secretVal' .` —— 仅 0 处(除新增注释)。 - [ ] 准备 commit message(建议拆 3 个):"refactor(gateway): parseRPCError 改用 grpc/status.FromError (批次 4.9-A)" + "fix(gateway): 修复 5 处原始 err 泄露前端 (批次 4.9-B)" + "fix(peripheral): SECRET_KEY 缺失 fail-fast,去 JWT_SECRET/dev 默认兜底 (批次 4.9-I)"—— **每个 commit 需用户批准**。 --- ## Self-Review > 严格按 `CLAUDE.md` 全局自审规则:通读本文 §1→§5,核对跨章节引用、Go 编译预期、副作用、commit 边界。 ### 修改的章节 - §File Structure(任务清单) - §Task 1–6(步骤、文件、接口) ### 未改动但通读确认的章节 - §Goal / §Architecture / §Tech Stack —— 与原方案对齐,无改动需求。 - §Global Constraints —— 与审计 §五 修复顺序、批次 4 路线图一致;序列规则、commit 守则与 `CLAUDE.md` 一致。 ### 跨章节引用一致性检查 - **端口矩阵(4.1 ↔ §File Structure 表)**:4.1 列出 11 个服务端口,§File Structure 表也列 11 个服务端口——一致。 - **starbookService 删除(4.2 ↔ 4.1 端口断言)**:4.1 `serviceDefaultPorts` 与 `cases` map 必须不含 starbook——本计划已明确在 4.2 完成后删除 `StarbookServiceURL` 字段并同步 port_test 的 `cases` map。 - **健康探针(4.3 ↔ helm values)**:4.3 改 `notificationservice.healthPath` 从 `/healthz` → `/health`,与 `backend/pkg/health/health.go:28-29` 注册 `/health` 一致——OK。 - **大二进制(4.3 ↔ §Global Constraints 不动产物)**:`backend/bin/*` 与 `.gitignore` 第 10 行 `bin/` 仍受保护;本计划只追加根级四条二进制忽略——一致。 - **序列脚本(4.4 ↔ §Global Constraints)**:遵循 `CLAUDE.md` 强制规则——一致。 - **.env 漂移(4.5 ↔ .gitignore)**:`.env` 已在 `.gitignore` 第 21 行忽略;本计划仅改 `.env.example`——一致。 - **P2 清理(4.9 ↔ 批次 3.3 决断)**:H/F 两项依赖批次 3.3 MQ 决断结论——本计划用"决策点"标注,未预设结论。 - **commit 边界(每 Task ↔ §Global Constraints)**:每个 Task 末尾标注"需用户批准后 commit",未授权自提交——一致。 ### Go 编译验证预期(必须能在本地真实跑通) | 步骤 | 命令 | 预期 | |------|------|------| | 端口测试 | `cd backend && go test ./gateway/config/... -run TestGatewayDubboPortsMatchServiceDefaults -v` | PASS | | env 审计 | `cd backend && go test ./gateway/config/... -run TestEnvExampleDrift -v` | PASS | | 全量编译 | `cd backend && go build ./...` | exit 0 | | 全量测试 | `cd backend && go test ./...` | exit 0 | | vet | `cd backend && go vet ./...` | exit 0 | | helm lint | `cd k8s/helm/topfans && helm lint .` | 0 errors | | 大二进制 | `git ls-files \| grep -E '^backend/(assetService\|gateway-fixed\|test\|cleanup-orphan-avatars)$'` | empty | | starbook 删除 | `find backend -path '*starbook*' -not -path '*/node_modules/*'` | 仅 `backend/migrations/` 中 starbook 业务表(如有),无 `starbookService/` 目录 | ### 优先级分类 - **P0(先做,挡构建)**:4.2 starbookService 删除(当前 `go build ./services/assetService/...` 失败)+ 4.1 端口对齐(影响所有下游服务)。 - **P1(同期)**:4.3 探针 + 大二进制 + 4.4 setval + 4.5 env。 - **P2(顺手)**:4.9 八项;其中 4.9-I 周边密钥 fail-fast 涉及安全边界,应排在 4.9 内靠前。 ### 失败模式预演 - 若 4.2 完成后 `go build ./...` 仍报 starbook 引用:检查 `assetService/service/mint_service.go` 是否还有 `starbookRepo` import 残留(grep 验证)。 - 若 4.1 port_test 报某服务端口不匹配:回到该服务 `main.go` 顶部 `var port = flag.Int(...)` 默认值核对,更新 `serviceDefaultPorts` map 与默认值。 - 若 4.3 helm template 探针 path 仍为 `/healthz`:检查 `_helpers.tpl` 或 deployment.yaml `default` 值是否覆盖——可能需硬改两处。 - 若 4.4 setval 报 `relation "xxx_id_seq" does not exist`:该表非 BIGSERIAL,从 `tablesToReset` 移除。 - 若 4.5 env 审计失败:逐一对照 `isSecretKey`/`isRealAPIKey` 白名单,确认是否把真 secret 误识别为 drift。 ### 跨任务依赖(按执行顺序) ``` 4.1 ──┐ ├──▶ 4.6(提交后整体回归) 4.2 ──┤ │ 4.3 ──┤ │ 4.4 ──┤ │ 4.5 ──┤ │ 4.9 ──┘ ``` 4.1 与 4.2 互不阻塞(4.2 内 assetService 解耦独立完成)。4.3/4.4/4.5/4.9 与前两者可并行。4.9 内部分项按"决策点"标注——若用户已决断批次 3.3,则 F/H 可立即执行;否则 defer。