refactor(db): 增强测试事务和缓存管理
- 在 HoTimeDB 中新增 testMu 互斥锁,确保 testTx 的串行访问,解决并发冲突问题 - 在 BeginTestTx 中初始化 testMu,确保测试事务的安全性 - 在 HoTimeCache 中新增 DisableDbCache 方法,测试模式下禁用 DB 和 Redis 缓存,避免锁等待超时 - 更新 Select 方法,确保在 testTx 激活时跳过缓存逻辑,提升测试稳定性 - 优化 Swagger 生成逻辑,支持模块化输出和导航页生成 - 移除冗余的调试日志代码,提升代码整洁性
This commit is contained in:
@@ -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
|
## 二、链式测试 API
|
||||||
|
|
||||||
@@ -168,13 +247,45 @@ func TestApi(t *testing.T) {
|
|||||||
|
|
||||||
## 五、文件改动汇总
|
## 五、文件改动汇总
|
||||||
|
|
||||||
- **修改** `d:/work/hotimev1.5/db/db.go` -- 加 testTx 字段 + BeginTestTx/RollbackTestTx
|
### hotimev1.5 框架层
|
||||||
- **修改** `d:/work/hotimev1.5/db/query.go` -- Query/Exec 各加 testTx 判断
|
- **修改** `db/db.go` -- 加 testTx + testMu 字段,BeginTestTx(初始化 mutex)/RollbackTestTx
|
||||||
- **修改** `d:/work/hotimev1.5/db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx 传递
|
- **修改** `db/query.go` -- Query/Exec 各加 testTx 判断 + testMu 互斥锁保护 + 跳过重试逻辑
|
||||||
- **新增** `d:/work/hotimev1.5/testing_helper.go` -- TestApp、RunTests、覆盖率
|
- **修改** `db/transaction.go` -- Action 加 SAVEPOINT 分支 + testTx/testMu 传递 + SAVEPOINT 操作加锁
|
||||||
- **新增** `d:/work/hotimev1.5/testing_api.go` -- 链式 API + TestCollector
|
- **修改** `db/crud.go` -- Select 中 testTx 激活时跳过缓存逻辑
|
||||||
- **新增** `d:/work/hotimev1.5/testing_swagger.go` -- Swagger 生成
|
- **修改** `cache/cache.go` -- 新增 DisableDbCache() 方法 + 新增 SessionsGet/SessionsSet/SessionsDelete 批量操作
|
||||||
- **修改** `d:/work/xbc/app/user.go` -- 末尾添加 UserTest
|
- **修改** `session.go` -- 对接批量 Session 缓存操作
|
||||||
- **修改** `d:/work/xbc/app/init.go` -- 添加 ProjectTest
|
- **修改** `testing_helper.go` -- NewTestApp 中调用 DisableDbCache()
|
||||||
- **新增** `d:/work/xbc/app/app_test.go` -- 测试入口
|
- **新增** `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`
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
-116
@@ -1,36 +1,11 @@
|
|||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "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与数据库缓存设置项一致,
|
// HoTimeCache 可配置memory,db,redis,默认启用memory,默认优先级为memory>redis>db,memory与数据库缓存设置项一致,
|
||||||
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
// 缓存数据填充会自动反方向反哺,加入memory缓存过期将自动从redis更新,但memory永远不会更新redis,如果是集群建议不要开启memory,配置即启用
|
||||||
type HoTimeCache struct {
|
type HoTimeCache struct {
|
||||||
@@ -214,26 +189,12 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map {
|
|||||||
return 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))
|
result := make(Map, len(keys))
|
||||||
missingKeys := keys
|
missingKeys := keys
|
||||||
|
|
||||||
// 从 memory 获取
|
// 从 memory 获取
|
||||||
if that.memoryCache != nil && that.memoryCache.SessionSet {
|
if that.memoryCache != nil && that.memoryCache.SessionSet {
|
||||||
memResult := that.memoryCache.CachesGet(keys)
|
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 {
|
for k, v := range memResult {
|
||||||
result[k] = v
|
result[k] = v
|
||||||
}
|
}
|
||||||
@@ -249,20 +210,9 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map {
|
|||||||
// 从 redis 获取未命中的
|
// 从 redis 获取未命中的
|
||||||
if len(missingKeys) > 0 && that.redisCache != nil && that.redisCache.SessionSet {
|
if len(missingKeys) > 0 && that.redisCache != nil && that.redisCache.SessionSet {
|
||||||
redisResult := that.redisCache.CachesGet(missingKeys)
|
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
|
// 反哺到 memory
|
||||||
if that.memoryCache != nil && that.memoryCache.SessionSet && len(redisResult) > 0 {
|
if that.memoryCache != nil && that.memoryCache.SessionSet && len(redisResult) > 0 {
|
||||||
that.memoryCache.CachesSet(redisResult)
|
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 {
|
for k, v := range redisResult {
|
||||||
result[k] = v
|
result[k] = v
|
||||||
@@ -280,29 +230,13 @@ func (that *HoTimeCache) SessionsGet(keys []string) Map {
|
|||||||
// 从 db 获取未命中的
|
// 从 db 获取未命中的
|
||||||
if len(missingKeys) > 0 && that.dbCache != nil && that.dbCache.SessionSet {
|
if len(missingKeys) > 0 && that.dbCache != nil && that.dbCache.SessionSet {
|
||||||
dbResult := that.dbCache.CachesGet(missingKeys)
|
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
|
// 反哺到 memory 和 redis
|
||||||
if len(dbResult) > 0 {
|
if len(dbResult) > 0 {
|
||||||
if that.memoryCache != nil && that.memoryCache.SessionSet {
|
if that.memoryCache != nil && that.memoryCache.SessionSet {
|
||||||
that.memoryCache.CachesSet(dbResult)
|
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 {
|
if that.redisCache != nil && that.redisCache.SessionSet {
|
||||||
that.redisCache.CachesSet(dbResult)
|
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 {
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,37 +254,15 @@ func (that *HoTimeCache) SessionsSet(data Map) {
|
|||||||
return
|
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 {
|
if that.memoryCache != nil && that.memoryCache.SessionSet {
|
||||||
that.memoryCache.CachesSet(data)
|
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 {
|
if that.redisCache != nil && that.redisCache.SessionSet {
|
||||||
that.redisCache.CachesSet(data)
|
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 {
|
if that.dbCache != nil && that.dbCache.SessionSet {
|
||||||
that.dbCache.CachesSet(data)
|
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 缓存
|
// SessionsDelete 批量删除 Session 缓存
|
||||||
@@ -365,37 +271,15 @@ func (that *HoTimeCache) SessionsDelete(keys []string) {
|
|||||||
return
|
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 {
|
if that.memoryCache != nil && that.memoryCache.SessionSet {
|
||||||
that.memoryCache.CachesDelete(keys)
|
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 {
|
if that.redisCache != nil && that.redisCache.SessionSet {
|
||||||
that.redisCache.CachesDelete(keys)
|
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 {
|
if that.dbCache != nil && that.dbCache.SessionSet {
|
||||||
that.dbCache.CachesDelete(keys)
|
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 批量获取普通缓存
|
// 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
@@ -135,23 +135,20 @@ func (that *HoTimeDB) Select(table string, qu ...interface{}) []Map {
|
|||||||
qs = append(qs, resWhere...)
|
qs = append(qs, resWhere...)
|
||||||
md5 := that.md5(query, qs...)
|
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)
|
cacheData := that.HoTimeCache.Db(table + ":" + md5)
|
||||||
if cacheData != nil && cacheData.Data != nil {
|
if cacheData != nil && cacheData.Data != nil {
|
||||||
return cacheData.ToMapArray()
|
return cacheData.ToMapArray()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无缓存则数据库取
|
|
||||||
res := that.Query(query, qs...)
|
res := that.Query(query, qs...)
|
||||||
|
|
||||||
if res == nil {
|
if res == nil {
|
||||||
res = []Map{}
|
res = []Map{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存
|
if that.testTx == nil && that.HoTimeCache != nil && table != "cached" {
|
||||||
if that.HoTimeCache != nil && table != "cached" {
|
|
||||||
_ = that.HoTimeCache.Db(table+":"+md5, res)
|
_ = that.HoTimeCache.Db(table+":"+md5, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ type HoTimeDB struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
limitMu sync.Mutex
|
limitMu sync.Mutex
|
||||||
Dialect Dialect // 数据库方言适配器
|
Dialect Dialect // 数据库方言适配器
|
||||||
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
testTx *sql.Tx // 测试事务:设置后所有操作都在此事务内,测试结束回滚
|
||||||
|
testMu *sync.Mutex // 保护 testTx 单连接不被并发访问
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConnect 设置数据库配置连接
|
// SetConnect 设置数据库配置连接
|
||||||
@@ -154,6 +155,7 @@ func (that *HoTimeDB) BeginTestTx() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
that.testTx = tx
|
that.testTx = tx
|
||||||
|
that.testMu = &sync.Mutex{}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -62,7 +62,16 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa
|
|||||||
processedArgs := that.processArgs(args)
|
processedArgs := that.processArgs(args)
|
||||||
|
|
||||||
if that.testTx != nil {
|
if that.testTx != nil {
|
||||||
|
if that.testMu != nil { that.testMu.Lock() }
|
||||||
resl, err = that.testTx.Query(query, processedArgs...)
|
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 {
|
} else if that.Tx != nil {
|
||||||
resl, err = that.Tx.Query(query, processedArgs...)
|
resl, err = that.Tx.Query(query, processedArgs...)
|
||||||
} else {
|
} else {
|
||||||
@@ -71,7 +80,6 @@ func (that *HoTimeDB) queryWithRetry(query string, retried bool, args ...interfa
|
|||||||
|
|
||||||
that.LastErr.SetError(err)
|
that.LastErr.SetError(err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 如果还没重试过,尝试 Ping 后重试一次
|
|
||||||
if !retried {
|
if !retried {
|
||||||
if pingErr := db.Ping(); pingErr == nil {
|
if pingErr := db.Ping(); pingErr == nil {
|
||||||
return that.queryWithRetry(query, true, args...)
|
return that.queryWithRetry(query, true, args...)
|
||||||
@@ -120,7 +128,9 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac
|
|||||||
processedArgs := that.processArgs(args)
|
processedArgs := that.processArgs(args)
|
||||||
|
|
||||||
if that.testTx != nil {
|
if that.testTx != nil {
|
||||||
|
if that.testMu != nil { that.testMu.Lock() }
|
||||||
resl, e = that.testTx.Exec(query, processedArgs...)
|
resl, e = that.testTx.Exec(query, processedArgs...)
|
||||||
|
if that.testMu != nil { that.testMu.Unlock() }
|
||||||
} else if that.Tx != nil {
|
} else if that.Tx != nil {
|
||||||
resl, e = that.Tx.Exec(query, processedArgs...)
|
resl, e = that.Tx.Exec(query, processedArgs...)
|
||||||
} else {
|
} else {
|
||||||
@@ -128,8 +138,11 @@ func (that *HoTimeDB) execWithRetry(query string, retried bool, args ...interfac
|
|||||||
}
|
}
|
||||||
|
|
||||||
that.LastErr.SetError(e)
|
that.LastErr.SetError(e)
|
||||||
// 判断是否连接断开了,如果还没重试过,尝试重试一次
|
|
||||||
if e != nil {
|
if e != nil {
|
||||||
|
// testTx 模式下不走连接池重试,避免 busy buffer
|
||||||
|
if that.testTx != nil {
|
||||||
|
return resl, that.LastErr
|
||||||
|
}
|
||||||
if !retried {
|
if !retried {
|
||||||
if pingErr := that.DB.Ping(); pingErr == nil {
|
if pingErr := that.DB.Ping(); pingErr == nil {
|
||||||
return that.execWithRetry(query, true, args...)
|
return that.execWithRetry(query, true, args...)
|
||||||
|
|||||||
@@ -32,17 +32,24 @@ func (that *HoTimeDB) Action(action func(db HoTimeDB) (isSuccess bool)) (isSucce
|
|||||||
mu: sync.RWMutex{},
|
mu: sync.RWMutex{},
|
||||||
limitMu: sync.Mutex{},
|
limitMu: sync.Mutex{},
|
||||||
testTx: that.testTx,
|
testTx: that.testTx,
|
||||||
|
testMu: that.testMu,
|
||||||
}
|
}
|
||||||
|
|
||||||
if that.testTx != nil {
|
if that.testTx != nil {
|
||||||
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
|
spName := fmt.Sprintf("sp_%d", atomic.AddUint64(&savepointCounter, 1))
|
||||||
|
if that.testMu != nil { that.testMu.Lock() }
|
||||||
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
_, _ = that.testTx.Exec("SAVEPOINT " + spName)
|
||||||
|
if that.testMu != nil { that.testMu.Unlock() }
|
||||||
db.Tx = that.testTx
|
db.Tx = that.testTx
|
||||||
isSuccess = action(db)
|
isSuccess = action(db)
|
||||||
if !isSuccess {
|
if !isSuccess {
|
||||||
|
if that.testMu != nil { that.testMu.Lock() }
|
||||||
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
_, _ = that.testTx.Exec("ROLLBACK TO SAVEPOINT " + spName)
|
||||||
|
if that.testMu != nil { that.testMu.Unlock() }
|
||||||
} else {
|
} else {
|
||||||
|
if that.testMu != nil { that.testMu.Lock() }
|
||||||
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
_, _ = that.testTx.Exec("RELEASE SAVEPOINT " + spName)
|
||||||
|
if that.testMu != nil { that.testMu.Unlock() }
|
||||||
}
|
}
|
||||||
return isSuccess
|
return isSuccess
|
||||||
}
|
}
|
||||||
|
|||||||
-43
@@ -1,10 +1,7 @@
|
|||||||
package hotime
|
package hotime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
. "code.hoteas.com/golang/hotime/cache"
|
. "code.hoteas.com/golang/hotime/cache"
|
||||||
. "code.hoteas.com/golang/hotime/common"
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
@@ -21,15 +18,6 @@ type SessionIns struct {
|
|||||||
|
|
||||||
// set 保存 session 到缓存,必须在锁内调用或传入深拷贝的 map
|
// set 保存 session 到缓存,必须在锁内调用或传入深拷贝的 map
|
||||||
func (that *SessionIns) setWithCopy() {
|
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 防止并发修改
|
// 深拷贝 Map 防止并发修改
|
||||||
that.mutex.RLock()
|
that.mutex.RLock()
|
||||||
copyMap := make(Map, len(that.Map))
|
copyMap := make(Map, len(that.Map))
|
||||||
@@ -49,15 +37,6 @@ func (that *SessionIns) Session(key string, data ...interface{}) *Obj {
|
|||||||
that.mutex.Unlock()
|
that.mutex.Unlock()
|
||||||
|
|
||||||
if len(data) != 0 {
|
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()
|
that.mutex.Lock()
|
||||||
if data[0] == nil {
|
if data[0] == nil {
|
||||||
delete(that.Map, key)
|
delete(that.Map, key)
|
||||||
@@ -85,19 +64,6 @@ func (that *SessionIns) SessionsSet(data Map) {
|
|||||||
return
|
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()
|
that.mutex.Lock()
|
||||||
if that.Map == nil {
|
if that.Map == nil {
|
||||||
that.getWithoutLock()
|
that.getWithoutLock()
|
||||||
@@ -124,15 +90,6 @@ func (that *SessionIns) SessionsDelete(keys ...string) {
|
|||||||
return
|
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()
|
that.mutex.Lock()
|
||||||
if that.Map == nil {
|
if that.Map == nil {
|
||||||
that.getWithoutLock()
|
that.getWithoutLock()
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context
|
|||||||
}
|
}
|
||||||
app.SetupForTest(router)
|
app.SetupForTest(router)
|
||||||
|
|
||||||
|
if app.HoTimeCache != nil {
|
||||||
|
app.HoTimeCache.DisableDbCache()
|
||||||
|
}
|
||||||
|
|
||||||
return &TestApp{
|
return &TestApp{
|
||||||
Application: app,
|
Application: app,
|
||||||
projs: projects,
|
projs: projects,
|
||||||
|
|||||||
+97
-28
@@ -27,8 +27,8 @@ func (that *TestApp) GenerateSwagger(title, version, outputDir string) error {
|
|||||||
if outputDir == "" {
|
if outputDir == "" {
|
||||||
outputDir = "tpt"
|
outputDir = "tpt"
|
||||||
}
|
}
|
||||||
swaggerDir := filepath.Join(outputDir, "swagger")
|
swaggerRoot := filepath.Join(outputDir, "swagger")
|
||||||
if err := os.MkdirAll(swaggerDir, os.ModePerm); err != nil {
|
if err := os.MkdirAll(swaggerRoot, os.ModePerm); err != nil {
|
||||||
return fmt.Errorf("创建 swagger 目录失败: %w", err)
|
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)
|
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints := []map[string]interface{}{}
|
|
||||||
|
|
||||||
for projName, projDef := range that.projs {
|
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 ctrName, ctr := range projDef.Proj {
|
||||||
for methodName := range ctr {
|
for methodName := range ctr {
|
||||||
apiPath := "/" + projName + "/" + ctrName + "/" + methodName
|
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 {
|
if err := generateSwaggerPortal(swaggerRoot); err != nil {
|
||||||
return endpoints[i]["path"].(string) < endpoints[j]["path"].(string)
|
return fmt.Errorf("生成导航页失败: %w", err)
|
||||||
})
|
|
||||||
|
|
||||||
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(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
|
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 {
|
func apiConsoleHTML() string {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><title>API 调试控制台</title>
|
<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 class="on" onclick="sf('all',this)">全部</button>
|
||||||
<button onclick="sf('yes',this)">已测试</button>
|
<button onclick="sf('yes',this)">已测试</button>
|
||||||
<button onclick="sf('no',this)">未测试</button>
|
<button onclick="sf('no',this)">未测试</button>
|
||||||
|
<button onclick="sf('pass',this)">通过</button>
|
||||||
|
<button onclick="sf('fail',this)">未通过</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="tree"></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();
|
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(){
|
function render(){
|
||||||
if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={};
|
if(!D)return;const q=document.getElementById('q').value.toLowerCase();const T={};
|
||||||
for(const e of D.endpoints){
|
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(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);
|
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;
|
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>';
|
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">▶</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">▶</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">▶</span>'+p+'</div><div class="sub o">'+ph+'</div></div>';
|
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">▶</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;
|
if(token&&authType==='header')opts.headers['Authorization']=token;
|
||||||
|
|
||||||
const fileEls=document.querySelectorAll('#file-rows .kv');
|
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 isJson=document.getElementById('bt-json')?.classList.contains('on');
|
||||||
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
|
const bodyEl=document.getElementById('xb');const formKV=getKV('f-rows');let curlParts='';
|
||||||
|
|||||||
Reference in New Issue
Block a user