From 87098a9180df4637af9a1114cdc1feebb972f60c Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Sat, 14 Mar 2026 13:06:32 +0800 Subject: [PATCH] =?UTF-8?q?refactor(db):=20=E5=A2=9E=E5=BC=BA=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=BA=8B=E5=8A=A1=E5=92=8C=E7=BC=93=E5=AD=98=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题 - 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性 - 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时 - 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性 - 优化 Swagger 生成逻辑,支持模块化输出和导航页生成 - 移除冗余的调试日志代码,提升代码整洁性 --- .../api测试框架完整实现_ce7fcbde.plan.md | 131 ++++++++++++++++-- cache/cache.go | 121 +--------------- db/crud.go | 7 +- db/db.go | 4 +- db/query.go | 17 ++- db/transaction.go | 7 + session.go | 43 ------ testing_helper.go | 4 + testing_swagger.go | 125 +++++++++++++---- 9 files changed, 254 insertions(+), 205 deletions(-) diff --git a/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md b/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md index 3d57502..d977787 100644 --- a/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md +++ b/.cursor/plans/api测试框架完整实现_ce7fcbde.plan.md @@ -83,7 +83,86 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce } ``` -**改动量:** 3 个文件,约 50 行,生产代码零影响(testTx 默认 nil)。 +### 3.1 testTx 并发保护(调试发现的关键问题) + +**问题现象:** 测试运行时出现 `[mysql] busy buffer`、`bad connection` 错误,导致测试挂起。 + +**根因分析:** `testTx` 是单个 `*sql.Tx` 连接,MySQL 驱动不允许在同一连接上并发执行查询。业务代码中存在 `go func()` 启动的 goroutine(如 `syncGoodsToYst`、`stats.go` 中的统计协程),这些 goroutine 会并发访问 `testTx`,导致驱动层的 `busy buffer` 错误。 + +**解决方案:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,在 `BeginTestTx()` 时初始化。所有通过 `testTx` 执行的 Query、Exec、SAVEPOINT 操作都通过 `testMu` 加锁保护,确保串行化访问。 + +```go +// db/db.go - HoTimeDB 新增字段 +testMu *sync.Mutex // 保护 testTx 单连接不被并发访问 + +// db/db.go - BeginTestTx 中初始化 +that.testMu = &sync.Mutex{} + +// db/query.go - Query 中加锁 +if that.testTx != nil { + if that.testMu != nil { that.testMu.Lock() } + resl, err = that.testTx.Query(query, processedArgs...) + // ... 消费 result set ... + if that.testMu != nil { that.testMu.Unlock() } +} + +// db/query.go - Exec 中加锁 +if that.testTx != nil { + if that.testMu != nil { that.testMu.Lock() } + resl, e = that.testTx.Exec(query, processedArgs...) + if that.testMu != nil { that.testMu.Unlock() } +} + +// db/transaction.go - SAVEPOINT 操作加锁 +if that.testMu != nil { that.testMu.Lock() } +_, _ = that.testTx.Exec("SAVEPOINT " + spName) +if that.testMu != nil { that.testMu.Unlock() } +``` + +**注意事项:** +- 生产环境不受影响(`testMu` 默认 nil,所有锁检查都有 nil 保护) +- 互斥锁确保 goroutine 中的 DB 操作排队执行而非并发冲突 +- Query 锁的范围包括结果集消费(`that.Row(resl)`),防止未读完数据就发起新查询 + +**改动量:** 3 个文件,约 50 行核心改动 + mutex 保护约 30 行,生产代码零影响(testTx/testMu 默认 nil)。 + +### 3.2 测试模式下禁用 DB/Redis 缓存(调试发现的关键问题) + +**问题现象:** 测试运行时出现 `Error 1205: Lock wait timeout exceeded` 错误,指向 `DELETE FROM cached` 操作。 + +**根因分析:** 框架的 `HoTimeCache` 支持三级缓存(memory > redis > db)。在测试模式下,DB 缓存的读写操作(如 `DELETE FROM cached`)会尝试使用独立连接,而 `testTx` 持有事务锁,导致 `Lock wait timeout`。即使走 `testTx`,缓存的并发读写也与事务隔离产生冲突。 + +**解决方案:** + +1. 在 `cache/cache.go` 中新增 `DisableDbCache()` 方法: +```go +func (that *HoTimeCache) DisableDbCache() { + that.dbCache = nil + that.redisCache = nil +} +``` + +2. 在 `testing_helper.go` 的 `NewTestApp` 中调用: +```go +if app.HoTimeCache != nil { + app.HoTimeCache.DisableDbCache() +} +``` + +3. 在 `db/crud.go` 的 `Select` 方法中,当 `testTx` 激活时跳过缓存逻辑: +```go +if that.testTx == nil { + // 原有缓存读写逻辑 +} +``` + +**影响范围:** 测试期间仅使用 memory 缓存,不影响生产环境。Session 在测试中通过 `WithSession()` 直接注入,不依赖 DB/Redis 缓存。 + +### 3.3 测试模式下 Query 的重试策略调整 + +**问题:** `queryWithRetry` 和 `execWithRetry` 在遇到连接错误时会调用 `db.Ping()` 尝试重连,但 `testTx` 是单连接事务,Ping 会创建新连接导致 `busy buffer`。 + +**解决:** 当 `testTx != nil` 时,跳过 `db.Ping()` 和重试逻辑,直接返回错误。 ## 二、链式测试 API @@ -168,13 +247,45 @@ func TestApi(t *testing.T) { ## 五、文件改动汇总 -- **修改** `d:/work/hotimev1.5/db/db.go` -- 加 testTx 字段 + BeginTestTx/RollbackTestTx -- **修改** `d:/work/hotimev1.5/db/query.go` -- Query/Exec 各加 testTx 判断 -- **修改** `d:/work/hotimev1.5/db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx 传递 -- **新增** `d:/work/hotimev1.5/testing_helper.go` -- TestApp、RunTests、覆盖率 -- **新增** `d:/work/hotimev1.5/testing_api.go` -- 链式 API + TestCollector -- **新增** `d:/work/hotimev1.5/testing_swagger.go` -- Swagger 生成 -- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest -- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest -- **新增** `d:/work/xbc/app/app_test.go` -- 测试入口 +### hotimev1.5 框架层 +- **修改** `db/db.go` -- 加 testTx + testMu 字段,BeginTestTx(初始化 mutex)/RollbackTestTx +- **修改** `db/query.go` -- Query/Exec 各加 testTx 判断 + testMu 互斥锁保护 + 跳过重试逻辑 +- **修改** `db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁 +- **修改** `db/crud.go` -- Select 中 testTx 激活时跳过缓存逻辑 +- **修改** `cache/cache.go` -- 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作 +- **修改** `session.go` -- 对接批量 Session 缓存操作 +- **修改** `testing_helper.go` -- NewTestApp 中调用 DisableDbCache() +- **新增** `testing_helper.go` -- TestApp、RunTests、覆盖率 +- **新增** `testing_api.go` -- 链式 API + TestCollector +- **新增** `testing_swagger.go` -- Swagger 生成 + +### xbc 业务层 +- **修改** `app/user.go` -- 末尾添加 UserTest +- **修改** `app/init.go` -- 添加 ProjectTest +- **新增** `app/app_test.go` -- 测试入口(含 Swagger 生成) +- **新增** `app/*_test.go` -- 30+ 个控制器测试文件 +- **新增** `wx/wx_test.go` + `wx/*_test.go` -- 微信端测试入口 + 7 个控制器测试 +- **新增** `dd/dd_test.go` + `dd/*_test.go` -- 钉钉端测试入口 + 6 个控制器测试 +- **新增** `customer/customer_h5_test.go` + `customer/*_test.go` -- H5 客户端测试 +- **新增** `sms/sms_test.go` + `sms/sms_ctr_test.go` -- 短信服务测试 +- **修改** `app/wechat.go` -- 修复 log.Println -> log.Printf(go vet 警告) +- **修改** `customer/init.go` -- 修复 log.Println -> log.Printf(go vet 警告) + +## 六、已知问题与调试经验 + +### 问题一:busy buffer / bad connection +- **现象:** 测试运行数秒后出现 `[mysql] busy buffer`,随后 `bad connection`,测试挂起 +- **根因:** 业务代码中 `go func()` 启动的 goroutine(如 `app/goods.go:syncGoodsToYst`、`app/stats.go` 中的统计协程)并发访问 `testTx` 单连接 +- **解决:** 在 `HoTimeDB` 中新增 `testMu *sync.Mutex`,所有 testTx 的 Query/Exec/SAVEPOINT 操作加互斥锁 +- **验证方式:** 通过在 query.go 中插入带时间戳和 goroutine 计数的日志,观察到操作严格串行化,无重叠 + +### 问题二:Lock wait timeout +- **现象:** `Error 1205: Lock wait timeout exceeded`,指向 `DELETE FROM cached` +- **根因:** `HoTimeCache` 的 DB 缓存通过独立连接操作 `cached` 表,与 `testTx` 事务锁冲突 +- **解决:** 测试启动时调用 `DisableDbCache()` 禁用 DB/Redis 缓存,仅保留 memory 缓存 + +### 问题三:go vet 编译失败 +- **现象:** `go test` 因 `go vet` 警告 `log.Println call has possible Printf formatting directive %v` 而拒绝运行 +- **根因:** `app/wechat.go` 和 `customer/init.go` 中误用 `log.Println` 传入 `%v` 格式化指令 +- **解决:** 改为 `log.Printf` diff --git a/cache/cache.go b/cache/cache.go index 12ea5a5..83ae733 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,36 +1,11 @@ package cache import ( - "encoding/json" "errors" - "os" - "time" . "code.hoteas.com/golang/hotime/common" ) -const debugLogPath = `d:\work\hotimev1.5\.cursor\debug.log` - -// debugLog 写入调试日志 -func debugLog(hypothesisId, location, message string, data map[string]interface{}) { - // #region agent log - logFile, _ := os.OpenFile(debugLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if logFile != nil { - logEntry, _ := json.Marshal(map[string]interface{}{ - "sessionId": "cache-debug", - "runId": "test-run", - "hypothesisId": hypothesisId, - "location": location, - "message": message, - "data": data, - "timestamp": time.Now().UnixMilli(), - }) - logFile.Write(append(logEntry, '\n')) - logFile.Close() - } - // #endregion -} - // HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致, // 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用 type HoTimeCache struct { @@ -214,26 +189,12 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map { return Map{} } - // #region agent log - debugLog("D", "cache.go:SessionsGet:start", "SessionsGet开始", map[string]interface{}{ - "keys_count": len(keys), - "has_memory": that.memoryCache != nil, - "has_redis": that.redisCache != nil, - "has_db": that.dbCache != nil, - }) - // #endregion - result := make(Map, len(keys)) missingKeys := keys // 从 memory 获取 if that.memoryCache != nil && that.memoryCache.SessionSet { memResult := that.memoryCache.CachesGet(keys) - // #region agent log - debugLog("D", "cache.go:SessionsGet:memory", "从Memory获取", map[string]interface{}{ - "found_count": len(memResult), - }) - // #endregion for k, v := range memResult { result[k] = v } @@ -249,20 +210,9 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map { // 从 redis 获取未命中的 if len(missingKeys) > 0 && that.redisCache != nil && that.redisCache.SessionSet { redisResult := that.redisCache.CachesGet(missingKeys) - // #region agent log - debugLog("D", "cache.go:SessionsGet:redis", "从Redis获取", map[string]interface{}{ - "missing_count": len(missingKeys), - "found_count": len(redisResult), - }) - // #endregion // 反哺到 memory if that.memoryCache != nil && that.memoryCache.SessionSet && len(redisResult) > 0 { that.memoryCache.CachesSet(redisResult) - // #region agent log - debugLog("D", "cache.go:SessionsGet:backfill_redis_to_mem", "Redis数据反哺到Memory", map[string]interface{}{ - "backfill_count": len(redisResult), - }) - // #endregion } for k, v := range redisResult { result[k] = v @@ -280,29 +230,13 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map { // 从 db 获取未命中的 if len(missingKeys) > 0 && that.dbCache != nil && that.dbCache.SessionSet { dbResult := that.dbCache.CachesGet(missingKeys) - // #region agent log - debugLog("D", "cache.go:SessionsGet:db", "从DB获取", map[string]interface{}{ - "missing_count": len(missingKeys), - "found_count": len(dbResult), - }) - // #endregion // 反哺到 memory 和 redis if len(dbResult) > 0 { if that.memoryCache != nil && that.memoryCache.SessionSet { that.memoryCache.CachesSet(dbResult) - // #region agent log - debugLog("D", "cache.go:SessionsGet:backfill_db_to_mem", "DB数据反哺到Memory", map[string]interface{}{ - "backfill_count": len(dbResult), - }) - // #endregion } if that.redisCache != nil && that.redisCache.SessionSet { that.redisCache.CachesSet(dbResult) - // #region agent log - debugLog("D", "cache.go:SessionsGet:backfill_db_to_redis", "DB数据反哺到Redis", map[string]interface{}{ - "backfill_count": len(dbResult), - }) - // #endregion } } for k, v := range dbResult { @@ -310,12 +244,6 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map { } } - // #region agent log - debugLog("D", "cache.go:SessionsGet:end", "SessionsGet完成", map[string]interface{}{ - "total_found": len(result), - }) - // #endregion - return result } @@ -326,37 +254,15 @@ func (that *HoTimeCache) SessionsSet(data Map) { return } - // #region agent log - debugLog("A", "cache.go:SessionsSet:start", "SessionsSet开始", map[string]interface{}{ - "data_count": len(data), - "has_memory": that.memoryCache != nil, - "has_redis": that.redisCache != nil, - "has_db": that.dbCache != nil, - }) - // #endregion - if that.memoryCache != nil && that.memoryCache.SessionSet { that.memoryCache.CachesSet(data) - // #region agent log - debugLog("A", "cache.go:SessionsSet:memory", "写入Memory完成", map[string]interface{}{"count": len(data)}) - // #endregion } if that.redisCache != nil && that.redisCache.SessionSet { that.redisCache.CachesSet(data) - // #region agent log - debugLog("A", "cache.go:SessionsSet:redis", "写入Redis完成", map[string]interface{}{"count": len(data)}) - // #endregion } if that.dbCache != nil && that.dbCache.SessionSet { that.dbCache.CachesSet(data) - // #region agent log - debugLog("A", "cache.go:SessionsSet:db", "写入DB完成", map[string]interface{}{"count": len(data)}) - // #endregion } - - // #region agent log - debugLog("A", "cache.go:SessionsSet:end", "SessionsSet完成", nil) - // #endregion } // SessionsDelete 批量删除 Session 缓存 @@ -365,37 +271,15 @@ func (that *HoTimeCache) SessionsDelete(keys []string) { return } - // #region agent log - debugLog("C", "cache.go:SessionsDelete:start", "SessionsDelete开始", map[string]interface{}{ - "keys_count": len(keys), - "has_memory": that.memoryCache != nil, - "has_redis": that.redisCache != nil, - "has_db": that.dbCache != nil, - }) - // #endregion - if that.memoryCache != nil && that.memoryCache.SessionSet { that.memoryCache.CachesDelete(keys) - // #region agent log - debugLog("C", "cache.go:SessionsDelete:memory", "从Memory删除完成", map[string]interface{}{"count": len(keys)}) - // #endregion } if that.redisCache != nil && that.redisCache.SessionSet { that.redisCache.CachesDelete(keys) - // #region agent log - debugLog("C", "cache.go:SessionsDelete:redis", "从Redis删除完成", map[string]interface{}{"count": len(keys)}) - // #endregion } if that.dbCache != nil && that.dbCache.SessionSet { that.dbCache.CachesDelete(keys) - // #region agent log - debugLog("C", "cache.go:SessionsDelete:db", "从DB删除完成", map[string]interface{}{"count": len(keys)}) - // #endregion } - - // #region agent log - debugLog("C", "cache.go:SessionsDelete:end", "SessionsDelete完成", nil) - // #endregion } // CachesGet 批量获取普通缓存 @@ -602,3 +486,8 @@ func (that *HoTimeCache) Init(config Map, hotimeDb HoTimeDBInterface, err ...*Er } } + +func (that *HoTimeCache) DisableDbCache() { + that.dbCache = nil + that.redisCache = nil +} diff --git a/db/crud.go b/db/crud.go index 0e87641..b7ae69e 100644 --- a/db/crud.go +++ b/db/crud.go @@ -135,23 +135,20 @@ func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map { qs = append(qs, resWhere...) md5 := that.md5(query, qs...) - if that.HoTimeCache != nil && table != "cached" { - // 如果缓存有则从缓存取 + if that.testTx == nil && that.HoTimeCache != nil && table != "cached" { cacheData := that.HoTimeCache.Db(table + ":" + md5) if cacheData != nil && cacheData.Data != nil { return cacheData.ToMapArray() } } - // 无缓存则数据库取 res := that.Query(query, qs...) if res == nil { res = []Map{} } - // 缓存 - if that.HoTimeCache != nil && table != "cached" { + if that.testTx == nil && that.HoTimeCache != nil && table != "cached" { _ = that.HoTimeCache.Db(table+":"+md5, res) } diff --git a/db/db.go b/db/db.go index b6c3bcc..15f99f5 100644 --- a/db/db.go +++ b/db/db.go @@ -32,7 +32,8 @@ type HoTimeDB struct { mu sync.RWMutex limitMu sync.Mutex Dialect Dialect // 数据库方言适配器 - testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚 + testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚 + testMu *sync.Mutex // 保护 testTx 单连接不被并发访问 } // SetConnect 设置数据库配置连接 @@ -154,6 +155,7 @@ func (that *HoTimeDB) BeginTestTx() error { return err } that.testTx = tx + that.testMu = &sync.Mutex{} return nil } diff --git a/db/query.go b/db/query.go index db6d1ac..052495d 100644 --- a/db/query.go +++ b/db/query.go @@ -62,7 +62,16 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa processedArgs := that.processArgs(args) if that.testTx != nil { + if that.testMu != nil { that.testMu.Lock() } resl, err = that.testTx.Query(query, processedArgs...) + that.LastErr.SetError(err) + if err != nil { + if that.testMu != nil { that.testMu.Unlock() } + return nil + } + result := that.Row(resl) + if that.testMu != nil { that.testMu.Unlock() } + return result } else if that.Tx != nil { resl, err = that.Tx.Query(query, processedArgs...) } else { @@ -71,7 +80,6 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa that.LastErr.SetError(err) if err != nil { - // 如果还没重试过,尝试 Ping 后重试一次 if !retried { if pingErr := db.Ping(); pingErr == nil { return that.queryWithRetry(query, true, args...) @@ -120,7 +128,9 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac processedArgs := that.processArgs(args) if that.testTx != nil { + if that.testMu != nil { that.testMu.Lock() } resl, e = that.testTx.Exec(query, processedArgs...) + if that.testMu != nil { that.testMu.Unlock() } } else if that.Tx != nil { resl, e = that.Tx.Exec(query, processedArgs...) } else { @@ -128,8 +138,11 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac } that.LastErr.SetError(e) - // 判断是否连接断开了,如果还没重试过,尝试重试一次 if e != nil { + // testTx 模式下不走连接池重试,避免 busy buffer + if that.testTx != nil { + return resl, that.LastErr + } if !retried { if pingErr := that.DB.Ping(); pingErr == nil { return that.execWithRetry(query, true, args...) diff --git a/db/transaction.go b/db/transaction.go index f1ca70d..65a57df 100644 --- a/db/transaction.go +++ b/db/transaction.go @@ -32,17 +32,24 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce mu: sync.RWMutex{}, limitMu: sync.Mutex{}, testTx: that.testTx, + testMu: that.testMu, } if that.testTx != nil { spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1)) + if that.testMu != nil { that.testMu.Lock() } _, _ = that.testTx.Exec("SAVEPOINT " + spName) + if that.testMu != nil { that.testMu.Unlock() } db.Tx = that.testTx isSuccess = action(db) if !isSuccess { + if that.testMu != nil { that.testMu.Lock() } _, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName) + if that.testMu != nil { that.testMu.Unlock() } } else { + if that.testMu != nil { that.testMu.Lock() } _, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName) + if that.testMu != nil { that.testMu.Unlock() } } return isSuccess } diff --git a/session.go b/session.go index b511b09..7a7e9f0 100644 --- a/session.go +++ b/session.go @@ -1,10 +1,7 @@ package hotime import ( - "encoding/json" - "os" "sync" - "time" . "code.hoteas.com/golang/hotime/cache" . "code.hoteas.com/golang/hotime/common" @@ -21,15 +18,6 @@ type SessionIns struct { // set 保存 session 到缓存,必须在锁内调用或传入深拷贝的 map func (that *SessionIns) setWithCopy() { - // #region agent log - logFile, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if logFile != nil { - logEntry, _ := json.Marshal(map[string]interface{}{"sessionId": "debug-session", "runId": "run1", "hypothesisId": "A", "location": "session.go:setWithCopy", "message": "Session写入数据库触发", "data": map[string]interface{}{"session_id": that.SessionId, "map_size": len(that.Map)}, "timestamp": time.Now().UnixMilli()}) - logFile.Write(append(logEntry, '\n')) - logFile.Close() - } - // #endregion - // 深拷贝 Map 防止并发修改 that.mutex.RLock() copyMap := make(Map, len(that.Map)) @@ -49,15 +37,6 @@ func (that *SessionIns) Session(key string, data ...interface{}) *Obj { that.mutex.Unlock() if len(data) != 0 { - // #region agent log - logFile, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if logFile != nil { - logEntry, _ := json.Marshal(map[string]interface{}{"sessionId": "debug-session", "runId": "run1", "hypothesisId": "B", "location": "session.go:Session", "message": "Session.Set调用", "data": map[string]interface{}{"key": key, "is_delete": data[0] == nil}, "timestamp": time.Now().UnixMilli()}) - logFile.Write(append(logEntry, '\n')) - logFile.Close() - } - // #endregion - that.mutex.Lock() if data[0] == nil { delete(that.Map, key) @@ -85,19 +64,6 @@ func (that *SessionIns) SessionsSet(data Map) { return } - // #region agent log - logFile, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if logFile != nil { - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - logEntry, _ := json.Marshal(map[string]interface{}{"sessionId": "debug-session", "runId": "run1", "hypothesisId": "C", "location": "session.go:SessionsSet", "message": "SessionsSet批量设置", "data": map[string]interface{}{"keys": keys, "count": len(data)}, "timestamp": time.Now().UnixMilli()}) - logFile.Write(append(logEntry, '\n')) - logFile.Close() - } - // #endregion - that.mutex.Lock() if that.Map == nil { that.getWithoutLock() @@ -124,15 +90,6 @@ func (that *SessionIns) SessionsDelete(keys ...string) { return } - // #region agent log - logFile, _ := os.OpenFile(`d:\work\hotimev1.5\.cursor\debug.log`, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if logFile != nil { - logEntry, _ := json.Marshal(map[string]interface{}{"sessionId": "debug-session", "runId": "run1", "hypothesisId": "C", "location": "session.go:SessionsDelete", "message": "SessionsDelete批量删除", "data": map[string]interface{}{"keys": keys, "count": len(keys)}, "timestamp": time.Now().UnixMilli()}) - logFile.Write(append(logEntry, '\n')) - logFile.Close() - } - // #endregion - that.mutex.Lock() if that.Map == nil { that.getWithoutLock() diff --git a/testing_helper.go b/testing_helper.go index 529c3e3..cd4703a 100644 --- a/testing_helper.go +++ b/testing_helper.go @@ -89,6 +89,10 @@ func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context } app.SetupForTest(router) + if app.HoTimeCache != nil { + app.HoTimeCache.DisableDbCache() + } + return &TestApp{ Application: app, projs: projects, diff --git a/testing_swagger.go b/testing_swagger.go index f97f1e7..9c816f3 100644 --- a/testing_swagger.go +++ b/testing_swagger.go @@ -27,8 +27,8 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { if outputDir == "" { outputDir = "tpt" } - swaggerDir := filepath.Join(outputDir, "swagger") - if err := os.MkdirAll(swaggerDir, os.ModePerm); err != nil { + swaggerRoot := filepath.Join(outputDir, "swagger") + if err := os.MkdirAll(swaggerRoot, os.ModePerm); err != nil { return fmt.Errorf("创建 swagger 目录失败: %w", err) } @@ -42,9 +42,13 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { pathRecords[r.Path] = append(pathRecords[r.Path], r) } - endpoints := []map[string]interface{}{} - for projName, projDef := range that.projs { + moduleDir := filepath.Join(swaggerRoot, projName) + if err := os.MkdirAll(moduleDir, os.ModePerm); err != nil { + return fmt.Errorf("创建模块目录 %s 失败: %w", projName, err) + } + + endpoints := []map[string]interface{}{} for ctrName, ctr := range projDef.Proj { for methodName := range ctr { apiPath := "/" + projName + "/" + ctrName + "/" + methodName @@ -99,33 +103,91 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error { }) } } + + sort.Slice(endpoints, func(i, j int) bool { + return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) + }) + + spec := map[string]interface{}{ + "title": title, + "version": version, + "endpoints": endpoints, + } + + specJSON, err := json.MarshalIndent(spec, "", " ") + if err != nil { + return fmt.Errorf("序列化 JSON 失败: %w", err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "api-spec.json"), specJSON, os.ModePerm); err != nil { + return fmt.Errorf("写入 %s/api-spec.json 失败: %w", projName, err) + } + if err := os.WriteFile(filepath.Join(moduleDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { + return fmt.Errorf("写入 %s/index.html 失败: %w", projName, err) + } + fmt.Printf("Swagger 文档已生成: %s\n", moduleDir) } - sort.Slice(endpoints, func(i, j int) bool { - return endpoints[i]["path"].(string) < endpoints[j]["path"].(string) - }) - - spec := map[string]interface{}{ - "title": title, - "version": version, - "endpoints": endpoints, + if err := generateSwaggerPortal(swaggerRoot); err != nil { + return fmt.Errorf("生成导航页失败: %w", err) } - - specJSON, err := json.MarshalIndent(spec, "", " ") - if err != nil { - return fmt.Errorf("序列化 JSON 失败: %w", err) - } - if err := os.WriteFile(filepath.Join(swaggerDir, "api-spec.json"), specJSON, os.ModePerm); err != nil { - return fmt.Errorf("写入 api-spec.json 失败: %w", err) - } - if err := os.WriteFile(filepath.Join(swaggerDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil { - return fmt.Errorf("写入 index.html 失败: %w", err) - } - - fmt.Printf("Swagger 文档已生成: %s\n", swaggerDir) return nil } +func generateSwaggerPortal(swaggerRoot string) error { + entries, err := os.ReadDir(swaggerRoot) + if err != nil { + return err + } + + var modules []struct{ Name, Title string } + for _, e := range entries { + if !e.IsDir() { + continue + } + specPath := filepath.Join(swaggerRoot, e.Name(), "api-spec.json") + title := e.Name() + if data, err := os.ReadFile(specPath); err == nil { + var spec map[string]interface{} + if json.Unmarshal(data, &spec) == nil { + if t, ok := spec["title"].(string); ok && t != "" { + title = t + } + } + } + modules = append(modules, struct{ Name, Title string }{e.Name(), title}) + } + sort.Slice(modules, func(i, j int) bool { return modules[i].Name < modules[j].Name }) + + html := swaggerPortalHTML(modules) + return os.WriteFile(filepath.Join(swaggerRoot, "index.html"), []byte(html), os.ModePerm) +} + +func swaggerPortalHTML(modules []struct{ Name, Title string }) string { + var cards string + for _, m := range modules { + cards += `
` + strings.ToUpper(m.Name[:1]) + m.Name[1:] + `
` + m.Title + `
` + m.Name + `/
` + } + return ` +API 文档中心 + +

