spawn 开发指南
项目概述
spawn 是一个基于 Go-zero 框架的游戏社交平台 monorepo 项目,采用微服务架构,支持多端应用开发 (仓库原名 tappi,Go module 路径仍为 github.com/tappi/tappi,与仓库名解耦,不随改名迁移)。
开发环境准备
1. 工具安装
# 安装 Go (建议 1.26+)
brew install go
# 安装 goctl 工具
go install github.com/zeromicro/go-zero/tools/goctl@latest
# 安装 protobuf 工具
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# 将 GOPATH/bin 添加到 PATH
export PATH=$PATH:$(go env GOPATH)/bin2. 项目初始化
# 克隆项目
git clone git@github.com:cuihairu/spawn.git
cd spawn
# 验证 Go workspace
go work sync
# 安装依赖
go work download项目结构
spawn/
├── apps/ # 面向最终用户的应用
│ ├── web-client # Web 站点(React 19 + Vite:登录/榜单/攻略/评论/社区/通知)
│ └── mobile-app # React Native(Expo)客户端(游戏/发现/社区/战绩/我的)
├── services/ # 微服务(以下八个均已落地)
│ ├── user-service # 用户服务 API(:8888,JWT 签发方)
│ ├── user-service-rpc # 用户服务 RPC(gRPC :8080,集群内部)
│ ├── game-catalog # 游戏库、榜单、推荐(:8890)
│ ├── content-service # 攻略、评论、收藏(:8891)
│ ├── community # 帖子、话题、关注、点赞、通知、私信、审核(:8892)
│ ├── data-panel # 战绩摄入与查询(:8896)
│ ├── realtime-hub # WebSocket 实时中枢(:8893,非 go-zero)
│ └── api-gateway # BFF 聚合 + 五路反代(:8800)
├── packages/ # 共享包(config 已落地;ui-kit/data-models 等为规划)
├── platform/ # 基础设施(规划)
├── deploy/ # compose 与观测栈配置
├── tools/ # 开发工具
└── docs/ # 文档开发工作流
1. 创建新服务
# 创建 API 服务
cd services
goctl api new my-service
# 创建 RPC 服务
goctl rpc new my-service-rpc
# 更新 go.work
cd ..
go work use ./services/my-service ./services/my-service-rpc2. 开发 API
- 修改 API 定义 (
my-service.api)
syntax = "v1"
type UserRequest {
UserId int64 `path:"userId"`
}
type UserResponse {
UserId int64 `json:"userId"`
Username string `json:"username"`
Email string `json:"email"`
}
service my-service {
@handler UserHandler
get /users/:userId (UserRequest) returns (UserResponse)
@handler CreateUserHandler
post /users (CreateUserRequest) returns (UserResponse)
}- 生成代码
cd my-service
goctl api go -api my-service.api -dir .- 实现业务逻辑 (
internal/logic/userlogic.go)
func (l *UserLogic) User(req *types.UserRequest) (resp *types.UserResponse, err error) {
// 从数据库或其他服务获取用户信息
user, err := l.svcCtx.UserModel.FindOne(l.ctx, req.UserId)
if err != nil {
return nil, err
}
return &types.UserResponse{
UserId: user.Id,
Username: user.Username,
Email: user.Email,
}, nil
}3. 开发 RPC 服务
- 定义 Proto 文件 (
my-service.proto)
syntax = "proto3";
package myservice;
option go_package = "./myservice";
message UserRequest {
int64 user_id = 1;
}
message UserResponse {
int64 user_id = 1;
string username = 2;
string email = 3;
}
service MyService {
rpc GetUser(UserRequest) returns (UserResponse);
}- 生成代码
cd my-service-rpc
goctl rpc protoc my-service.proto --go_out=. --go-grpc_out=. --zrpc_out=.- 实现服务逻辑 (
internal/logic/userlogic.go)
func (l *GetUserLogic) GetUser(in *myservice.UserRequest) (*myservice.UserResponse, error) {
// 实现获取用户逻辑
user, err := l.svcCtx.UserModel.FindOne(l.ctx, in.UserId)
if err != nil {
return nil, err
}
return &myservice.UserResponse{
UserId: user.Id,
Username: user.Username,
Email: user.Email,
}, nil
}服务通信
1. 在 API 服务中调用 RPC 服务
- 配置 RPC 客户端 (
etc/my-service.yaml)
Name: my-service
Host: 0.0.0.0
Port: 8888
MyServiceRpc:
Etcd:
Hosts:
- etcd:2379
Key: my-service-rpc- 更新 ServiceContext (
internal/svc/servicecontext.go)
type ServiceContext struct {
Config config.Config
MyServiceRpc myserclient.MyService
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
MyServiceRpc: myserclient.NewMyService(zrpc.MustNewClient(c.MyServiceRpc)),
}
}- 在 Logic 中调用 RPC
func (l *UserLogic) User(req *types.UserRequest) (resp *types.UserResponse, err error) {
rpcResp, err := l.svcCtx.MyServiceRpc.GetUser(l.ctx, &myservice.UserRequest{
UserId: req.UserId,
})
if err != nil {
return nil, err
}
return &types.UserResponse{
UserId: rpcResp.UserId,
Username: rpcResp.Username,
Email: rpcResp.Email,
}, nil
}本地开发
1. 启动依赖服务
# 启动 etcd (用于服务发现)
docker run -d --name etcd \
-e ALLOW_NONE_AUTHENTICATION=yes \
-p 2379:2379 \
quay.io/coreos/etcd:v3.5.0
# 启动 Redis (用于缓存)
docker run -d --name redis -p 6379:6379 redis:7-alpine
# 启动 MySQL (用于持久化)
docker run -d --name mysql \
-e MYSQL_ROOT_PASSWORD=root123 \
-p 3306:3306 \
mysql:8.02. 启动服务
# 启动 RPC 服务
cd services/user-service-rpc
go run user.go -f etc/user.yaml
# 启动 API 服务
cd ../user-service
go run user.go -f etc/user-api.yaml3. 测试 API
# 测试用户服务
curl http://localhost:8888/from/you
# 返回: {"message": "Hello, you!"}
curl http://localhost:8888/from/me
# 返回: {"message": "Hello, me!"}配置管理
1. 环境变量
服务配置支持环境变量替换:
Name: user-api
Host: ${HOST:0.0.0.0}
Port: ${PORT:8888}2. 配置文件位置
- API 服务:
etc/{service-name}.yaml - RPC 服务:
etc/{service-name}.yaml - 配置结构:
internal/config/config.go
数据库集成
1. 添加 MySQL 依赖
go get github.com/go-sql-driver/mysql2. 配置数据库连接
# etc/user-api.yaml
DataSource: ${MYSQL_USER:root}:${MYSQL_PASSWORD:root123}@tcp(${MYSQL_HOST:localhost}:3306)/tappi?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai3. 使用 Model Cache
// 自动生成缓存模型
goctl model mysql datasource -url="user:pass@tcp(localhost:3306)/database" -table="user" -dir="./model"
// 在 Logic 中使用
func (l *UserLogic) GetUser(req *types.UserRequest) (*types.UserResponse, error) {
user, err := l.svcCtx.UserModel.FindOne(l.ctx, req.UserId)
if err != nil {
return nil, err
}
return &types.UserResponse{
UserId: user.Id,
Username: user.Name,
}, nil
}注:上例 goctl model cache / Redis 流程为未来可选方案,当前未采用 (原因见下文设计决策第 3 条)。
4. 设计决策:数据库模型与缓存(「下一步」第 2 项)
2026-09-30 拍板并落档。本项为多轮工作,以下决策覆盖全部后续切片; 首个切片(user-service 模型层缓存)已落地,交付物见第 3 条。
1)落地服务与顺序
| 顺序 | 服务 | 理由 | 状态 |
|---|---|---|---|
| 1 | user-service | 模型层已有 MySQL/SQLite 双驱动(本节先例),只缺缓存;登录/注册是全仓最热 DB 路径,缓存收益直接 | 已落地(本切片) |
| 2 | game-catalog | 原 JSON 文件仓库,按 user-service 模式迁 SQLite/MySQL 双驱动 + 读缓存;目录量为数百行,过滤/排序/推荐语义保留在应用侧 | 已落地(本切片) |
| 3 | community、content-service | 同为文件仓库,按 game-catalog 跑通的模式跟进 | content-service、community 均已落地(本切片) |
- user-service-rpc 为 goctl 生成层,不手改、不纳入切片。
2)MySQL DSN 与部署形态(沿用双驱动先例)
- DSN 注入:
MySQL.DataSource(envDATASOURCE),标准形态见上文 「配置数据库连接」:user:pass@tcp(host:3306)/tappi?charset=utf8mb4&parseTime=true&... - 驱动选择(
svc.NewServiceContext判定):DSN 含file:或.db→ SQLite; 否则 → MySQL。启动时Ping失败即 panic(快速失败,启动期暴露配置问题)。 - 部署形态(假设注明:仓库当前无 K8s 集群/镜像仓库凭据,与上文 「CI 流水线」一节结论一致):
- 开发:本页「启动依赖服务」的 docker mysql(8.0);
- CI / 单测:SQLite(临时文件或
:memory:),门禁零外部依赖; - 生产:环境变量注入的外置 MySQL 8 实例(单实例假定; 多实例化前需先落跨进程缓存失效方案,见第 3 条)。
3)缓存选型:进程内 TTL+LRU(本切片),Redis 留作演进
- 选型:进程内泛型缓存
services/user-service/internal/cache(TTL + LRU, 可注入时钟,ttl<=0即禁用),接入model.UserModel:- 读:
FindOne/FindByUsername/FindByEmail命中返回值副本(无别名共享); - 写:
Create/Update走统一失效点;Update先读行内旧 username/email (调用方常只传 Id+Email 部分结构,旧键须从行内容推导); - 不做负缓存(未找到/错误不写入);
CheckXxxExists的 COUNT 恒直查数据库; - 兜底:任何失效遗漏由 60s TTL 硬界收敛(陈旧 ≤60s)。
- 读:
- 不选 go-zero sqlc / goctl model cache + Redis 的原因:
- 本仓模型为手写
database/sql,全仓无 sqlc 代码生成,引入即等于重写全部模型; - CI 无 Redis 服务,新增外部依赖会破坏「门禁零外部依赖」的现状;
- 当前单实例部署,进程内缓存语义正确(无跨进程失效问题)。
- 本仓模型为手写
- 演进触发条件:实例数 >1、或出现跨进程失效/共享缓存需求时,将
internal/cache实现替换为 go-zerocache.Cache(Redis,接入点不变、 模型层代码零改动);届时启用本页「启动依赖服务」的 Redis 容器, 上文 goctl model cache 流程按需采用。 - 首个切片交付物:
services/user-service/internal/cache/ttlcache.go—— 泛型 TTL+LRU 缓存;services/user-service/model/usermodel.go—— 读缓存与失效点接入;- 单测
internal/cache/ttlcache_test.go(命中/过期/LRU 淘汰/禁用/并发); - 集成测试
model/usercache_test.go(关库命中、更新失效、无负缓存、值副本隔离)。
- game-catalog 切片交付物(2026-10-02):
services/game-catalog/model/gamemodel.go—— JSON 文件仓迁GameModel(games 表,数组列 JSON 文本编码;GameStore接口五方法签名不变, logic 层零改动);services/game-catalog/internal/cache/ttlcache.go—— 上列泛型缓存的 模块内同构副本(含单测);svc.NewServiceContext驱动探测(file:/.db→ SQLite)/ensureSQLiteDir/ Ping 快速失败/建表 +SeedIfEmpty(空表写入内嵌 8 款种子目录, 替代原data/games.json,种子内嵌进二进制);- 读路径语义保持:
List/Featured/Recommend每次全量读取后应用侧过滤排序 (数百行量级 + 与原内存仓语义一致,SQL 不下推);跨方言确定性以ORDER BY id为基线,并列趋势分的排序回退到 id 序(与原插入序的次序 漂移见台账);List 的Limit=0返回 0 行等模型层语义不变(logic 层 兜底 20 与原先一致); - Dockerfile 改 CGO 构建 + debian:bookworm-slim 运行时(go-sqlite3 需 cgo),
DATASOURCE默认file:/app/data/games.db。
- content-service 切片交付物(2026-10-02):
services/content-service/model/guidemodel.go+commentmodel.go—— Guide/Comment 双 JSON 文件仓迁GuideModel/CommentModel(guides/comments 两表,tags 数组列 JSON 文本编码;GuideStore七方法 /CommentStore五方法签名不变,logic 层零改动);services/content-service/internal/cache/ttlcache.go—— 泛型缓存的 模块内同构副本(含单测);svc.NewServiceContext驱动探测 /ensureSQLiteDir/ Ping 快速失败 / 建表 +SeedIfEmpty×2(空表写入内嵌 2 攻略 + 3 评论种子,替代原data/guides.json、data/comments.json,种子内嵌进二进制);- 两模型构造器
db.SetMaxOpenConns(1):并发测试实写并发(SQLite 文件库 多连接写触发 SQLITE_BUSY),且:memory:每连接各得独立空库;单实例 + 低写速率假设备注于模型注释; - 读路径语义保持:
List全量读取后应用侧过滤 + 分页钳制(与原内存仓 逐分支一致);ORDER BY id与原插入序严格一致(自增 id 恒单调, 零次序漂移);Seed显式保留 id、Create走 LastInsertId, 「删除后 id 不复用」语义不变; - Dockerfile 改 CGO 构建 + debian:bookworm-slim(同 game-catalog),
DATASOURCE默认file:/app/data/content.db。
- community 切片交付物(2026-10-02):
services/community/internal/model/{topic,post,follow}model.go—— 三 JSON 文件仓迁TopicModel/PostModel/FollowModel(topics/posts/follows 三表;TopicStore五方法 /PostStore九方法 /FollowStore五方法签名不变, logic 层零改动);软删语义落库:posts 增status列(Delete置deleted、行保留),与原内存仓「删除后不可见」逐分支一致;hotScore时间衰减与Hot置顶优先排序保留在应用侧(不下推 SQL);FollowModel不接缓存(写多读少的关联表,唯一索引单查已足够; Follow/Unfollow 的 bool 返回由「影响行数」判定,双方言幂等写INSERT OR IGNORE/INSERT IGNORE+UNIQUE(user_id,target_type,target_id));services/community/internal/cache/ttlcache.go—— 泛型缓存的模块内 同构副本(含单测),接入 TopicModel/PostModel 读路径;svc.NewServiceContext驱动探测 /ensureSQLiteDir/ Ping 快速失败 / 建表 ×3 +SeedIfEmpty×2(话题 2 条 + 帖子 2 条内嵌种子,替代原data/{topics,posts,follows}.json;follows 表刻意不设种子,空表起步);- Dockerfile 改 CGO 构建 + debian:bookworm-slim(同前两切片),
DATASOURCE默认file:/app/data/community.db。
5. 覆盖率台账刷新与 user-service-rpc 测试落地(2026-09-30)
全仓覆盖率快照(services/cov_*.out,本地不入库;生成命令: cd services/<模块> && go test ./... -covermode=atomic -coverpkg=./... -coverprofile=... 后按块聚合去重,可信总数以 go tool cover -func 为准):
| 模块 | 覆盖率 | 备注 |
|---|---|---|
| api-gateway | 98.2% | 本轮收口(原 96.3%) |
| user-service | 97.7% | 本轮独立复核确认见顶(派发 97.2% → 现 97.7%) |
| game-catalog | 97.5% | DB 切片轮重测(原内存仓收口 97.6%;迁 DB 新增模型/缓存代码后同口径复测) |
| community | 98.3% | DB 切片轮重测(原内存仓收口 99.2%;迁 DB 新增模型/缓存代码后同口径复测,缺口全量登记) |
| content-service | 97.2% | DB 切片轮重测(原内存仓收口 98.2%;迁 DB 新增模型/缓存代码后同口径复测,缺口全量登记) |
| user-service-rpc | 79.2%(含生成代码)/ 91.7%(手写面) | 见下 |
user-service-rpc 首轮测试落地:user.go main→run 重构(与五个兄弟服务一致, 配置加载失败返回错误而非 panic);新增 logic/server/svc 单测 + main 集成测试 (run() 起真 zRPC 服务,userclient 经 gRPC 端到端 Ping,Mode: test 覆盖 reflection 注册分支)。手写代码除 main()(stderr+exit,全仓既定不可达约定)外 100% 覆盖。模块口径 79.2% 的缺口全部来自 user/ 包 protoc 生成管道 (getter/Descriptor/GZIP/Unimplemented 守卫,DO NOT EDIT)——按「不硬造不可达 路径用例」的既定约定不做覆盖,同其早前被排除出覆盖率战役的理由一致。
user-service logic 层 DB 错误分支收口(同日第二轮): registerlogic.go:73(邮箱存在性检查报错)已补——用缺 email 列的 users 表触达 (CheckUsernameExists 按 username 查询正常,CheckEmailExists 报 no such column: email;闭库会让先执行的用户名检查先失败,够不到此分支), 见 internal/logic/register_dberror_test.go,96.5% → 96.8%。 其余候选经逐行论证确属不可达,按台账口径登记:
registerlogic.go:89(HashPassword):bcrypt 唯一错误 ErrPasswordTooLong (>72 字节),而 ValidatePassword 以 len() 字节计上限 50 < 72,不可达;loginlogic.go:57(GenerateToken):HS256 生成恒成功(既定 ledger);updateuserinfologic.go:85(更新后二次 FindOne 失败):关库/只读库故障会先在 Update 处返回(同一故障对称命中写、读两步),单请求内无法注入 「写成功、随后读失败」;updateuserinfologic.go:97(昵称回退 else):logic 的 if/else 两臂均构造 Valid=true 的 Nickname,更新后的行 nickname 不可能为 NULL;model/usermodel.go:77(LastInsertId):mattn/go-sqlite3 的 SQLiteResult.LastInsertId 恒返回 (id, nil),无错误路径。 巡检第三轮(同日):原沿袭登记的 svc 两个 panic 分支复核后实际可达,已补测 (internal/svc/servicecontext_test.go)——servicecontext.go:44:畸形 MySQL DSN 使 sql.Open 阶段 ParseDSN 即失败 (如user:pw@tcp(127.0.0.1:3306)x,tcp 地址后缺 "/dbname" 分隔);servicecontext.go:63:只读库(file:...?mode=ro)且 users 表不存在时 Ping 成功、CreateUsersTable 的 SQLite/MySQL 双格式建表均失败。 96.8% → 97.2%。handlerhttpx.ErrorCtx空错分支 ×5 经逐一核对:对应 logic (register/update/getuserinfo/getuserrecommendations/user)全部零非 nil error 返回(envelope 恒}, nil;login 的 logic 会返回真实错误且其 handler 分支已覆盖), 维持 ledger。 剩余未覆盖 13 块 = 已论证 5 块(register:89、login:57、update:85/97、usermodel:77)
- main(2)、handler ×5、utils HS256 GenerateToken 错误(1)。
user-service 仓储接口化收口轮(次日第九轮,97.2% → 97.7%):上段登记的 update:85(更新后二次回读失败 → 500)与 update:97(昵称 NULL 回退 else) 复核后确认——不可达的根源是 ServiceContext 持有具体指针 *model.UserModel,对称 DB 故障(关库/只读库)在先行语句即返回, 单请求内无法注入「写成功、随后读失败」与「回读 NULL 昵称」, 而非分支本身死代码(生产瞬时故障与历史 NULL 行均可达)。 处置沿用 content-service 第六轮先例:model 包新增 UserStore 接口(含 var _ UserStore = (*UserModel)(nil) 编译期断言),ServiceContext.UserModel 字段改接口类型;生产装配不变(NewServiceContext 仍注入同一具体模型, logic 调用签名不变;既有测试的复合字面量构造同步兼容)。 据此以内嵌真实仓储 + 仅覆写 FindOne 的故障实现补测 2 例 (internal/logic/updateuser_refetch_test.go):二次回读报错 → 500 「更新成功,但获取信息失败」;二次回读昵称 NULL → 回退分支取 username。 剩余 11 块维持不可达登记:register:89(bcrypt ErrPasswordTooLong 需 >72 字节,校验上限 50)、login:57(HS256 签发恒成功)、utils:69(jwt/v5 在 Parse 阶段已完成 exp 与签名校验,err==nil 蕴含 token.Valid 的死代码, 全仓同款既有登记;原与 login:57 合并写作「HS256 签发恒成功」系误标, 签发与该 Parse 侧分支无关,已更正)、 usermodel:90(原 :77,mattn/go-sqlite3 LastInsertId 恒 (id, nil);行号随接口 插入平移)+ main ×2、handler envelope ×5。
user-service 覆盖率见顶验证轮(次日第十轮,97.7% → 97.7%):复测全模块覆盖率快照(台账口径), 仍为 97.7%。本轮新增 1 例单测:
model/usermodel_test.go追加 Create 分支(关库触发 Exec 错误 → 500 "创建用户失败"), 覆盖usermodel.go:85Exec 错误分支; 复核其余候选均为既定不可达集,维持台账登记:
usermodel.go:90LastInsertId —— mattn/go-sqlite3 恒返回 (id, nil);registerlogic.go:89HashPassword —— bcrypt ErrPasswordTooLong 需 >72 字节,校验上限 50;loginlogic.go:57GenerateToken —— HS256 签发既定恒成功(签发侧;utils:69 为 Parse 侧死代码,成因不同,见第 5 条与第九轮段落更正);handler/*envelope 空错分支 ×5 —— 对应 logic 恒返回ApiResponse, nil(login 除外,其 handler 分支已 100%);utils/auth.go:69ParseToken "无效的令牌" —— jwt/v5 Parse 阶段已完成 exp/签名校验, err==nil 蕴含 token.Valid;user.go:23main stderr+exit —— 全仓既定不可达约定。 剩余 11 块即上述全量不可达集。手写面最低可推进项现为 user-service-rpc(91.7%,protoc 缺口维持不碰)。
user-service 覆盖率独立复核轮(次日第十一轮,97.7% → 97.7%):任务派发读数 97.2%, 复测时模块已由任务间落地的两笔提交推进至 97.7%(0be40ed UserStore 接口化 + refetch 故障注入、191eda0 关库 Exec 分支 + 见顶登记),按铁律不重写、在其上叠加。本轮逐块 读源码对剩余 11 块 / 14 句(总 601 句)做独立复核——不照抄台账结论,全部 11 块证实 为真不可达而非「不可注入」误判(content-service 第六轮教训):
user.go:24/25main stderr+exit ×2 —— 全仓既定约定;run() 错误路径已单独覆盖, user.go 其余块 100%;handler/*envelope 空错分支 ×5 —— 逐行核对 getuserinfo / getuserrecommendations / register / updateuserinfo / user 五个 logic,全部仅return &types.ApiResponse{...}, nil, 结构上无非 nil error 出口(login 的 logic 真返错误,其 handler 分支已 100%);loginlogic.go:57GenerateToken 失败 —— 读 jwt/v5 v5.3.0hmac.go源码:Sign仅当 key 非[]byte时返回ErrInvalidKeyType,本包jwtSecret恒为[]byte(空 secret 亦然),无空 key 拒绝分支;SHA256.Available()由既有签发/登录绿测反证;registerlogic.go:89HashPassword 失败 —— bcryptErrPasswordTooLong需 >72 字节,ValidatePassword上限 50 字节且先于加密执行(registerlogic.go:47 → :87 顺序核对); 错误路径本身由utils.TestHashPassword_TooLong覆盖;usermodel.go:90(原 :77,随接口插入平移)LastInsertId 失败 —— 补强前序登记的 单驱动论证:本仓为双驱动装配(servicecontext.go:37sqlite3 /:40mysql), 逐一核对 mattn/go-sqlite3 v1.14.32return r.id, nil、go-sql-driver/mysql v1.9.3result.goreturn res.insertIds[...], nil,均字面恒 nil error;database/sqldriverResult.LastInsertId仅加锁转发;本仓无 sqlmock 依赖,不为其引入(不硬造);utils/auth.go:69ParseToken「无效的令牌」—— jwt/v5ParseWithClaimserr==nil 时 claims 类型恒为*JWTClaims且token.Valid必真,断言后置分支不可入。 另更正前序记录:191eda0提交信息「372 stmts」系与 game-catalog 总句数混淆, user-service 实测 601 句(587/601 = 97.7%,未覆盖 14 句 / 11 块)。快照已刷新 (services/cov_user-service.out,本地不入库)。升至 98.0% 需再盖 2 句,而 14 句已全证死, 97.7% 为该模块硬顶;手写面最低可推进项维持 user-service-rpc(91.7%,protoc 缺口不碰)。
user-service login 信封化轮(2026-10-11 第十二轮,97.7% → 97.8% coverpkg 口径): beb726f login 迁移业务信封 + internal/httperr 新包(error→HTTP 状态映射,40 句全绿), logic 总量 601 → 632 句。新增 1 例:
internal/logic/register_dberror_test.go追加 UsernameCheckError(users 表缺 username 列, 首个存在性检查CheckUsernameExistsScan 即报 no such column → 500 信封),盖掉usermodel.go:221(该块在默认./...口径下因跨包不计入而显未盖,coverpkg 口径证实已达)。 httperr 包四服务同轮补齐 Error() 非 nil 臂 / BadRequest / community ErrPermissionDenied 哨兵分支,全部 100%(67f6292)。剩余不可达集维持 11 块原论证,行号仅平移:user.go:26/27main、handler envelope ×5(login 不在其列,其 handler 分支已 100%)、loginlogic.go:57(GenerateToken HS256 恒成功)、registerlogic.go:89(bcrypt >72 字节)、usermodel.go:90(LastInsertId 恒 nil)、utils/auth.go:69(ParseToken 死分支)。
content-service 收口轮(同日第四轮,88.8% → 94.2%):已补测试——
- logic 层:五个 401 分支(createcomment/createguide/deletecomment/updateguide/ publishguide,ctx 缺 user_id)、createcomment 的目标攻略缺失 404 与昵称回退、 createguide 的游戏拉取失败回退 gameId 标题与昵称回退、likecomment 的评论缺失 404 与指向已删攻略的二级 404(种子孤儿评论触达)、listcomments/listguides 分页归一化与草稿攻略评论 404、updateguide 的 cover_image/tags 增量字段、 mapper 两个 nil 防御分支(
internal/logic/errorbranches_test.go); - middleware:非 Bearer 头 401、空 Bearer token 401、/ping 与 /health 放行、 GET 非 guides/comments 前缀的兜底 return;
- client:构造参数归一化(负值/nil 注入项)、退避期间 ctx 取消、调用前 ctx 已取消、截断响应体(hijack 写半截 Content-Length)、gameId 含控制字符的 构造请求失败、statusForInternalMetrics/resultLabel 的 unknown/canceled 映射、 breaker SetOnChange 回调与 half-open 双探测(白盒置态)、非法状态名的 unknown 兜底、退避封顶的循环内与循环后两个分支(
client/extras_test.go)。
content-service 剩余 43 块全部按台账口径登记(不硬造用例):
- 仓储为纯内存 map(构造时一次性加载,写入不落盘),
Get/写方法各自只有 单一哨兵错误——「get failed → 500」分支(getguide:38、createcomment:50、 likecomment:38/55、likeguide:38、deletecomment:46、updateguide:46、 publishguide:46)与「Get 成功后写操作再 404/500」分支(likecomment:71/77、 likeguide:53/59、deletecomment:61/67、publishguide:61/67、updateguide:79/85、 createcomment:81、createguide:70)均不可达; - handler
httpx.ErrorCtx空错分支 ×10:十个 logic 恒返回 envelope+nil error (与 user-service 同款 envelope 模式,逐一核对); content.gomain stderr+exit(全仓既定);utils/auth.go:46/51:jwt/v5 在 Parse 阶段已完成 exp 与签名校验, err==nil 蕴含 token.Valid,两分支为死代码;client/gamecatalog.go:157(重试循环编译完整性兜底,源码注释自述)、gamecatalog.go:176与metrics.go:90(错误类别 switch 穷举后的 default)。
content-service 仓储接口化收口轮(次日第六轮,94.2% → 98.2%):原登记为 不可达的「仓储 Get/写失败 → 500」与「Get 成功后写再 404/500」两类分支(约 41 语句)复核后确认——不可达的根源是 ServiceContext 持有具体指针类型 *model.GuideRepository / *model.CommentRepository,故障无法注入,而非分支 本身死代码。处置:model 包新增 GuideStore / CommentStore 接口(含 var _ XxxStore = (*XxxRepository)(nil) 编译期断言),ServiceContext 两个字段 改为接口类型;生产装配不变(NewServiceContext 仍注入同一具体仓储,行为 零变化),logic 层方法调用签名不变。据此以「内嵌真实仓储 + 仅覆写指定方法 注入错误」的故障仓储补测 19 例(internal/logic/repoerror_test.go):getguide/ createguide/updateguide/publishguide/likeguide 的 Get 500 与写 404/写 500, createcomment 的目标攻略 Get 500 与评论落库 500,deletecomment/likecomment 的评论 Get 500、二级攻略 Get 500、写 404 与写 500,以及 updateguide 此前 漏测的 Summary 增量字段。剩余 18 块维持不可达登记:
- handler
httpx.ErrorCtx空错分支 ×10(十个 logic 恒 envelope+nil error); content.gomain stderr+exit ×2;utils/auth.go:46/51(jwt/v5 Parse 阶段已完成 exp 与签名校验的死代码);client/gamecatalog.go:157、gamecatalog.go:176、metrics.go:90(编译完整性兜底与穷举 switch default,重试次数归一化/错误类别枚举为 构造器不变量,生产不可达)。
game-catalog 收口轮(次日第七轮,95.7% → 97.6%):按台账口径重跑快照 (372 语句,原 16 未覆盖语句 / 12 块),逐块分诊后 4 块补测、8 块登记。 沿用 content-service 第六轮的仓储接口化先例——ServiceContext 原持具体指针 *model.GameRepository,故障无法注入:model 包新增 GameStore 接口(含 var _ GameStore = (*GameRepository)(nil) 编译期断言),字段改接口类型, 生产装配与 logic 调用签名不变。已补测试——
- svc 层:相对路径数据源走
filepath.Clean归一化分支,文件不存在时回落 内置种子(internal/svc/servicecontext_test.go); - logic 层故障注入 ×2:getgamedetail 仓储 Get 非哨兵错误 → 500 查询失败、 creategame 校验通过后落库失败 → 500 创建失败 (
internal/logic/repoerror_test.go,同包访问*apiError断言状态码); - handler 层:请求上下文缺路由变量 id 时
httpx.Parse失败走httpx.ErrorCtx分支 → 400(internal/handler/getgamedetail_parse_test.go, go-zero 对缺失 path 必填字段报错;真服务路由恒有 pathvar,故仅直调注入)。
game-catalog 剩余 8 块(372 语句中 9 句)登记不可达(不硬造用例):
game.gomain stderr+exit ×2(全仓既定;run() 错误路径与其他服务一致 已有单测);- handler
httpx.ErrorCtx空错分支 ×3:listgames/getfeaturedgames/ getrecommendations 三个 logic 恒return &types.XxxResponse{...}, nil无非 nil error(逐一核对;getgamedetail 的 Parse 分支本轮已补); model/gamerepository.go:290:内嵌种子 JSON 编译期字面量,defaultSeedGames的 unmarshal panic 生产不可达;utils/auth.go:43/47:jwt/v5 Parse 阶段已完成 exp 与签名校验, err==nil 蕴含 token.Valid,两分支为死代码(全仓同款既有登记)。
api-gateway 收口轮(次日第八轮,96.3% → 98.2%):按台账口径重跑快照 (原 10 未覆盖语句 / 8 块),逐块分诊后 4 块补测、4 块登记。已补测试——
- integration 层:两个 client 构造器不校验 baseURL,配置值携带控制字符时
http.NewRequestWithContext内部url.Parse失败,GetFeatured / Login / GetRecommendations 的构造请求错误分支 ×3 由此触达(不经网络,internal/integration/client_malformed_url_test.go); - proxy 层:
serveHTTP的请求构造错误分支 → 502 bad gateway——真 http 服务器在协议解析层即拒绝非法方法 token,handler 永远收不到,故白盒直调 注入敌意方法(与既有TestCopyHeaderDeletesHopByHop同款直测口径,internal/proxy/upstream_badrequest_test.go)。
api-gateway 剩余 4 块(5 句)登记不可达(不硬造用例):
gateway.gomain stderr+exit ×2(全仓既定);internal/integration/user_client.go:52:json.Marshal(LoginPayload)仅两字符串字段,Marshal 对任意字符串(含非法 UTF-8 会替换而非报错) 恒成功,分支死代码;internal/proxy/upstream.go:117:Ping的构造请求错误分支——baseURL 在NewUpstream已通过 Parse 与 scheme/host 校验,Parse(String()+"/health")恒成功,构造器不变量。
风险登记(仅登记不动手):2026-10-01 push 响应提示 GitHub 对默认分支报 164 个 dependabot 告警(13 critical / 72 high / 47 moderate / 32 low), 2026-10-02 push 复测为 168 个(13 critical / 74 high / 49 moderate / 32 low, 波动来自依赖图刷新,非本仓改动引入——两个 DB 切片新增 mysql/sqlite3 驱动 各带少量间接依赖), 详见仓库 security/dependabot 页;按任务约束本轮不处置。
community 收口轮(同日第五轮,91.5% → 99.2%):按台账口径重跑快照确认 community 为手写面最大缺口(77 个未覆盖块),逐块分诊后 70 块补测、7 块登记。 已补测试——
- model 层(
internal/model/errorbranches_test.go):persist 五个故障分支 (无目录前缀直返 nil、空文件、父路径为普通文件的 MkdirAll 失败、chan 不可 序列化的 marshal 失败、临时路径被目录占用的 WriteFile 失败);follow 仓储 种子 JSON 装载的两组填充循环、重复关注/无关系集/目标缺失的六个 false 返回、 保存与列举时 ≥2 元素集合的排序比较器;topic 仓储空数组种子回落默认话题、 null 元素在建索引与列表跳过、Create 落盘失败、List 的 offset/limit/越界 三处钳制、不存在话题的两种计数自增哨兵错误;post 仓储空数组种子、null 元素与状态/类型过滤的三个 continue、列表三处钳制、Hot 的 null 跳过与 limit<=0 回落、Update 的 Images 增量字段及 Update/Create/Like/Share 的 落盘失败四分支、hotScore 的 CreatedAt 解析失败与 decay=4 / decay=8 分桶; - logic 层:topic 包分页钳制 ×3、GetTopic 空请求 400、GetFollowingTopics 的 401 与已删话题 continue、follow/unfollow topic 的 401+400、CreateTopic 的 401/空请求/空名称/落盘失败(
internal/logic/topic/errorbranches_test.go); follow 包 FollowUser/UnfollowUser 的 401、user_id 缺失 400、自取关 400 (internal/logic/follow/errorbranches_test.go);post 包 CreatePost 的 落盘失败(internal/logic/post/create_save_error_test.go); - handler 层:逻辑可报错的三个 handler(create_topic/unfollow_user/ get_following_topics)在请求上下文缺 user_id 时 401 走
httpx.ErrorCtx分支(internal/handler/errorbranch_test.go)。
community 剩余 7 块全部按台账口径登记(不硬造用例):
community.go:24-27(main 的 run 错误 stderr+exit 两块,全仓既定;run() 自身的错误返回已由community_test.go的 LoadConfigError 覆盖);- handler
httpx.ErrorCtx空错分支 ×3:get_topics/get_posts/get_hot_posts 三个 logic 无任何非 nil error 返回(纯查询 + 分页钳制,逐一核对); utils/auth.go:40/44:jwt/v5 在 Parse 阶段已完成 exp 与签名校验, err==nil 蕴含 token.Valid,两分支为死代码(与 user-service/content-service 同款既有登记)。
game-catalog 数据库切片轮(2026-10-02,97.6% → 97.5%,功能切片):「下一步」 第 2 项第二切片落地——JSON 文件仓迁 SQLite/MySQL 双驱动 GameModel + 进程内读 缓存(设计决策见「数据库集成 → 4」),非覆盖率专项轮;覆盖率按同口径重测并登记。
- 语义保持:
GameStore五方法签名不变,logic 层与第七轮的repoerror_test.go故障注入测试零改动通过;缓存值副本隔离、无负缓存、Create 统一失效点, 与 user-service 切片同款; - 新增测试:模型层(SeedIfEmpty 填充与空表跳过、缓存关库命中、值副本隔离、 文件库持久化重开、过滤/排序/分页钳制、推荐题材池与轮转确定性、关库错误 传播(列表读静默零值、单条读写显式报错)、Seed 主键冲突传播、数组列损坏 JSON ×3 列与空串列与数值列脏数据、同分 tie-break、offset>0 且 limit<0 的 窗口倒挂防御钳制);svc 层(SQLite 文件库建表种子、内存库带参 DSN 跳过 目录创建、Open 急切解析 panic、Ping 不可达 panic——实测
user:pw@tcp(127.0.0.1:3306)x会在 Open 期 ParseDSN 即失败(缺 "/dbname" 分隔),并不触达 Ping,前序 user-service 巡检第三轮对该 DSN 的描述据此 修正理解:Open-panic 与 Ping-panic 是两个分支,各自需不同 DSN 形态); handler 层(列表类 querylimit=abc类型不匹配 →httpx.Parse失败 400 ×3——字段全 optional/default 的正常路径 Parse 恒成功,类型不匹配是唯一 可达 Parse 失败入口,与 getgamedetail 缺 pathvar 同口径); - 删除:
model/gamerepository*.go、data/games.json。第七轮台账所引model/gamerepository.go:290(defaultSeedGames panic)现平移至model/gamemodel.go:458,登记不变; - 剩余缺口登记(DB 切片后重测,全量 11 块 / 12 句,均不可达或外部依赖口径; 头条原记「15 块 / 16 句」与下方枚举不符,独立审计轮更正为 11 块 / 12 句, 枚举与登记本身不变):
game.gomain stderr+exit ×2(全仓既定);- handler
httpx.ErrorCtx逻辑错误分支 ×3:listgames/getfeaturedgames/ getrecommendations 三个 logic 恒 envelope+nil error(逐一核对,与第七轮 登记同款;getgamedetail/creategame 的对应分支既有覆盖); svc/servicecontext.go建表 panic 与种子 panic ×2 —— SQLite 路径 Ping 通过后建表恒成,MySQL 在线但建表/种子失败需真实 MySQL 故障注入, CI(SQLite-only)口径不可达;model/gamemodel.goall 的 rows.Err —— 需驱动级遍历中断注入;model/gamemodel.go:458defaultSeedGames panic —— 编译期内嵌常量;utils/auth.go:43/47jwt/v5 死分支(全仓同款既有登记)。
content-service 数据库切片轮(2026-10-02,98.2% → 97.2%,功能切片): 「下一步」第 2 项第三切片落地——Guide/Comment 双 JSON 文件仓迁 SQLite/MySQL 双驱动 GuideModel/CommentModel + 进程内读缓存(设计决策见「数据库集成 → 4」), 非覆盖率专项轮;覆盖率按同口径重测并登记。
- 语义保持:
GuideStore七方法 /CommentStore五方法签名不变,第六轮repoerror_test.go故障注入测试改嵌GuideStore/CommentStore接口后 零行为改动通过;缓存值副本隔离(Guide 含 Tags 切片深拷)、无负缓存、 写路径统一失效点,与前两切片同款; - id 语义:表主键 AUTOINCREMENT/AUTO_INCREMENT,
Seed显式保留 id (自增从最大 id 续),Create走 LastInsertId——「删除后 id 不复用」 原断言保持;ORDER BY id与原插入序严格一致(自增 id 恒单调,零漂移); - 新增测试:模型层(SeedIfEmpty 填充/跳过、显式 id 保留与非正 id 跳过、 持久化重开幂等建表、过滤/分页钳制全量用例(含 page=0 回归)、缓存关库 命中、值副本隔离、关库错误传播(列表读静默零值、单条读写显式报错)、 Seed 主键冲突传播、tags 列脏数据三态(空串/坏 JSON/数值元素)、数值列 文本脏行、并发读写、UPDATE 触发器 RAISE(ABORT) 写失败传播);svc 层 (SQLite 建库建表种子、
?mode=ro只读库四条启动 panic——预建库逐项 裁剪表与数据可精确分色到建表×2/种子×2,Open/Ping panic 两分支沿用 game-catalog 口径、内存库带参 DSN);handler/logic 层装载改临时 SQLite (JSON 种子常量反序列化后 Seed,原行为断言不变); - 删除:
model/guiderepository*.go、model/commentrepository*.go与data/guides.json、data/comments.json装载路径。第六轮台账所引旧文件 删除,对应结论平移(handler 信封死分支 ×10 与 jwt/v5 死分支登记不变); - 部署:Dockerfile 改 CGO 构建 + debian:bookworm-slim(同 game-catalog),
DATASOURCE默认file:/app/data/content.db; - 剩余缺口登记(DB 切片后重测,全量 30 块,均不可达/外部依赖/既有登记口径):
content.gomain stderr+exit ×2(全仓既定);- handler
httpx.ErrorCtx逻辑错误分支 ×10(第六轮登记:十个 logic 恒 envelope+nil error,本轮逐一核对不变); clientGetGameById 尾块 / statusForInternalMetrics / resultLabel ×3 (第六轮登记,client 包测试未动,与切片前一致);utils/auth.gojwt/v5 Parse 侧死分支 ×2(全仓同款既有登记);model驱动不可达 ×11:LastInsertId 失败 ×2(成功 INSERT 后驱动恒成功)、 RowsAffected 失败 ×5(Publish/Like/IncrementViews/Delete 与 Like), Like 回读点赞数失败 ×2(UPDATE 成功后同连接 SELECT 恒成功), all 的 rows.Err ×2(需驱动级遍历中断注入,同 game-catalog 口径);model防御死分支 ×2:List 循环内 nil-guide/nil-comment continue (all() 恒产非 nil 元素)。
community 数据库切片轮(2026-10-02,99.2% → 98.3%,功能切片): 「下一步」第 2 项末切片落地——Post/Topic/Follow 三 JSON 文件仓迁 SQLite/MySQL 双驱动 PostModel/TopicModel/FollowModel + 进程内读缓存(设计决策见 「数据库集成 → 4」),非覆盖率专项轮;覆盖率按同口径重测并登记。
- 语义保持:三 Store 接口签名不变,logic 层零改动;旧
.tmp目录占位式 落盘失败注入(topic/create 两处)改failingTopicStore/failingPostStore接口故障注入(ServiceContext 字段已改接口类型,测试直接换实现), 断言语义不变;FollowRepo.FollowTopic(7, 99999)直写测试照旧可用; - id 与软删语义:
Seed显式保留 id、Create走 LastInsertId;posts 增status列表达软删(Get/allVisible/Like/Share/Update/Delete对已删行统一返回 ErrPostNotFound,List/Hot不再可见);ORDER BY id与原插入序严格一致(自增 id 恒单调,零漂移); - 新增测试:模型层(SeedIfEmpty 填充/跳过、nil 与非正 id 种子跳过、 关键词大小写不敏感与官方过滤、分页钳制、计数增减下限钳 0、缺失话题 增减返回哨兵、缓存关库命中与写后失效、值副本隔离、并发点赞恰好一次 生效、持久化重开、关注幂等 bool 语义(重复 false/缺失 false/影响行数)、 两类目标 id 互不串扰、图像/标签数组列三态(空串/好图坏签/坏 JSON)、 数值列文本脏行 ×2、Hot 排序(置顶优先 + draft 不上榜 + limit 钳制 + IsHot 仅作用返回副本)、hotScore 四分桶与非法时间戳退化、UPDATE 触发器 RAISE(ABORT) 写失败传播 ×2、AFTER 触发器删行致二次读取失败 ×2); svc 层(SQLite 建库建表种子、
?mode=ro只读库五条启动 panic——预建库 逐项裁剪表与数据精确分色到建表×3/种子×2、Open/Ping panic 两分支、 内存库两种 DSN 形态、带参 DSN 目录创建);handler/logic 层装载改临时 SQLite(原行为断言不变); - 删除:
model/{post,topic,follow}_repository*.go、model/persist.go、model/errorbranches_test.go与internal/svc/data/占位文件。原 errorbranches 台账所引文件级分支已平移(List 权限/可见性断言在 postmodel_test 重生); - 部署:Dockerfile 改 CGO 构建 + debian:bookworm-slim(同前两切片),
DATASOURCE默认file:/app/data/community.db;etc/community-api.yaml 改MySQL.DataSource块; - 剩余缺口登记(DB 切片后重测,全量 15 块,均不可达/既有登记口径):
community.gomain stderr+exit ×2(全仓既定);- handler
httpx.ErrorCtx逻辑错误分支 ×3:getposts/gethotposts/ gettopics 三个 logic 恒 envelope+nil error(List 系仓储方法无错误 返回,逐一核对;其余端点的对应分支既有覆盖); utils/auth.gojwt/v5 Parse 侧死分支 ×2(全仓同款既有登记: ParseWithClaims 对过期/无效令牌先行报错,其后的手动校验恒不可达);model驱动不可达 ×8:LastInsertId 失败 ×2(topic/post Create, 成功 INSERT 后驱动恒成功)、RowsAffected 失败 ×3(topic 计数更新、 follow 幂等写/删),rows.Err ×3(topic List / post allVisible / follow List 的驱动级遍历中断,同前两切片口径)。
api-gateway 覆盖率重测轮(2026-10-05,98.2% → 95.8% → 98.6%): M3 第四切片(分享卡)与 BFF 一/二阶段(缓冲反代、/home/feed 聚合)是功能 切片,落地后未重测覆盖率,本轮巡检快照读数回落至 95.8%。按台账口径逐块 分诊:18 块中 12 块补测、6 块登记。已补测试——
- integration 层:community/content client 的构造请求错误分支 ×3 (ListHotPosts/ListTopics/ListGuides 畸形 baseURL,扩展
client_malformed_url_test.go——第八轮只覆盖了当时的三个方法)、 ListHotPosts 连接失败与非法 JSON、ListTopics 非 200 与非法 JSON、 ListGuides 非法 JSON(community_client_test.go、content_client_test.go); - logic 层:topics 组降级分支——既有
TestHomeFeedLogicDegradesPerGroup为证明「健康组不受牵连」固定 topics 成功,新增对偶用例TestHomeFeedLogicDegradesTopics让 topics 失败;summarizeForFeed短正文/空正文分支(TestSummarizeForFeed,超长截断分支原已覆盖); - handler 层:分享卡
summary的超长截断分支 (sharelink_summary_test.go,120 rune + 省略号不劈 CJK); - proxy 层:
serveHTTP请求体读取失败分支 → 502——真请求的 Body 由 http 层保证可读,白盒直调注入errBody(与第八轮非法方法注入同款口径,upstream_body_test.go)。
api-gateway 剩余 6 块(5 句 + 1 句)登记不可达(不硬造用例):
gateway.gomain stderr+exit ×2(全仓既定);internal/integration/user_client.go:52:json.Marshal(LoginPayload)恒成功死代码(第八轮既有登记,行号未变);internal/proxy/upstream.go:141:Ping构造请求错误分支(第八轮登记 于 :117,BFF 缓冲转发改动使代码位移,判据不变——构造器不变量);internal/handler/homefeedhandler.go:24:HomeFeed恒resp, nil(唯一 return 在函数尾),错误分支不可达——与 community 三个 get handler 的httpx.ErrorCtx登记同款口径;internal/handler/sharelinkhandler.go:106:tmpl.Execute失败分支—— 模板固定在闭包内 Must 解析、数据为定型 struct,无注入面 (源码注释已标「近乎不可能」)。
同轮快照其余读数(台账口径):community 95.8%(较 DB 切片轮 98.3% 回落, M3 关注流/我的点赞切片新增代码未重测,列为下一轮)、content-service 97.2%、 game-catalog 97.5%、user-service 97.7%(见顶持平)、user-service-rpc 手写面 91.7% 持平(原始 77.2% 为 pb.go 管线口径,按无捏造用例约定排除, 波动来自 protoc 生成面)。
api-gateway 缺口补齐轮(2026-10-11,96.6% → 97.8% coverpkg 口径): login 聚合面删除(第十二轮)后读数自 98.6% 回落,巡检逐块分诊补齐:
internal/upload:ParseToken 非 HMAC 算法混淆防护(RSA 签发的令牌,crypto/rsa现场生成密钥)、SaveImage 的 MkdirAll 失败(父路径被普通 文件占据)与只读目录 OpenFile 失败(chmod 0555,测试末复原便于 TempDir 清理);internal/handler/uploadhandler_test.go:静态托管补「白名单形态但盘上 不存在」的文件名(命中 os.Open 失败 404 臂,原有两条用例均止步 SafeResolve 白名单拒绝);internal/integration/user_client.go:readError 空响应体回落臂 (上游 503 无正文 → "upstream error" 占位);internal/datapanel/routes_test.go(新建,镜像 community/games/users/ 四个同款 panic 测试):非法上游地址 → NewUpstream 失败 → panic。 剩余零块均为既有登记集,维持不硬造:gateway.go:26main stderr+exit;homefeedhandler.go:13HomeFeedHandler(HomeFeed 恒 resp,nil)、sharelinkhandler.go:75ShareLinkHandler(tmpl.Execute 无注入面)、upstream.go:138Ping(构造器不变量);upload 新增登记 ×3—— ParseToken「无效的令牌」(jwt/v5 err==nil 蕴含 token.Valid,与 user-service utils/auth.go:69 同族)、SaveImage file.Seek 失败 (multipart.File 为 buffer/section 读取器,无失败注入面)、rand.Read 与 io.Copy 失败(crypto/rand 与 buffer 读取器恒成功族)。
data-panel 缺口清零轮(2026-10-11,96.9% → 98.2% coverpkg 口径): 接上轮 1f3c1d4(94.4% → 96.9%)后逐块分诊,五块可达补齐:
playerstatsmodel.goSeedIfEmpty 的 count>0 跳过臂——预置一行合法 数据的完整表,SeedIfEmpty直接返回 nil 且不再插行(生产库已迁移、 容器重启不重灌的语义,此前无此路径测试);- ListGames 列表查询错误臂——关库会让先执行的 COUNT 先失败,改预置 仅含
(id, user_id)两列的表:COUNT 正常、SELECT 具名列报no such column(缺列注入的同一套手法的列表方向);行扫描错误臂—— 列齐全但 matches 存文本,COUNT 与 SELECT 均成功、Scan 到 int64 失败; - logic 层 Record 错误臂(
failingStatsStore增 Record 投毒方法)与 ListGameStats 的 nil 列表归一臂(nilGamesStatsStore返回 nil 无错; 真实仓储恒返回空切片非 nil,nil 仅出现于降级实现——与 communitynilLikesStore同口径)。 剩余 6 块均为既定登记家族,维持不硬造:data-panel.go:24/25main stderr+exit ×2;playerstatsmodel.go:154内嵌种子 JSON 反序列化 panic (const 常量,结构上不可失败);playerstatsmodel.go:299rows.Err 驱动 级遍历中断族;utils/auth.go:40(!ok || !token.Valid,jwt/v5 在 Parse 阶段已完成校验,与 user-service utils/auth.go:69 同族)与utils/auth.go:44(ExpiresAt 二次检查,jwt/v5 Parse 已校验 exp)。
content-service 缺口清点轮(2026-10-11,97.0% → 97.3% coverpkg 口径): 逐块分诊三处可达补齐:
internal/svc/servicecontext_test.go启动梯子第七级——攻略与评论半边在 只读库上完整通过(表已建 + 种子行已存在,双 SeedIfEmpty 跳过)后收藏表 建表触雷(seedCommentRow辅助函数新增);model/favoritemodel_errors_test.goListByUser 两臂——先执行的 COUNT(1) 只查 guide_favorites,DROP 掉 guides 表后 JOIN 报 no such table 触达列表 查询错误分支(关库注入会让 COUNT 先失败,够不到);脏行臂复用handInsertDirtyGuide(views 列混文本,Scan 到 int64 失败)。 剩余 30 块全量登记(不硬造用例),其中较第六轮登记的增量修正:- handler
httpx.ErrorCtx空错分支 ×12(原登记 ×10 + favorites 两个 handler——UnfavoriteGuide/GetFavoriteStatus 同为 envelope+nil error, 逐一核对;ListFavorites 例外,其 logic 可返错且分支已覆盖); - model 家族细分 ×16:LastInsertId ×2(commentmodel:220 / guidemodel:298, 双驱动字面恒 nil,见 user-service 第十一轮论证);RowsAffected ×5 (commentmodel:235/251、guidemodel:359/375/396,driver result 恒 nil 同族);写后二次 SELECT ×2(commentmodel:258 / guidemodel:382——UPDATE 成功蕴含 likes 列存在且可读,同一方法内无中途故障注入面);rows.Err 驱动级遍历中断 ×3(commentmodel:285 / guidemodel:432 / favoritemodel:163);List nil 元素防御 ×2(commentmodel:300 / guidemodel:447,all() 按行构造永不产 nil);单表达式比较器闭包计数块 ×2(guidemodel:484.35/493.4——编译器内联使计数块恒零,排序行为已由
TestGuideModelList_Sort「newest first」用例断言,为覆盖工件非测试缺口); - 沿第六轮登记:
content.gomain ×2、client/gamecatalog.go:157/176与metrics.go:90编译兜底/default ×3、utils/auth.go:46/51jwt/v5 死 分支 ×2。
game-catalog 启动梯子轮(2026-10-11,97.5% → 97.9% coverpkg 口径): 第七轮登记的 svc 层仅覆盖相对路径归一化与文件缺失回落两条路径,只读 启动梯子(content-service 同款口径)此前未建——本轮补两级: TestNewServiceContext_ReadOnly_CreateGamesTablePanics(已落盘但无 games 表的只读库,建表触雷)与 TestNewServiceContext_ReadOnly_SeedGamesPanics (预建空 games 表的只读库,种子写入触雷)。注:mode=ro 对不存在文件 在打开阶段即失败,故第一级需先落盘一个 marker 表否则够不到建表分支。 剩余 9 块均为第七轮既定登记:main ×2、handler ×3(envelope+nil)、 gamemodel.go:281 rows.Err 族、gamemodel.go:458 内嵌种子 JSON panic、 utils/auth.go:43/47 jwt/v5 死分支 ×2。
community 覆盖率重测轮(2026-10-05,98.3% → 95.8% → 98.2%): 同 api-gateway 轮背景——M3 三切片(关注流/我的点赞/post_likes 关系表与 建表链路)是功能切片,落地后未重测,巡检快照回落。逐块分诊:42 块中 23 块补测、19 块登记(原记「21 块中 15 块补测、6 块登记」系笔误,独立 审计轮据登记明细 15 既定 + 4 新增更正,登记本身不变)。已补测试——
- handler/logic 层:M3 四端点(关注流/已关注用户/我的点赞/点赞帖子) 无鉴权 401 分支,handler
httpx.ErrorCtx与 logicUserFromContext一测双覆盖(m3_errorbranch_test.go,与 errorbranch_test 同款直调); 关注流与我的点赞分页参数非数字 →httpx.Parse400 分支;点赞关系表 缺失的桩注入(nilLikesStore)触达 logic 层 nil 切片归一分支——真实 PostModel 成功路径自身已归一为空切片,nil 仅出现在查询失败降级 (liked_nil_normalize_test.go); - model 层:
ListFollowingUserIds关库 Query 失败与脏行 Scan 失败 (ClosedDB 与 ListScanError 两用例各扩用户方向一条断言);Like在 post_likes 表 DROP 后双方言连败传播(insertLikeRelation → Like);ListLikedPosts关系表缺失降级与关联脏行扫描失败;ListByFollow关库降级、草稿过滤 continue、时间戳比较与同秒 id 兜底(RFC3339 秒级 精度下相邻 Create 必同秒,三种组合一次构造齐); - svc 层:启动 panic 梯子补第六级——全库就绪唯 post_likes 缺失,只读库 CREATE 失败走 SQLite→MySQL 双格式回落仍败(
precreateDB增 withFollows 段位)。
community 剩余 19 块登记不可达(不硬造用例):
- 沿 DB 切片轮既有登记 ×15:
community.gomain stderr+exit ×2;handlerhttpx.ErrorCtx×3(get_posts/get_hot_posts/get_topics 恒 envelope+nil error);utils/auth.gojwt/v5 死分支 ×2;topicmodel LastInsertId / rows.Err / RowsAffected ×3;postmodel Create LastInsertId 与 allVisible rows.Err ×2;followmodel RowsAffected ×2 与 ListFollowingTopicIds rows.Err ×1; - 新增登记 ×4:followmodel
ListFollowingUserIdsrows.Err(M3 新方法, 与已登记的 topic 方向 rows.Err 同族,驱动级遍历中断);postmodel insertLikeRelation 的 INSERT IGNORE 回落成功分支——SQLite 下 INSERT IGNORE 是语法错误恒失败,成功路径仅存在于 MySQL 方言,SQLite 测试面不可达(双方言回落策略的固有盲区);postmodelListLikedPostsrows.Err(同族);postmodel 我的点赞计数 QueryRow Scan 失败——计数与 列表查询同表同源(同一条 JOIN 与 COUNT),「列表成功而计数单独失败」 在 SQLite/MySQL 驱动下无注入面(count() 恒返回单行 int64)。
覆盖率登记独立审计轮(2026-10-05,四模块零块逐一复核):覆盖率巡检 收尾动作——对四个 DB 切片轮模块的不可达登记做独立对账:重新生成快照, 以完整块位置(file:start.end)为键聚合后取零块集,与登记枚举逐块核对, 不照抄登记结论。方法论更正一条:块级聚合若按行号为键,同行多块会被 合并、零块被非零同线块掩盖而漏报——本轮前置巡检对 game-catalog「缺 rows.Err 与 seed panic 两块」、user-service「缺 LastInsertId 一块」的 假象均源于此,精确键重算后消除。逐模块结论:
- content-service:当前 30 零块与 DB 切片轮登记 30 块逐块吻合(main×2 + handler ErrorCtx×10 + client×3 + jwt×2 + model 驱动×11 + model 防御×2), 登记成立;
- game-catalog:当前 11 零块 / 12 句与 DB 切片轮登记枚举六项逐块吻合 (main×2 + handler×3 + svc 建表/种子 panic×2 + gamemodel rows.Err (:281)与 defaultSeedGames panic(:458)×2 + jwt×2)——前次疑似的 两个缺块确认仍在零块集,登记成立;该轮头条「全量 15 块 / 16 句」与其 自身枚举(合计 11 块 / 12 句)不符,已就地更正(枚举与登记不变);
- user-service:当前 11 零块 / 12 句与独立复核轮登记 11 块逐块吻合 (main×2 + handler envelope×5 + loginlogic:57 + registerlogic:89 + usermodel.go:90 LastInsertId + utils:69)——前次疑似缺的 LastInsertId 块确认在零块集,登记成立;该轮头条「14 句」与当前实测 12 句的 2 句差 源于其后观测性提交(669d0ad,六服务 JSON 日志)对语句结构的调整, 块级登记不受影响;
- community:重测轮头条「21 块中 15 块补测、6 块登记」系笔误,已就地 更正为 42 块中 23 块补测、19 块登记(登记明细 15 既定 + 4 新增 = 19 不变)。 四模块登记全部成立,登记面无缺口、无过期项;六模块覆盖维持 gateway 98.6 / community 98.2 / user-service 97.7 / game-catalog 97.5 / content-service 97.2 / user-service-rpc 91.7(手写面)。
handler 面清零冲刺轮(2026-10-11,六模块复测):审计轮后巡检发现两处 handler 层真缺口(非登记不可达、纯属无测试)并补齐:
- community 评论三 handler 此前 10%(无任何 handler 层用例):
cbc46b9补TestCommentEndpoints_Flow全链路——匿名列表 200 / 匿名发评 401 / 发评回评(冗余被回复者昵称)/ 幽灵帖 404 / 越权删评 403 / 三端点 Parse 400 / 幽灵列表 404,三 handler 100%; - moderation/notification 六 handler 的 Parse 与 logic 错误分支:
c2eae09补坏 JSON body 400、?limit=abc400、幽灵帖举报 404、 二次处置外的坏处置 body 400;logic 错误分支以brokenNotificationRepo故障桩整体替换 NotificationRepo 触达(非哨兵错误走 httperr 兜底 400 信封),六 handler 全部 100%; - data-panel
svc.NewServiceContext五级 panic 阶梯(mysql 不可达 / 畸形 DSN / 目录路径 / 只读库建表败 / 缺列种子败)与ensureSQLiteDir三分支 + handler 负值校验三例:1f3c1d4。
同轮六模块复测读数(coverpkg 口径,生成命令见 2026-09-30 段): api-gateway 96.6%(login 聚合面删除后读数回落,见第十二轮)、 user-service 97.8%、game-catalog 97.5%、content-service 97.0%、 data-panel 94.4% → 96.9%(1f3c1d4)、community 94.3% → 96.6% (cbc46b9 + c2eae09)、user-service-rpc 77.2%(生成面口径)/ 91.7%(手写面,protoc 缺口维持不碰)。community 剩余零块均为 重测轮既有登记集(main ×2 / jwt 死分支 ×2 / model 双驱动与 rows.Err 族),handler 面零块清零。
community 通知面零块对账轮(2026-10-11,96.6% → 98.5% coverpkg 口径): 背景——community 的 2026-10-05 重测轮登记(19 块)早于通知系统与内容 审核两切片落地,新增代码(notifications/reports 表模型、svc 尾段建表、 三触发点、举报处置链路)不带覆盖,巡检快照 54 零块中 35 块游离于登记面 之外。本轮以当前零块集为事实源逐块对账:30 块补测关闭,24 块归入登记 (原 19 块块族全部存活 + 新增 5 块,非推翻原登记)。四批增量——
- svc 梯子第七至九级(
dbb0548):启动序列尾段四表(点赞/评论/通知/ 举报,通知表为通知系统新增段)。注意 precreateDB 不含 post_likes 段位 ——点赞表先于评论表,够不到尾段 panic,须先以 precreateTailTables 按 启动顺序补建;通知/举报表无种子步骤,纯建表 panic。外加 AdminSet 预计算臂(审核管理员名单传配置即达); - notificationmodel/reportmodel 列表臂(
69ecd63):两模型同为 「COUNT 先行、具名 SELECT 后行」——关库连 COUNT 都过不去,缺列注入 (仅 user_id/status 列的表)让 COUNT 过、SELECT 报 no such column; is_read/handled_by 存文本的脏行注入打 Scan 臂; - post/comment/wordfilter 臂(
36c0b1c):RemoveByModerator 与 IncrementComments 的 UPDATE 错误臂以只读库注入(关库连首查都过不去, 读成写败才够得到写路径);IncrementComments 的「UPDATE 后二次重读」 错误臂首次采用触发器注入——行上 AFTER UPDATE 触发器把 view_count 污染成文本,UPDATE 本身成功而重读 Scan 必败;commentmodel scanComments 脏行(脏列不得撞 WHERE 过滤列,否则行被过滤根本进不了扫描); wordfilter FirstHit 空词条目 continue(词表直传未经 NormalizeWords 清洗的场景); - logic 错误传播臂 ×20(
3fdd8b8):moderation 五臂(裸 context 直调 UserFromContext 失败 ×2——handler 链路被 Auth 中间件拦截唯直调可达; ListByStatus/Create 桩透传;resolve 方向状态落库失败,dismiss 方向 已有用例);comment 八臂(Create/ListByPost/CountByPost/Delete 四桩 透传、负 offset 钳制、缺评论 id 400、回复通知写失败记日志——被回复者 既非自己也非帖子作者的双通知场景);like_post/follow 通知写失败不中断 本体两臂 + truncateRunes 截断直测;hot 榜有效令牌但关注集合为空回落 匿名臂;notifications nil 请求规整臂。
新增登记 ×5(登记面 19 → 24 块):notificationmodel/reportmodel Create 的 INSERT IGNORE 回落成功臂 ×2(同 insertLikeRelation 族:SQLite 下恒 语法错,成功面仅存在于 MySQL 方言,SQLite 测试面不可达); notificationmodel ListByUser / reportmodel ListByStatus / commentmodel scanComments 的 rows.Err ×3(驱动级遍历中断族)。剩余 24 块零块集逐块 核对全部落既有登记族:main stderr+exit ×2、handler httpx.ErrorCtx ×3 (三列表恒 envelope+nil error)、jwt/v5 死分支 ×2、model 驱动级( LastInsertId/RowsAffected/rows.Err)×14、INSERT IGNORE 回落成功 ×3。
部署
1. Docker 构建
各业务服务目录下均有独立 Dockerfile(user-service-rpc 除外——集群内部按设计独立内网部署,无镜像)。 SQLite 侧服务模式一致(以 services/community/Dockerfile 为例);api-gateway 与 realtime-hub 为 CGO_ENABLED=0 构建 + distroless 运行时: CGO 构建(SQLite 驱动需要)+ debian:bookworm-slim 运行时(含 sqlite3)+ DATASOURCE 环境变量注入数据源(默认本地 file: SQLite,生产注入 MySQL 连接串):
# Dockerfile(摘自 services/community/Dockerfile)
FROM golang:1.26 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /bin/community community.go
FROM debian:bookworm-slim
ENV TZ=Asia/Shanghai
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata sqlite3 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /bin/community /usr/local/bin/community
COPY etc ./etc
RUN mkdir -p /app/data
ENV DATASOURCE=file:/app/data/community.db
EXPOSE 8892
ENTRYPOINT ["/usr/local/bin/community","-f","etc/community-api.yaml"]本地一键拉起全部服务:根目录 docker compose up --build;观测栈(Prometheus/Loki/Grafana) 见 deploy/docker-compose.monitoring.yaml。
2. Kubernetes 部署
候选形态(未落地):当前部署方式为根目录
docker compose up --build, 仓库尚无 K8s 集群与镜像仓库凭据,下例仅供未来上 K8s 时参考。
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: spawn/user-service:latest
ports:
- containerPort: 8888
env:
- name: ETCD_ENDPOINTS
value: "etcd:2379"
---
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
selector:
app: user-service
ports:
- port: 8888
targetPort: 8888
type: ClusterIP最佳实践
1. 错误处理
// 使用 go-zero 的错误处理
if err != nil {
logx.Errorf("处理用户请求失败: %v", err)
return nil, httpx.NewApiError(http.StatusBadRequest, "参数错误")
}2. 日志记录
// 结构化日志
logx.Infow("处理用户请求",
logx.Field("userId", req.UserId),
logx.Field("action", "getUser"),
)3. 限流和熔断
# 配置文件中添加
Name: user-api
Host: 0.0.0.0
Port: 8888
RateLimit: 1000 # 每秒 1000 次请求
Prometheus:
Host: 0.0.0.0
Port: 90914. 单元测试
func TestUserLogic_User(t *testing.T) {
tests := []struct {
name string
req *types.UserRequest
want *types.UserResponse
wantErr bool
}{
{
name: "获取用户信息成功",
req: &types.UserRequest{UserId: 1},
want: &types.UserResponse{
UserId: 1,
Username: "test",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := &UserLogic{}
got, err := l.User(tt.req)
if (err != nil) != tt.wantErr {
t.Errorf("User() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("User() = %v, want %v", got, tt.want)
}
})
}
}监控与指标(Prometheus)
所有 go-zero 服务通过配置文件中的 Prometheus 段暴露 /metrics 端点(go-zero agent, 独立于业务端口监听),输出 Prometheus 文本格式:
| 服务 | 业务端口 | /metrics 端口 |
|---|---|---|
| user-service | 8888 | 9091 |
| game-catalog | 8890 | 9092 |
| content-service | 8891 | 9093 |
| community | 8892 | 9094 |
| api-gateway | 8800 | 9095 |
| data-panel | 8896 | 9097 |
| user-service-rpc | 8080 (gRPC) | 9096 |
# 验证指标端点
curl http://localhost:9093/metrics内置指标(go-zero 自动采集)
rest 服务(rest.MustNewServer 内部调用 ServiceConf.SetUp() 启动 agent,Prometheus 中间件默认开启):
http_server_requests_duration_ms_bucket{path,method,code}— 请求耗时直方图http_server_requests_code_total{path,method,code}— 按状态码统计的请求量
zrpc 服务(经 UnaryPrometheusInterceptor):
rpc_server_requests_duration_ms_bucket{method,code}等
自定义指标(content-service 跨服务调用)
services/content-service/client 对 game-catalog 的跨服务调用(含熔断与重试)导出 (定义见 internal/metrics/metrics.go):
content_game_catalog_client_calls_total{method,status}— 调用次数,status∈ success / timeout / circuit_open / retry_exhausted / parse_errorcontent_game_catalog_client_call_duration_seconds{method,status}— 调用耗时(秒)content_circuit_breaker_state— 熔断器状态:0=closed 1=half-open 2=opencontent_circuit_breaker_transitions_total{from,to}— 熔断状态切换次数
错误率示例(PromQL):
sum(rate(content_game_catalog_client_calls_total{status!="success"}[5m]))
/ sum(rate(content_game_catalog_client_calls_total[5m]))新服务接入监控
在 etc/*.yaml 中增加配置即可(无需改代码):
Prometheus:
Host: 0.0.0.0
Port: <未占用端口>接入观测栈(指标 + 日志)还需两步:deploy/prometheus/prometheus.yml 的 tappi-services job 下加一条带 service 标签的 target;根 docker-compose.yaml 补发布该服务的 /metrics 端口(如 9091:9091)。
设计决策:监控集成与日志收集(「下一步」第 4 项)
2026-10-02 拍板并落地。选型按仓库现状与部署形态(docker compose、 无 K8s、无云厂商依赖,见「CI 流水线」一节结论)取同量级最轻方案。
| 行 | 项 | 选型 | 理由 | 状态 |
|---|---|---|---|---|
| 1 | 指标采集面 | Prometheus 静态抓取(deploy/prometheus/prometheus.yml,tappi-services job 七 target 9091–9097,带 service 标签) | 七服务的 go-zero agent /metrics 端点早已全量暴露(上表端口即约定),指标面只缺「抓取配置」即补齐;静态抓配零服务发现设施,单实例假定下最简 | 已完成 |
| 2 | 日志结构化 | go-zero logx Log.Encoding: json(七个服务 etc/*.yaml 增 Log 块,ServiceName 标识来源,stdout console 模式;realtime-hub 非 go-zero,无 Log 块) | logx 是仓内既有日志栈,JSON 单行天然可机器解析;容器 stdout 即日志流,零代码改动(ServiceConf.Log 为 go-zero 内建配置键) | 已完成 |
| 3 | 集中收集与可视化 | docker compose 轻量观测栈:Prometheus + Promtail + Loki + Grafana(deploy/docker-compose.monitoring.yaml) | 仓内部署形态即 docker compose;Promtail 文件抓取容器 json-file 日志 + Loki 单二进制文件存储,是 ELK(JVM 重)之外同量级最轻组合;Grafana 一处查指标与日志,数据源 provisioning 免手工配置 | 已完成 |
交付物:
deploy/prometheus/prometheus.yml—— 静态抓取七服务/metrics; 目标经host.docker.internal(extra_hosts: host-gateway)指向宿主 已发布端口,宿主机裸跑与根 compose 两种运行方式同样可达 (user-service-rpc 不在根 compose——依赖 etcd 按设计独立内网部署, 宿主机单独启动后可见);deploy/loki/loki-config.yaml—— Loki 单二进制本地模式(TSDB + 文件系统存储 + 单副本 inmemory 环);reject_old_samples: false兼容容器历史日志;deploy/promtail/promtail-config.yaml—— 抓取/var/lib/docker/containers(只读挂载),管道:dockerstage 剥 json-file 包装 → 解析 go-zero JSON 行的level/content→level提升为标签 →content作为输出行;非 JSON 行原样保留;deploy/grafana/provisioning/datasources/datasources.yml—— Prometheus(默认)+ Loki 数据源自动装配;deploy/docker-compose.monitoring.yaml—— 四容器编排 (Prometheus 9090 / Loki 3100 / Grafana 3000 / Promtail 无外部端口), 数据卷持久化 Loki/Grafana/positions;- 根
docker-compose.yaml—— 六服务补发布/metrics端口 (9091–9095、9097,注释「deploy/ 观测栈抓取」); - 七个服务
etc/*.yaml——Log块(ServiceName/Mode: console/Encoding: json/Level: info;realtime-hub 非 go-zero,无Log块)。
启动与验证:
# 1. 起业务面(二选一:宿主机 go run,或根 compose)
docker compose up -d --build
# 2. 起观测面
docker compose -f deploy/docker-compose.monitoring.yaml up -d
# 3. 验证
curl localhost:9090/api/v1/targets # 七 target up
curl localhost:3000 # Grafana(Prometheus/Loki 已装配)
# Grafana Explore 选 Loki,按 {job="docker", level="error"} 过滤服务日志假设与边界:
- 镜像按 tag 固定(prom/prometheus v2.53.0、grafana/loki 与 promtail 3.1.0、grafana/grafana 11.1.0);仓库当前无镜像仓库凭据,不做镜像 发布;
- Promtail 需读
/var/lib/docker/containers:Linux 下运行观测栈的 用户需在 docker 组或以 root 运行(compose 注释已注明); - 追踪(tracing)暂不接线:go-zero 内建 OTel 已具备,当前无跨服务 时延排查需求,待出现再评估 exporter 选型,避免引入未用设施。
认证与授权(JWT)
现状总览(7 个服务)
| 服务 | 鉴权状态 | 实现位置 |
|---|---|---|
| user-service | 签发 + 全局校验 | 签发:utils/auth.go GenerateToken(HS256,默认 7 天);校验:middleware/auth.go 全局挂载(server.Use),白名单 /auth/register、/auth/login、/ping、/health、/from/ 前缀 |
| content-service | 可选校验 | utils/auth.go + middleware/auth.go(GET 公开、写接口需令牌),已有单测 |
| community | 按路由校验 | internal/middleware/auth_middleware.go(rest.Middleware),写路由经 rest.WithMiddlewares 挂载,已有集成测试 |
| api-gateway | 透传 | 转发 Authorization 头到上游(upstream_test.go 覆盖);/upload 本地校验 JWT |
| user-service-rpc | 无需鉴权 | 仅集群内部 gRPC 通信,不直接暴露公网 |
| game-catalog | 按路由校验 | utils/auth.go + middleware/auth.go,POST /games 受保护,4 个 GET 路由保持匿名公开(上一批次补齐) |
| realtime-hub | WS 握手校验 | internal/auth/auth.go(HS256,/ws?token= 查询参数校验,失败回 401 拒绝升级) |
令牌机制
- 算法:HS256 对称签名;user-service 是唯一签发方,其余服务只做校验。
- 载荷:
user_id(int64)、username(string),校验通过后注入请求上下文 keyuser_id/username。 - 过期:签发方
TokenExpire默认 7 天;校验方在ParseToken中强制检查exp。 - 密钥:各服务
etc/*.yaml的Auth.JWTSecret,支持JWT_SECRET环境变量覆盖; 开发环境共享默认值,生产环境必须通过环境变量注入独立密钥。
受保护路由示例(game-catalog)
# 注册账号(服务无内置种子用户,需先注册再登录)
curl -s -X POST http://localhost:8888/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"demo","email":"demo@example.com","password":"***","nickname":"demo"}'
# 登录获取令牌(user-service 签发)
TOKEN=$(curl -s -X POST http://localhost:8888/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"demo","password":"***"}' | jq -r '.data.token')
# 匿名读:公开
curl http://localhost:8890/games
# 匿名写:401
curl -X POST http://localhost:8890/games -d '{}' # {"code":401,"message":"缺少认证令牌"}
# 携带令牌写:通过
curl -X POST http://localhost:8890/games \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Halo","description":"FPS","genres":["FPS"],"platforms":["Xbox"],"release_date":"2001-11-15","developer":"Bungie","publisher":"Microsoft","tags":[],"score":9.5,"cover_image":""}'新服务接入认证
- 复制任一服务的
utils/auth.go(校验工具)到本服务; config.Config增加Auth.JWTSecret(带env=JWT_SECRET),yaml 补Auth段;- 按 go-zero 惯例二选一挂载:
- 全局:
server.Use(middleware.AuthMiddleware(serverCtx))(user-service 模式,配白名单); - 按路由:
rest.WithMiddleware(...)/rest.WithMiddlewares(...)包裹受保护路由(community / game-catalog 模式)。
- 全局:
故障排查
1. 常见问题
- 服务启动失败: 检查配置文件格式和端口占用
- RPC 调用失败: 检查 etcd 连接和服务注册
- 数据库连接失败: 检查连接字符串和权限
2. 调试技巧
# 查看服务日志(go-zero logx 输出到 stdout,JSON 编码;compose 部署时按容器名取)
docker compose logs -f user-service
# 本地 go run 启动的服务,日志直接打印在终端
# 查看服务注册(仅 user-service-rpc 走 etcd 注册模式时相关)
etcdctl get --prefix "" | grep user-rpc
# 测试服务连通性(user-service 未注册 /health,用受保护路由的公开探测路由)
curl -v http://localhost:8888/from/localdevCI 流水线
.github/workflows/ci.yml(GitHub Actions)在 push 到 main 与所有 PR 时执行 两个作业、五项门禁:
- gate 作业(Go,Node/Go 环境随
go-version-file走go.work):- gofmt 检查:
gofmt -l .非空即失败; - 全模块构建:遍历
go work edit -json声明的模块执行go build ./...(新增服务自动纳入,无需改 workflow); - 全模块测试:同列表执行
go test ./... -race -count=1;
- gofmt 检查:
- mobile 作业(前端,pnpm + Node 24):
- web-client:
pnpm web:typecheck(tsc)+pnpm web:lint(eslint); - mobile-app:
pnpm mobile:typecheck(tsc)+pnpm mobile:lint(eslint)。
- web-client:
Go 版本跟随 go.work(actions/setup-go@v7 的 go-version-file),模块间共享构建缓存。 等价的本地验证:
gofmt -l . # 应无输出
for m in $(go work edit -json | jq -r '.Use[].DiskPath'); do
(cd "$m" && go build ./... && go test ./... -race -count=1) || echo "FAIL: $m"
done
# 前端两作业(仓库根目录执行)
pnpm web:typecheck && pnpm web:lint
pnpm mobile:typecheck && pnpm mobile:lintCD(部署流水线)暂未配置:当前无生产部署目标(K8s 集群/镜像仓库凭据),待部署方案确定后补充。
待拍板材料(2026-10-05)
时点快照,当时状态「只备料未动手」。两项其后均已结清:上传后端 2026-10-08 拍板走方案 A 并落地(见下文「状态」「落地记录」); dependabot PR #2 同日按方案 A 合并、后续批次方案 D 代做(见 「结局」补记)。
1. 图片上传后端存储选型
缺口登记在 mobile_plan.md 第 3 条:content-service / community 现无对象存储, 首期图片功能限「选图预览」,上传能力另立后端任务。
现状(以源码核实):
services/下无 multipart/FormFile 处理,上传端点从零起步,没有既有实现要兼容。- 图片字段只在 community 两处:
Posts.Images(JSON 字符串数组)与Topics.CoverImage。content-service 攻略有CoverImage(创建/更新可传); user-service 用户没有头像 字段;移动端头像目前是首字母占位(profile.tsx),web-client 仅在发帖 参数带images?: string[]。字段里存的都是 URL 字符串,指向哪里由后端定。 - 部署形态:docker compose 单机六服务 + web-client nginx,业务服务无数据卷, 无 K8s、无云厂商依赖——与 2026-10-02 监控选型时点的判断一致(见上文 「设计决策:监控集成与日志收集」)。
| 方案 | 成本 | 运维 | 数据边界 |
|---|---|---|---|
| A. 本地盘 + 服务静态托管 | 货币 0(用宿主既有磁盘) | 备份要自己纳入(仓内现无备份机制,SQLite 同处境);扩容即加盘 | 数据不出部署边界 |
| B. MinIO 自托管(compose 加一容器,S3 兼容) | 货币 0 | 多一个常驻容器的升级与监控;换来预签名、分片上传、生命周期策略 | 数据不出部署边界 |
| C. 云对象存储(OSS/S3/R2) | 按量付费(存储量 + 请求次数)+ 密钥管理 | 存储面零运维,CDN 直出 | 数据外发到第三方。默认关:不配凭据启动即拒、开关显式打开才生效,开启需另行拍板 |
| D. 图片存数据库(BLOB) | 货币 0 | 随库备份 | 数据不出边界。库膨胀、读放大,SQLite/MySQL 都不宜,列出来只为排除 |
推荐方案 A:本地盘存储,api-gateway 做统一上传入口。 理由:
- 单机 compose、无云依赖的现状下,A 是同量级最轻方案,与监控选型同一口径 (按部署形态取最轻)。
- 首期要传的只有帖子图、话题封面,加头像也不过 MB 级;B 的高级接口、C 的 CDN 在这个量级用不上。
- 边界干净:图片留在部署内。将来若要外发(C)是独立拍板项,开关默认关, 不随本次选型搭车。
落点(实施细节,随实施单出,不在本材料拍板范围):gateway 加 POST /upload(JWT 之后)校验类型与大小、落 uploads/,返回 /uploads/... URL 写进既有 Images/CoverImage 字段;compose 给 gateway 挂 uploads 卷;业务服务只存 URL 不碰文件,web/mobile 只拿 URL 渲染。
状态:已拍板(2026-10-08 用户拍板):走方案 A——本地盘存储 + api-gateway 统一上传入口,按上文落点开工(POST /upload 挂在 JWT 之后, 校验类型与大小、落 uploads/ 返回 URL 写既有 Images/CoverImage 字段, compose 给 gateway 挂 uploads 卷)。外发方案 C 维持默认关,另立拍板 不随本项搭车。
落地记录:后端 6642132(gateway POST /upload JWT 本地校验 + 512 字节 魔数嗅探白名单 png/jpeg/webp/gif + 10MB 上限 + 随机文件名 O_EXCL 落盘、 GET /uploads/:file 文件名正则白名单防遍历 + ServeContent 回读,compose 挂 uploads 卷;全栈冒烟过:无/坏 token 401、非图 400、超限 413、上传-回读 字节一致)。前端接线同日收尾:web 侧 createPost 透传 images(后端 CreatePostReq.Images 本就接受)、发帖表单选图(png/jpg/webp/gif 最多 4 张、 单张 10MB 提交前本地预检、本地 blob 预览可移除,提交时逐张 /upload 换 URL 再随帖落库)、帖子详情以 resolveImageUrl 把 /uploads/ 相对路径拼 网关来源后渲染;mobile 对等接线(expo-image-picker 系统相册多选 + 同一 /upload helper,compose 缩略图行、post/[id] 网格渲染)。
2. dependabot npm 安全组 PR #2(CI 锁文件红)
PR 是什么:dependabot npm_and_yarn 分组安全更新,分支 dependabot/npm_and_yarn/npm_and_yarn-fd5110c855,manifest 侧 14 项依赖 升级,横跨根与 apps/web-client 两个 workspace(docs、mobile-app 的 manifest 不在改动面)。组内包名单(取自 run 37239958566 标题,标题截断, 全名单以 PR 页面为准):@babel/core、@humanfs/node、baseline-browser-mapping、 brace-expansion、braces、browserslist、decode-uri-component、esbuild、flatted、 js-yaml、minimatch、nanoid、node-forge、picomatch、postcss、react-router (多实例)等——构建链安全补丁为主,react-router 属运行时。仓库没有 .github/dependabot.yml(版本更新从未配置),这类分组来自服务端安全更新 设置,与已合并的 go_modules 组 PR #1 同一来源(152 告警背景)。
为什么红:dependabot 改了 package.json 但没更新 pnpm-lock.yaml,CI 的 pnpm install --frozen-lockfile 报 ERR_PNPM_OUTDATED_LOCKFILE: pnpm-lock.yaml is not up to date with apps/web-client/package.json(run 37239958566,3m4s,completed failure)。 PR 在 git 层 MERGEABLE、与 main 无冲突,红的只有锁文件同步这道校验;main 的锁文件与 manifest 一致(本地装过验证),问题不在 main。
照原样合并的影响面——三条 workflow 的 install 都是 pnpm install --frozen-lockfile,会全部转红:
| workflow | 挂在哪一步 | 后果 |
|---|---|---|
| ci.yml | mobile job(web + mobile 四道门禁的 install) | push 后 CI 红;Go gate job 不受影响 |
| docs.yml | build job 的 install | 文档站构建部署被挡,站点内容冻结 |
| nightly.yml | install | 每日 nightly Release 断供(18:37 UTC cron + 手动触发) |
另:Dependabot 自己的 Update 作业因该 PR 红而持续失败,随本 PR 一并解决。
| 方案 | 做法 | 代价 |
|---|---|---|
| A(推荐) | 拍板合并 PR #2(允许红检查合入)→ 我在 main 跑 pnpm install(钉 pnpm 10.22.0)生成锁文件,跑 pnpm install --frozen-lockfile + web/mobile 四道门禁转绿 → 提交推送 | main 有分钟级红窗口;窗口若撞上 nightly cron,当日 Release 缺一次 |
| B | 放开「只推 main」限制,我在 PR 分支补锁 → CI 转绿后再正常合并 | 零红窗口;突破现行推送约束,需显式授权 |
| C | 关闭 PR #2 等 dependabot 下轮重建 | 重建大概率同样红(锁文件更新能力问题不因重建消失),安全补丁继续敞口 |
| D | 我在 main 代做同款 14 项升级 + 补锁,关掉 PR #2 | 与 dependabot 重复劳动,等于替它改依赖,超出本轮「不动手 dependabot」范围 |
推荐方案 A:红窗口分钟级,修复动作是一次 pnpm install 加一个 commit; 要零窗口就选 B 并显式放开分支推送。
结局(2026-10-07 补记;2026-10-08 用户正式拍板追认:修锁文件 CI 红后 合并,即方案 A):走方案 A。PR #2 已合并(0d2757f),main 补锁 2cda261 后 CI/docs 转绿。两笔后续:
- vite
^7→^8连带@vitejs/plugin-react@5.1.1的 peer 不认 vite 8——pnpm 不卡 peer 冲突所以 CI 门禁没拦到,Docker 镜像的npm ci才 ERESOLVE。9682992顶 plugin-react 6.1.2(peervite ^8.0.0)、双锁同步、Dockerfile 转 node:24-alpine +npm ci可复现安装。镜像实构建已复验(2026-10-07: 本机 Docker Hub 域名遭 DNS 污染,验证真 IP 钉 /etc/hosts 后docker compose build web-client成功、容器冒烟 HTTP 200;npm ci --dry-run与 pnpm 全门禁此前已绿)。 - Dependabot Updates 的 npm 组作业在 10-06 又红(braces、decode-uri-component、 esbuild、node-forge、uuid、vite 新一批,dependabot 依旧产不出一致的 pnpm-lock,PR 建不出)。2026-10-08 拍定方案 D 手动代做(等 dependabot 自修已被 10-04 / 10-06 两轮证伪,平台不支持 pnpm 锁更新,不会自愈),
17c5f40落地:pnpm overrides 钉vite@>=5 <6 → 6.4.3(vitepress 链 旧实例,esbuild 随 vite 6 的 pin 带上 0.25.12)与decode-uri-component@>=0.2 <0.5 → 0.5.0(expo 链 query-string@7 的 传递依赖),8 条 npm 告警清 5 条,web-client 的 vite 8 不受影响 (scoped override 只钉 vite 5 线)。其余 3 条登记不动:uuid@7.0.3(expo 工具链 xcode 包 pin,uuid 9+ 转 ESM-only,override 必断 prebuild,等上游跟进)、braces@3.0.3与node-forge@1.4.0(已是 最新版本,上游尚无修复版)。go_modules 组 10-06 批 PR #3 已同日合并 (1914f8a)。残留 3 条告警随后处置完毕(2026-10-08):dismiss API 不认no_fix_available理由(422,合法值仅 fix_started/inaccurate/ no_bandwidth/not_used/tolerable_risk),改按链路定性——braces(micromatch 构建链)与node-forge(@expo/cli 证书签名链)dismiss 为not_used(纯开发期工具链,不进生产产物),uuiddismiss 为fix_started(修复版 11.1.1 存在但 xcode@3.0.1 锁 ^7,台账跟踪等 上游)。同时补.github/dependabot.yml把方案 D 定成配置事实:gomod 六模块(含 user-service-rpc)合组单 PR 周更自动开、npm 刻意不列维持 人工管控;安全告警/安全 PR 由服务端设置驱动,不受该文件影响照常 推送。
下一步
实现用户认证和授权已完成(详见上文「认证与授权(JWT)」): 核查 7 个服务均有 JWT 体系——user-service 签发+全局校验、content-service 可选校验、 community 按路由校验、api-gateway 透传、user-service-rpc 内网免鉴权(设计如此); 唯一缺口 game-catalog(有公开写端点 POST /games)已补齐:utils/auth.go校验工具 +middleware/auth.go+ 路由挂载(POST /games受保护、GET 匿名公开)。 同时补齐 user-service 鉴权中间件与 game-catalog 认证工具/中间件/路由集成测试。添加数据库模型和缓存已完成:设计决策已落档(见上文「数据库集成 → 设计决策:数据库模型与缓存」——落地顺序 user-service → game-catalog → community/content、DSN 双驱动部署形态、进程内 TTL+LRU 缓存选型); user-service、game-catalog、content-service、community 四切片全部落地 (模型层读缓存 + 单测/集成测试,全模块 build +-race门禁通过; game-catalog、content-service、community 的 JSON 文件仓均已迁 SQLite/MySQL 双驱动,交付物见「数据库集成 → 4」)。实现服务间通信已完成(通路审计 + 补齐客户端契约测试):通路 实现位置 契约测试 api-gateway → user-service services/api-gateway/internal/integration/user_client.go(GetRecommendations,Bearer 透传);users 反代internal/users/routes.go(POST /auth/register、POST /auth/login、GET /users/:id,信封原样透传)user_client_test.go(httptest 断言路径/查询/头/响应解析);users/routes_test.go;handler/routes_integration_test.go(登录信封透传)api-gateway → game-catalog services/api-gateway/internal/integration/game_client.go(GetFeatured);games 反代internal/games/routes.go(GET /games、GET /games/:id)game_client_test.go;games/routes_test.goapi-gateway → community 反代 internal/proxy+internal/community/routes.go(话题/帖子/关注流/我的点赞全量路由);integration clientcommunity_client.go(GetPost/ListHotPosts/ListTopics,供聚合与分享卡)internal/proxy上游转发测试(既有);community_client_test.goapi-gateway → content 反代 internal/content/routes.go;integration clientcontent_client.go(ListGuides)content_client_test.goapi-gateway 自有聚合 handler GET /home/feed(精选游戏/热帖/话题/攻略四路并发 + 字段裁剪 + 分组降级)、GET /s/p/:id(分享卡跳板)、GET /games/featured、GET /users/:id/recommendations、POST /upload(上传落盘)、GET /uploads/:file(回读) logic/homefeedlogic_test.go、handler/routes_integration_test.go(按生产装配全量注册)content-service → game-catalog client/gamecatalog.go(熔断/重试/指标加持)client/*_test.go(既有,89.7%)user-service → game-catalog internal/integration/gamecatalog_client.go(GetRecommendations)gamecatalog_client_test.go(新增,97.2%)其余服务 → user-service-rpc user-service-rpc(zrpc + etcd)user_test.go集成测试(run() 起真 zRPC + userclient 端到端 Ping,见「覆盖率台账刷新」一节)community → realtime-hub internal/realtimepublish(点赞/评论/回复/关注/私信实时通知,fire-and-forget)internal/realtime单测 + e2e 实时流断言realtime-hub → community 无(hub 不回调业务域, /internal/publish为入向接口)— 全部 HTTP 通路的端点/参数/响应契约均已对照服务端真实路由核实 (曾修复 content-service 客户端路径不一致问题,见 ENHANCEMENT.md)。
集成监控和日志收集已完成(见上文「监控与指标(Prometheus)→ 设计决策:监控集成与日志收集」):指标采集面(Prometheus 静态抓取deploy/prometheus/prometheus.yml七 target(9091–9097),根 compose 发布 /metrics 端口 9091–9095、9097,9096 为 user-service-rpc 内部端口 不在根 compose)、日志结构化(八服务etc/*.yaml增 logxLog.Encoding: json+ ServiceName)、集中收集与可视化 (deploy/docker-compose.monitoring.yaml:Promtail 抓容器 JSON 日志 → Loki → Grafana 统一查指标与日志,数据源 provisioning 免配置)。 设计决策表三行(采集/结构化/集中收集)均已勾选,交付物与启动验证 命令见该节。配置 CI/CD 流水线CI 部分已完成(.github/workflows/ci.yml,见上文「CI 流水线」): push/PR 触发 Go 三道门禁(gofmt + 全模块 build +go test -race,模块列表动态读取 go.work) 与前端四道门禁(web/mobile 各自 tsc + eslint);CD 部分(部署流水线)因无部署目标暂缓。
更多详细信息请参考: