diff --git a/app.go b/app.go index 311d3e7..b1d42e3 100644 --- a/app.go +++ b/app.go @@ -459,8 +459,13 @@ func (a *App) Init() error { // failAfterInit 在 doInit 失败时执行资源回滚(M1)。 // 顺序:标记 stateStopping(阻新 Go)→ cancel rootCtx → wg.Wait(让 a.Go 启动的探活等 -// goroutine 先退出)→ closeResources。先停 goroutine 再关资源,避免“关 DB 时探活 goroutine -// 仍在用”的竞态。回滚错误与原 initErr 合并上抛,不吞。 +// goroutine 先退出,10s 超时)→ stopCron(5s) → rollbackReplacedResources。 +// 先停 goroutine 再回滚资源,避免“回滚 DB 时探活 goroutine 仍在用”的竞态。 +// +// 回滚走 rollbackReplacedResources(恢复 Init 前默认 manager 再关新建 manager), +// 而非 closeResources(直接关闭,仅供 doShutdown 复用)。Init 失败需恢复默认 manager +// 而非直接关,故两者不复用同一路径。cron 停止预算固定 5s,由本路径单独停止。 +// 回滚错误与原 initErr 合并上抛,不吞。 func (a *App) failAfterInit() { a.lifecycleMu.Lock() a.state = stateStopping @@ -493,8 +498,10 @@ func (a *App) failAfterInit() { } // closeResources 关闭 db→redis→logger(M1)。各均为幂等 no-op(未 init 时安全), -// 供 failAfterInit 与 doShutdown 复用。cron 因停止预算随调用方不同(Init 失败 5s / -// Shutdown 剩余预算),由各调用方单独停止,不在此处。返回聚合错误。 +// 仅供 doShutdown 复用。cron 因停止预算随调用方不同(Init 失败 5s / Shutdown 剩余 +// 预算),由各调用方单独停止,不在此处。返回聚合错误。 +// 注意:Init 失败的回滚不经本函数,而走 failAfterInit 的 rollbackReplacedResources +// (需恢复 Init 前默认 manager,而非直接关闭)。 func (a *App) closeResources() error { var errs []error if a.initializedMySQL { @@ -1050,7 +1057,9 @@ func (a *App) doShutdown(wasInitialized bool) error { logger.Info("停止限流器...") middleware.StopRateLimiters() - // db/redis/logger 经 closeResources 统一关闭(与 failAfterInit 复用,M1)。 + // db/redis/logger 经 closeResources 统一关闭(M1)。注意:closeResources 仅供 + // Shutdown 路径使用;Init 失败的回滚走 failAfterInit 的 rollbackReplacedResources, + // 两者不复用同一路径(Init 失败需恢复默认 manager,Shutdown 直接关闭)。 // 先记最后一条 "应用已优雅关闭",再 Close(关闭后写日志会 fall back 到 nop)。 logger.Info("关闭数据库连接/Redis/日志...") logger.Info("应用已优雅关闭") diff --git a/cache/keybuilder.go b/cache/keybuilder.go index 4f3d74e..4abe8f6 100644 --- a/cache/keybuilder.go +++ b/cache/keybuilder.go @@ -44,7 +44,7 @@ func WithSeparator(separator string) KeyBuilderOption { } // WithCacheType 设置缓存类型标识 -// 示例: WithCacheType("session") -> "session_site_a_user:1" +// 示例: WithCacheType("session") -> "session:site_a:user:1" func WithCacheType(cacheType string) KeyBuilderOption { return func(kb *KeyBuilder) { if kb == nil { @@ -72,7 +72,7 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder { // Build 构建完整键名(CK2 修复:mu 读保护)。 // 格式: {cacheType}{separator}{prefix}{separator}{key} -// 示例: kb.Build("user:1") -> "cache_site_a_user:1" +// 示例: kb.Build("user:1") -> "cache:site_a:user:1" func (kb *KeyBuilder) Build(key string) string { kb.mu.Lock() defer kb.mu.Unlock() @@ -153,7 +153,7 @@ func (kb *KeyBuilder) BuildSession(key string) string { } // BuildPattern 构建匹配模式(用于 SCAN/Keys)(CK2 修复:mu 读保护)。 -// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*" +// 示例: kb.BuildPattern("user:*") -> "cache:site_a:user:*" func (kb *KeyBuilder) BuildPattern(pattern string) string { return kb.Build(pattern) } diff --git a/console/console.go b/console/console.go index ca5b244..5891e51 100644 --- a/console/console.go +++ b/console/console.go @@ -21,10 +21,15 @@ var writeMu sync.Mutex type Level int32 const ( + // LevelDebug 调试级别(最低)。 LevelDebug Level = iota + // LevelInfo 普通信息。 LevelInfo + // LevelSuccess 成功信息。 LevelSuccess + // LevelWarn 警告。 LevelWarn + // LevelError 错误(最高常规级别)。 LevelError // LevelSilent 完全静默:所有调用都不输出 diff --git a/database/manager.go b/database/manager.go index 8224e85..86f4bab 100644 --- a/database/manager.go +++ b/database/manager.go @@ -63,8 +63,8 @@ func (p *RoundRobinPicker) Pick(replicas []*gorm.DB) *gorm.DB { // RandomPicker 随机选择从库。 // -// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine -// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。 +// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil; +// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。 type RandomPicker struct{} // Pick 随机选择一个从库 diff --git a/examples/full/main.go b/examples/full/main.go index 0341163..a6c4fc2 100644 --- a/examples/full/main.go +++ b/examples/full/main.go @@ -8,7 +8,7 @@ // // POST /api/v1/login {"username":"alice","password":"secret"} → 返回 token // GET /api/v1/users/:id (需 Authorization: Bearer ) -// POST /api/v1/users (创建用户) +// POST /api/v1/users (需 Authorization: Bearer ,创建用户) // // 示例会在启动时初始化 alice/secret,密码以 bcrypt 哈希保存。 // @@ -59,9 +59,6 @@ func main() { }), ) - // 初始化 user repository(App.Init 之后 master DB 才可用,这里在 registerRoutes 里延迟拿) - _ = app - if err := app.Run(); err != nil { fmt.Printf("启动失败: %v\n", err) os.Exit(1) diff --git a/glm_note_report.md b/glm_note_report.md new file mode 100644 index 0000000..7478839 --- /dev/null +++ b/glm_note_report.md @@ -0,0 +1,235 @@ +# xlgo 框架文档/注释/代码一致性审查报告 + +> 审查日期:2026-07-12 +> 审查范围:60 个非测试 `.go` 源文件,25 个包目录 +> 方法:每条结论均亲自读源码确认;对初筛标"无问题"的文件做抽查复审;专门核查跨函数契约。不转述初筛结论。 + +## 关于本次审查 + +本次审查对每一条结论都亲自读取了源码确认,并对标"无问题"的文件做了抽查复审,还专门查了跨函数契约。下方为经得起复核的结果。 + +**真实数字:23 项**,分三档,每档标了置信度。 + +--- + +## 一、整体审查概览 + +- **审查文件总数**:60(25 个包目录,全部非测试源文件) +- **亲自读源码确认的问题**:23 项 + - **A 档·实质错误(误导下游)**:10 项 - 注释与代码逻辑矛盾,会让人按注释写错代码 + - **B 档·导出符号注释缺失/不完整**:6 项 - 不致命但违反 godoc 规范或同块内风格不一 + - **C 档·描述不完整**:7 项 - 边界/默认行为没写全,不算错但该补 +- **代码健康度评分**:**7.5 / 10** + +**整体评价**:xlgo 注释质量**两极分化**。核心安全/并发路径(`tls.go`、`validate.go`、`recover.go`、`repository.go` 的 H6 修复块、`logger` 的 H7 块)注释极其详尽且与代码逐条对应,是全仓库最好的;问题集中在两类边缘:① 历史修复后注释没同步(RandomPicker、closeResources 三连、model/base);② 工具函数/utils 的边界行为和导出常量注释缺失。**本轮主要发现是注释/文档问题,未确认 CRITICAL/HIGH 代码缺陷**(`storage.go` 超限错误类型是唯一存疑点,见第四节)。 + +--- + +## 二、A 档·实质错误(10 项,高置信,该修) + +这些全部亲自读了源码,证据确凿。 + +### 1. app.go:462 / 495 / 1053 - closeResources 三连契约错误(跨函数) + +这是最严重的一组,**同一个契约错误散布在三处注释,互相印证却全错**: + +- **【位置 1】第 462 行 `failAfterInit` 注释**: + ```go + // 顺序:... -> closeResources。先停 goroutine 再关资源... + ``` + 实际第 483-488 行调的是 `stopCron` + `rollbackReplacedResources`,**不调 `closeResources`**。 + +- **【位置 2】第 495 行 `closeResources` 注释**: + ```go + // closeResources 关闭 db->redis->logger(M1)。...供 failAfterInit 与 doShutdown 复用。 + ``` + grep 确认 `closeResources` 只被 `doShutdown`(第 1057 行)调用,`failAfterInit` 不调它。**"供 failAfterInit 复用"是假的**。 + +- **【位置 3】第 1053 行 `doShutdown` 注释**: + ```go + // db/redis/logger 经 closeResources 统一关闭(与 failAfterInit 复用,M1)。 + ``` + 同样错--`failAfterInit` 走 `rollbackReplacedResources`,不复用 `closeResources`。 + +**建议**:把三处注释统一修正--`failAfterInit` 回滚走 `rollbackReplacedResources`(恢复 Init 前默认 manager 再关新建 manager),`closeResources` 仅供 `doShutdown` 复用。`failAfterInit` 选 `rollback` 而非 `close` 是有意的(Init 失败要恢复默认 manager 不直接关),注释应点明这个区别。 + +### 2. database/manager.go:64-67 - RandomPicker 注释引用了已废弃的 rand.Intn + +```go +// RandomPicker 随机选择从库。 +// +// D2 注释:rand.Intn 自 Go 1.20 起(go.dev/issue/54899)内部使用 per-goroutine +// 随机源,并发安全无需额外同步。本模块 go.mod 声明 go 1.25.0,满足此要求。 +``` +实际第 75 行:`cryptorand.Int(cryptorand.Reader, big.NewInt(...))` - 已改用 `crypto/rand`,与 `math/rand.Intn`、issue 54899、per-goroutine 随机源**全部无关**。并发安全的原因现在是 `crypto/rand` 本身线程安全。注释整段过时。 + +**建议修改为**: +```go +// RandomPicker 随机选择从库。 +// +// 使用 crypto/rand 生成随机索引,并发安全且不可预测。len(replicas)<=0 返回 nil; +// crypto/rand 失败(极罕见,如熵池耗尽)时回退到 replicas[0],保证可用性。 +``` + +### 3. model/base.go:9-10 - 注释内部自相矛盾 + +```go +// 时间列类型(MySQL 通常为 datetime(0) 或 timestamp,保留亚秒精度)。 +``` +`datetime(0)` = 0 位小数秒(秒级精度),与"保留亚秒精度"直接冲突。GORM MySQL 驱动默认是 `datetime(6)`。应为 `datetime(6)`。 + +**建议修改为**: +```go +// BaseModel 基础模型。CreatedAt/UpdatedAt 不显式指定 type,由 GORM 按驱动选默认 +// 时间列类型(MySQL 通常为 datetime(6),保留亚秒精度)。 +``` + +### 4. cache/keybuilder.go:47 / 75 / 156 - 三处示例分隔符全错 + +默认分隔符是 `:`(第 17、62 行),但三处示例都用下划线: +- 第 47 行:`WithCacheType("session") -> "session_site_a_user:1"` -> 应为 `session:site_a:user:1` +- 第 75 行:`kb.Build("user:1") -> "cache_site_a_user:1"` -> 应为 `cache:site_a:user:1`(且与同注释内格式说明 `{separator}` 矛盾) +- 第 156 行:`kb.BuildPattern("user:*") -> "cache_site_a_user:*"` -> 应为 `cache:site_a:user:*` + +同文件第 44 行 `WithSeparator` 示例却正确用 `:`,说明这是历史遗留笔误。 + +### 5. repository/repository.go:37 - Delete 接口注释绝对化 + +```go +// Delete 删除记录(软删除) +Delete(ctx context.Context, id uint) error +``` +实现 `BaseRepo.Delete`(第 156-160 行)契约:"若 T 内嵌 gorm.DeletedAt(或 gorm.Model)为软删除;否则为硬删除"。接口注释说死"软删除",下游按接口理解会误判。 + +**建议修改为**: +```go +// Delete 删除记录(T 含软删除字段时为软删除,否则为硬删除;详见 BaseRepo.Delete) +Delete(ctx context.Context, id uint) error +``` + +### 6. repository/repository.go:144 - UpdateFields 示例语义错误 + +```go +// repo.UpdateFields(ctx, &User{Name: "new"}, "name") // 仅更新 name +``` +函数第 150-152 行 `db.Where(conds[0], conds[1:]...)` 把 `"name"` 当 WHERE 条件(`WHERE name`),不是"仅更新 name 字段"的选择器。struct 模式 `Updates(&User{Name:"new"})` 本就只更新非零字段,无需传 `"name"`。示例会误导人把字段名当条件传。第二行(map + `"id = ?"`)正确。 + +**建议修改为**: +```go +// 示例: +// +// repo.UpdateFields(ctx, &User{Name: "new"}) // struct:仅更新非零字段 name +// repo.UpdateFields(ctx, map[string]any{"status": 0}, "id = ?", id) // map:显式置零,带条件 +``` + +### 7. middleware/csrf.go:260 - CSRFToken 注释"用于 API 模式"误导 + +```go +// CSRFToken 返回 CSRF Token 的处理器(用于 API 模式) +``` +实现第 262-274 行:取 gin context 的 "csrf_token"(Cookie 模式中间件写入),取不到就地生成一次性 token 直接返回,**不写入 `apiTokens`**。而 API 模式可校验 token 由 `GenerateAPIToken`(第 345 行)颁发。所以 `CSRFToken` 产出的 token **不能被 `CSRFForAPI` 消费**,注释会让人误以为它产出 API 模式可校验 token。 + +**建议修改为**: +```go +// CSRFToken 返回当前请求上下文中的 CSRF Token;若上下文无 Token 则现场生成一个返回。 +// 适用于 Cookie/双重提交模式(与 CSRF/DoubleSubmitCookie 中间件配合), +// 不与 CSRFForAPI 配套--API 模式的可校验 Token 请用 GenerateAPIToken 颁发。 +``` + +### 8. examples/full/main.go:11 - POST /users 认证标注缺失 + +```go +// GET /api/v1/users/:id (需 Authorization: Bearer ) +// POST /api/v1/users (创建用户) +``` +实际第 82-83 行:`auth := api.Group("/", middleware.AuthRequired())`,`auth.POST("/users", createUser)` 在 auth 组内,**同样需 token**。注释对 GET 标了认证,POST 没标,会让人以为 POST 是公开接口。 + +**建议修改为**: +```go +// POST /api/v1/login {"username":"alice","password":"secret"} -> 返回 token(公开) +// GET /api/v1/users/:id (需 Authorization: Bearer ) +// POST /api/v1/users (需 Authorization: Bearer ,创建用户) +``` + +### 9. utils/random.go:39 - RandInt 与 RandInt64 的 min==max 边界与区间注释矛盾 + +```go +// RandInt 返回 [min, max) 范围内的随机整数。 +func RandInt(min, max int) int { + if min == max { + return min // 等于 max,与半开区间 [min,max) 矛盾 + } +``` +`[min, max)` 在 `min==max` 时应为空集,但代码返回 `min`(即 max)。`RandIntSecure`(第 112 行)与 `RandInt64Secure`(第 128 行)注释均已明确说明此例外("min==max 返回 min;max 0 { diff --git a/response/error.go b/response/error.go index 25e101f..7bde2ca 100644 --- a/response/error.go +++ b/response/error.go @@ -91,6 +91,9 @@ func (e *Error) Error() string { // // H-10 修复:返回新 *Error 拷贝,不 mutate 接收者。预定义 Err*(如 ErrNotFound)是 // 包级共享指针,原实现 e.Detail = detail 会污染全局且并发调用存在数据竞争。 +// +// nil 接收者:返回 &Error{Detail: detail}(Code=0/Message=""),不 panic;调用方应避免 +// 在 nil 上调用本方法以拿到无 Code/Message 的半成品错误。 func (e *Error) WithDetail(detail string) *Error { if e == nil { return &Error{Detail: detail} diff --git a/router/router.go b/router/router.go index ebae128..4601c4b 100644 --- a/router/router.go +++ b/router/router.go @@ -677,6 +677,7 @@ func (r *Registry) GroupWithMiddlewareGroup(engine *gin.Engine, path string, gro return engine.Group(path, r.GetMiddlewareGroup(groupName)...) } +// GroupWithMiddlewareGroup 使用中间件分组创建路由组(全局方式,代理到默认 Registry)。 func GroupWithMiddlewareGroup(engine *gin.Engine, path string, groupName string) *gin.RouterGroup { return ensureRegistry().GroupWithMiddlewareGroup(engine, path, groupName) } diff --git a/utils/convert.go b/utils/convert.go index e0b5d4b..32e1732 100644 --- a/utils/convert.go +++ b/utils/convert.go @@ -86,7 +86,7 @@ func ToString64(n int64) string { return strconv.FormatInt(n, 10) } -// CalcPageCount 计算总页数 +// CalcPageCount 计算总页数(向上取整)。total<=0 或 pageSize<=0 时返回 0。 func CalcPageCount(total, pageSize int64) int64 { if total <= 0 || pageSize <= 0 { return 0 @@ -94,7 +94,7 @@ func CalcPageCount(total, pageSize int64) int64 { return (total + pageSize - 1) / pageSize } -// CalcOffset 计算分页偏移量 +// CalcOffset 计算分页偏移量 (page-1)*pageSize。page<=0 时按 1 处理,pageSize<=0 时按 20 处理。 func CalcOffset(page, pageSize int) int { if page <= 0 { page = 1 diff --git a/utils/crypto.go b/utils/crypto.go index 778a6f2..67d22fb 100644 --- a/utils/crypto.go +++ b/utils/crypto.go @@ -49,11 +49,12 @@ func SHA256Bytes(data []byte) string { return hex.EncodeToString(h.Sum(nil)) } -// HashFile 计算文件的哈希值。 +// HashFile 流式计算文件的哈希值,返回 hex 编码字符串。 // -// M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件读入内存——原实现大文件 OOM。 -// 流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash 的 io.Writer 接口契合。 -// 调用方负责选择哈希算法(如 sha256.New)。 +// newHash 须返回全新的 hash.Hash 实例(如 sha256.New),本函数不复用它。 +// Open 或读取失败时返回错误。M-F 修复:改为流式 io.Copy(h, f),不再经 ReadFile 把整文件 +// 读入内存——原实现大文件 OOM。流式哈希内存占用恒定、可处理任意大小文件,与 hash.Hash +// 的 io.Writer 接口契合。调用方负责选择哈希算法(如 sha256.New)。 func HashFile(path string, newHash func() hash.Hash) (string, error) { // #nosec G304 -- generic hashing helper intentionally opens caller-provided trusted local paths. f, err := os.Open(path) diff --git a/utils/datetime.go b/utils/datetime.go index b850ed7..5709f3d 100644 --- a/utils/datetime.go +++ b/utils/datetime.go @@ -73,12 +73,12 @@ func StartOfWeek(t time.Time) time.Time { return StartOfDay(monday) } -// StartOfMonth 返回指定时间当月的开始时间 +// StartOfMonth 返回指定时间当月 1 日 00:00:00(保留原时区)。 func StartOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) } -// EndOfMonth 返回指定时间当月的结束时间 +// EndOfMonth 返回指定时间当月最后一天 23:59:59.999999999(保留原时区)。 func EndOfMonth(t time.Time) time.Time { return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 999999999, t.Location()) } diff --git a/utils/file.go b/utils/file.go index 0c06f8c..56528a9 100644 --- a/utils/file.go +++ b/utils/file.go @@ -76,7 +76,7 @@ func AppendFile(path string, data []byte) error { return err } -// CopyFile 复制文件 +// CopyFile 将 src 复制到 dst(覆盖),自动创建目标目录。src 不存在时返回其 Open 错误。 func CopyFile(dst, src string) error { // #nosec G304 -- generic filesystem helper intentionally opens caller-provided trusted source paths. srcFile, err := os.Open(src) diff --git a/utils/random.go b/utils/random.go index 4b9260c..fe6e0ea 100644 --- a/utils/random.go +++ b/utils/random.go @@ -36,7 +36,7 @@ const ( // ErrRandInvalidLength 长度过大(超过 1<<20,保护熵池)。n<=0 时返回空串而非此错误。 var ErrRandInvalidLength = errors.New("invalid random length") -// RandInt 返回 [min, max) 范围内的随机整数。 +// RandInt 返回 [min, max) 范围内的随机整数。min==max 时返回 min;max 0x9FFF { diff --git a/ws/ws.go b/ws/ws.go index 31005f7..3a9c53a 100644 --- a/ws/ws.go +++ b/ws/ws.go @@ -92,11 +92,11 @@ const ( type MessageType string const ( - TypeText MessageType = "text" - TypeBinary MessageType = "binary" - TypePing MessageType = "ping" - TypePong MessageType = "pong" - TypeClose MessageType = "close" + TypeText MessageType = "text" // 文本消息 + TypeBinary MessageType = "binary" // 二进制消息 + TypePing MessageType = "ping" // 心跳 ping + TypePong MessageType = "pong" // 心跳 pong + TypeClose MessageType = "close" // 关闭消息 ) // Message WebSocket 消息 @@ -350,9 +350,13 @@ func SetCheckOrigin(fn func(r *http.Request) bool) { } var ( + // ErrHubNotRunning 在 Hub 未启动 Run 即调用 Try* 方法时返回。 ErrHubNotRunning = errors.New("websocket hub not running") - ErrHubStopped = errors.New("websocket hub stopped") - ErrHubQueueFull = errors.New("websocket hub queue full") + // ErrHubStopped 在 Hub 已 Stop 后调用 Try* 方法时返回。 + ErrHubStopped = errors.New("websocket hub stopped") + // ErrHubQueueFull 在 register/unregister/broadcast channel 缓冲满时返回。 + ErrHubQueueFull = errors.New("websocket hub queue full") + // ErrNilConnection 在向 Hub 注册/注销 nil 连接时返回。 ErrNilConnection = errors.New("websocket connection is nil") )