API 文档中心

+
选择一个模块查看接口文档与调试控制台
+
` + cards + `
+` +} + func apiConsoleHTML() string { return ` API 调试控制台 @@ -217,6 +279,8 @@ pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;l + +
@@ -238,10 +302,15 @@ fetch('./api-spec.json').then(r=>r.json()).then(d=>{ D=d;document.getElementById('dt').textContent=d.title+' v'+d.version;document.title=d.title;render(); }); +function epPass(e){if(!e.cases?.length)return false;return e.cases.every(x=>x.passed);} +function epFail(e){if(!e.cases?.length)return e.tested;return e.cases.some(x=>!x.passed);} function render(){ if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={}; for(const e of D.endpoints){ - if(F==='yes'&&!e.tested||F==='no'&&e.tested)continue; + if(F==='yes'&&!e.tested)continue; + if(F==='no'&&e.tested)continue; + if(F==='pass'&&!epPass(e))continue; + if(F==='fail'&&!epFail(e))continue; if(q&&!e.summary.toLowerCase().includes(q)&&!e.path.toLowerCase().includes(q)&&!e.ctr.toLowerCase().includes(q))continue; if(!T[e.project])T[e.project]={};if(!T[e.project][e.ctr])T[e.project][e.ctr]=[];T[e.project][e.ctr].push(e); } @@ -254,7 +323,7 @@ function render(){ const mn=e.path.split('/').pop();const lbl=e.tested&&e.summary!==mn?mn+' '+e.summary:e.summary; eh+='
'+e.method+''+lbl+''+tg+'
'; } - ph+='
'+c+''+es.length+'
'+eh+'
'; + ph+='
'+c+''+es.length+'
'+eh+'
'; } h+='
'+p+'
'+ph+'
'; } @@ -434,7 +503,7 @@ async function send(){ if(token&&authType==='header')opts.headers['Authorization']=token; const fileEls=document.querySelectorAll('#file-rows .kv'); - const files=[];fileEls.forEach(r=>{const fn=r.querySelector('input[type="text"]').value.trim();const fi=r.querySelector('input[type="file"]');if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});}); + const files=[];fileEls.forEach(r=>{const fnEl=r.querySelector('input:not([type="file"])');const fi=r.querySelector('input[type="file"]');if(!fnEl||!fi)return;const fn=fnEl.value.trim();if(fn&&fi.files.length)files.push({field:fn,file:fi.files[0]});}); const isJson=document.getElementById('bt-json')?.classList.contains('on'); const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';