refactor(db): 增强测试事务和缓存管理

- 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题
- 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性
- 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时
- 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性
- 优化 Swagger 生成逻辑,支持模块化输出和导航页生成
- 移除冗余的调试日志代码,提升代码整洁性
This commit is contained in:
2026-03-14 13:06:32 +08:00
parent bd20af0c89
commit 87098a9180
9 changed files with 254 additions and 205 deletions
@@ -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.Printfgo vet 警告)
- **修改** `customer/init.go` -- 修复 log.Println -> log.Printfgo 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`
+5 -116
View File
@@ -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 可配置memorydbredis,默认启用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
}
+2 -5
View File
@@ -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)
}
+2
View File
@@ -33,6 +33,7 @@ type HoTimeDB struct {
limitMu sync.Mutex
Dialect Dialect // 数据库方言适配器
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
}
+15 -2
View File
@@ -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...)
+7
View File
@@ -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
}
-43
View File
@@ -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()
+4
View File
@@ -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,
+82 -13
View File
@@ -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,7 +103,6 @@ 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)
@@ -115,17 +118,76 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
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(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(swaggerDir, "index.html"), []byte(apiConsoleHTML()), os.ModePerm); err != nil {
return fmt.Errorf("写入 index.html 失败: %w", 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)
}
fmt.Printf("Swagger 文档已生成: %s\n", swaggerDir)
if err := generateSwaggerPortal(swaggerRoot); err != nil {
return fmt.Errorf("生成导航页失败: %w", err)
}
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 += `<a class="card" href="` + m.Name + `/index.html"><div class="icon">` + strings.ToUpper(m.Name[:1]) + m.Name[1:] + `</div><div class="title">` + m.Title + `</div><div class="sub">` + m.Name + `/</div></a>`
}
return `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 文档中心</title>
<style>
:root{--bg:#1a1a2e;--bg2:#16213e;--bg3:#0f3460;--fg:#e0e0e0;--fg2:#888;--accent:#4fc3f7;--border:#2a2a4a}
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,sans-serif;display:flex;flex-direction:column;align-items:center;padding:60px 20px}
h1{font-size:24px;color:var(--accent);margin-bottom:8px}
.desc{color:var(--fg2);margin-bottom:40px;font-size:13px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px;width:100%;max-width:900px}
.card{display:block;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:24px 20px;text-decoration:none;color:var(--fg);transition:all .2s}
.card:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 4px 20px rgba(79,195,247,.15)}
.icon{font-size:20px;font-weight:700;color:var(--accent);margin-bottom:8px}
.title{font-size:15px;font-weight:600;margin-bottom:4px}
.sub{font-size:12px;color:var(--fg2)}
</style></head><body>
<h1>API 文档中心</h1>
<div class="desc">选择一个模块查看接口文档与调试控制台</div>
<div class="grid">` + cards + `</div>
</body></html>`
}
func apiConsoleHTML() string {
return `<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 调试控制台</title>
@@ -217,6 +279,8 @@ pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;l
<button class="on" onclick="sf('all',this)">全部</button>
<button onclick="sf('yes',this)">已测试</button>
<button onclick="sf('no',this)">未测试</button>
<button onclick="sf('pass',this)">通过</button>
<button onclick="sf('fail',this)">未通过</button>
</div>
</div>
<div id="tree"></div>
@@ -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+='<div class="ep'+(C?.path===e.path?' act':'')+'" data-p="'+e.path+'" onclick="go(this)"><span class="mt mt-'+m+'">'+e.method+'</span><span class="nm" title="'+e.path+'">'+lbl+'</span>'+tg+'</div>';
}
ph+='<div class="l2h" onclick="tog(this)"><span class="ar o">&#9654;</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub o">'+eh+'</div>';
ph+='<div class="l2h" onclick="tog(this)"><span class="ar">&#9654;</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub">'+eh+'</div>';
}
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">&#9654;</span>'+p+'</div><div class="sub o">'+ph+'</div></div>';
}
@@ -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='';