Compare commits
327 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e08b20698 | |||
| c8382200a0 | |||
| b951ba027e | |||
| a0719dc72d | |||
| 332c2414bc | |||
| 853940c795 | |||
| b5b764e3f9 | |||
| 25faa93724 | |||
| 831a7f017a | |||
| b254606003 | |||
| 412f08a048 | |||
| ec9d25fef6 | |||
| a600fa8a15 | |||
| f53d406110 | |||
| 8a1a27b457 | |||
| 5a4fd288bb | |||
| c2ce1955f9 | |||
| 16496a8dc0 | |||
| 9fe4ab2758 | |||
| c2a59e19c9 | |||
| 6b689f3a1b | |||
| e14c9742c1 | |||
| 8b095f87ea | |||
| 1e77dde6f0 | |||
| 7fdf5181da | |||
| 94a251ec44 | |||
| ef10437a5a | |||
| 66dee150c7 | |||
| f22eea72ec | |||
| 0340bc4b8e | |||
| d03ed65d41 | |||
| 10428b6da4 | |||
| 66f18a084a | |||
| 3430bdec19 | |||
| 9a9b9c83ff | |||
| 86daa3153e | |||
| 277e244e80 | |||
| 5a1b5e4fee | |||
| f787421f82 | |||
| cf8ff2b838 | |||
| f7c3a6c1c8 | |||
| 63360f9a14 | |||
| 17e1090077 | |||
| b3f263c965 | |||
| 37a67f5810 | |||
| 3681564ca4 | |||
| 37c6a00c0a | |||
| 70012032f0 | |||
| 813e26755f | |||
| a7395b08de | |||
| 5e2bd765bf | |||
| 6fda351585 | |||
| 7f7b585ffb | |||
| 1546967918 | |||
| 47e13b532b | |||
| 71e415dfe1 | |||
| 87098a9180 | |||
| bd20af0c89 | |||
| fab7931d3c | |||
| bf9bb5807d | |||
| 2d9e6e8e12 | |||
| 40eedccbd2 | |||
| 4940232b24 | |||
| a11331e08d | |||
| 8836e7a88d | |||
| 431318bf8a | |||
| cce765e735 | |||
| 64c2e800b0 | |||
| e704701752 | |||
| 5235ef133d | |||
| c3b8d15b54 | |||
| c4d0e16f2c | |||
| ebe590ca9b | |||
| 60a12a09bd | |||
| 595275e747 | |||
| fdf02d4927 | |||
| beebdfbbfa | |||
| 05d1bb6b48 | |||
| a770c52794 | |||
| 40f6783036 | |||
| 7843f7e8d6 | |||
| 2ecb12bd7d | |||
| e5e615717a | |||
| 6db8f75ec7 | |||
| 754668d017 | |||
| 544207a2e0 | |||
| 68ea5d5203 | |||
| 734d1b4be9 | |||
| c7a34990e3 | |||
| 6db22aa4fd | |||
| f7493a1217 | |||
| 61b3c5c7cc | |||
| f196e8a43a | |||
| 05eb8d39db | |||
| 2983934477 | |||
| 3d9666b630 | |||
| b8b543175d | |||
| 3a1828be07 | |||
| f043597300 | |||
| 6542a7316a | |||
| 57c0658da0 | |||
| 1f26cd2a50 | |||
| 1a7e92d524 | |||
| ea932a032b | |||
| 56f4911459 | |||
| 735c088a0a | |||
| 80db372857 | |||
| 8d1ad4499d | |||
| 458cefd0ac | |||
| 0d40fb5114 | |||
| 9ac5abe884 | |||
| 22a1f19e6a | |||
| 91a3fe2413 | |||
| b60b863af2 | |||
| 07637a635c | |||
| 36cf708983 | |||
| 8ab5c9f3e3 | |||
| e4686fd77d | |||
| 06714a1968 | |||
| 6fa8e66aaf | |||
| c7494015dc | |||
| 32d8c43e1f | |||
| ae6f2195b0 | |||
| 72d68d1738 | |||
| c959402250 | |||
| 340a90278b | |||
| 312e92004d | |||
| ce9d8d055c | |||
| 41f455cf4c | |||
| febd8d9805 | |||
| 60667734a7 | |||
| cf29db5d5c | |||
| 62a367ad5b | |||
| f5a24aa173 | |||
| 67d1450848 | |||
| 559e68a0b2 | |||
| a596b02d43 | |||
| 8ae4ccc126 | |||
| 0b1116ba9e | |||
| e8b391b0a2 | |||
| 4097a44e58 | |||
| 64daf04f35 | |||
| 97cca14b45 | |||
| 78539b9dbc | |||
| df5f8f158b | |||
| ca947bb9e0 | |||
| 899c1c340d | |||
| fab3d86519 | |||
| 48ee95ca67 | |||
| 9d31f2d1f8 | |||
| ffa32105f2 | |||
| 3da135e3cb | |||
| 4e24a913c6 | |||
| ed0944618f | |||
| e393d258a8 | |||
| c91c610d2d | |||
| 7d74ebf4e4 | |||
| abc7eaf529 | |||
| a1250e15c5 | |||
| f6d3aa800c | |||
| 854df4fa7d | |||
| 6c92a21c3b | |||
| 4b641cd09d | |||
| 497c4b028f | |||
| e37b7b586b | |||
| 15b73b3ce7 | |||
| 83acef93a9 | |||
| f5af9424f0 | |||
| 5d4d1e693b | |||
| 88372e992e | |||
| 3b558b22ff | |||
| 34e032105d | |||
| 5c2e642bd1 | |||
| 65ece0418e | |||
| c3a24eeb3c | |||
| 61d5f410da | |||
| f6f48a3f8d | |||
| 771b02bd61 | |||
| 72955466d5 | |||
| 8b7bf8a150 | |||
| dbabee7a8a | |||
| 71fb533950 | |||
| cbcfd735ca | |||
| e4f354d26f | |||
| 20d8926d05 | |||
| 0f2b47d6d9 | |||
| 2034e8e641 | |||
| f53a3108a9 | |||
| 6a35466221 | |||
| 243dd12747 | |||
| 5b453d7c5d | |||
| 06e6ceab6a | |||
| 4365fa6241 | |||
| 8fa8858b47 | |||
| 64df6c9f03 | |||
| 95ac340186 | |||
| 93353f618a | |||
| f2643222ca | |||
| abecca89c5 | |||
| 3a6b0f8647 | |||
| fa2545c2c8 | |||
| eaa6a90c0e | |||
| a534e74f7d | |||
| dec56b75d2 | |||
| 23b398e705 | |||
| e0752218ea | |||
| 25ab6c219d | |||
| 4cbc84063a | |||
| aea2e0241d | |||
| eb41162f3b | |||
| b37546bee2 | |||
| f0d4efd8fc | |||
| 0fd7500e34 | |||
| ce8059b0a0 | |||
| 7d36ad95a7 | |||
| 58da542533 | |||
| 0b09a13c17 | |||
| 062e849d44 | |||
| bdcec2d534 | |||
| 7b8abc9daa | |||
| 7d4358af83 | |||
| cac15ade74 | |||
| 25b12142de | |||
| 9c8190b54e | |||
| f1244091fc | |||
| 10b73bef8f | |||
| 1113c1eb43 | |||
| 31955fce52 | |||
| cfa668a5f3 | |||
| 3fdc3aac2e | |||
| c368619e51 | |||
| f1d066811d | |||
| 49afe5f714 | |||
| cd23ed85c2 | |||
| 045fd3a38c | |||
| 7da5f26098 | |||
| 02fc1d7843 | |||
| cb0bcdb2d1 | |||
| e426c285b0 | |||
| 716b3dfa6b | |||
| 369c06724a | |||
| 17ac1a9dcf | |||
| 8befd195c0 | |||
| ac027c6e42 | |||
| 7be976cdeb | |||
| b18bc1ced8 | |||
| 2b8816c54b | |||
| cecefa1227 | |||
| 730a2e41f5 | |||
| e7b9d7d100 | |||
| e200e0a370 | |||
| b5f6a33f07 | |||
| 6512f122f5 | |||
| cac0214271 | |||
| e4ef9230c2 | |||
| d90591af75 | |||
| 91bf142782 | |||
| fbe97b4461 | |||
| eb25417104 | |||
| 42f91d57ff | |||
| 2a98f0dcad | |||
| 32319d52f0 | |||
| 3e94cf8def | |||
| 6a46d06e81 | |||
| d4759bb03c | |||
| 9187960f66 | |||
| 7c2cc695b7 | |||
| 3b11f44915 | |||
| cea7e57409 | |||
| 29a84db736 | |||
| 74ec6e619a | |||
| b42e73ec50 | |||
| f31262559d | |||
| a8d7b934f3 | |||
| 865e14e121 | |||
| a3f97c04b3 | |||
| 5c5234016c | |||
| c222570eb2 | |||
| 816b57d4e3 | |||
| 8fce77b643 | |||
| fa50191741 | |||
| 3b3ca6facb | |||
| d3b355836d | |||
| a47473f245 | |||
| 9ce4f1c603 | |||
| 717ddeaea3 | |||
| d656f3689e | |||
| 6e32f05087 | |||
| 1c31be2807 | |||
| 5b00a69496 | |||
| 55e60ac0eb | |||
| 3ce77221f8 | |||
| 6fe5d0ba55 | |||
| d1d4d04415 | |||
| 62cbf28b87 | |||
| ac66331535 | |||
| 9482404265 | |||
| 7b85da4c7f | |||
| db013b9966 | |||
| 44e46982f5 | |||
| 02911fd39e | |||
| 3a706077dd | |||
| 219f93b5ff | |||
| fb6f55c98d | |||
| f38e6f4397 | |||
| f62d169465 | |||
| 96b1795f05 | |||
| 2734e95096 | |||
| 101609e81b | |||
| 2cea20e7c9 | |||
| 340043d1f0 | |||
| 41defc022a | |||
| 830d69825d | |||
| 1f2a247398 | |||
| fdab298f52 | |||
| 6c999990e3 | |||
| 4433c3efc5 | |||
| 024b42bfa1 | |||
| 3efd5bd0a0 | |||
| e5dfe9aa8d | |||
| 62ba6e4f86 | |||
| faddd72fee | |||
| c8c5f453d5 | |||
| 49d956cc36 | |||
| cdefc0e9e3 | |||
| 9513b80a24 | |||
| acb7cdd4b8 |
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: HoTime Doc-Driven + TDD 总门禁
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# HoTime 工作流
|
||||
|
||||
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
|
||||
|
||||
## 定位
|
||||
|
||||
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
|
||||
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
|
||||
|
||||
## 改与测
|
||||
|
||||
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
|
||||
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
|
||||
|
||||
## 文档
|
||||
|
||||
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
|
||||
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
|
||||
|
||||
## 回复
|
||||
|
||||
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
|
||||
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
|
||||
|
||||
## 批量补文档(仅整包)
|
||||
|
||||
一文一题;最少 2 轮自检。日常小改不适用。
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
]
|
||||
}
|
||||
+11
@@ -2,10 +2,21 @@
|
||||
.idea
|
||||
/example/tpt/demo/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
/cover
|
||||
/coverage.out
|
||||
/coverage.html
|
||||
/example/config
|
||||
/config/
|
||||
/tpt/
|
||||
example/tpt/swagger/
|
||||
**/swagger/
|
||||
/.cursor/*.log
|
||||
*.sql
|
||||
*.py
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Cursor / agent debug NDJSON leftovers
|
||||
debug-*.log
|
||||
|
||||
+153
-29
@@ -2,7 +2,9 @@ package hotime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
stdlog "log"
|
||||
@@ -25,8 +27,8 @@ import (
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
logrus "github.com/sirupsen/logrus"
|
||||
"github.com/rs/zerolog"
|
||||
logrus "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
@@ -97,10 +99,124 @@ func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration)
|
||||
} else {
|
||||
that.Log.Infof("服务已安全关闭")
|
||||
}
|
||||
// 冲刷 Seq 队列,避免停机前后关键日志丢失
|
||||
that.Log.CloseSeq()
|
||||
if that.WebConnectLog != nil {
|
||||
that.WebConnectLog.CloseSeq()
|
||||
}
|
||||
os.Exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
// isLoopbackIP 判断是否为回环地址(空串视为不可用)
|
||||
func isLoopbackIP(ip string) bool {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return true
|
||||
}
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
return parsed.IsLoopback()
|
||||
}
|
||||
|
||||
// remoteHost 从 RemoteAddr 取出 host(去掉端口)
|
||||
func remoteHost(req *http.Request) string {
|
||||
if req == nil || req.RemoteAddr == "" {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||
if err == nil && host != "" {
|
||||
return host
|
||||
}
|
||||
return req.RemoteAddr
|
||||
}
|
||||
|
||||
// resolveClientIP 多源识别客户端 IP。
|
||||
// best:EO-Connecting-IP → XFF 第一个非回环 → 非回环 X-Real-IP → RemoteAddr
|
||||
// (XFF 先于 X-Real:反代误把 CDN 节点写进 X-Real-IP 时仍能落到客户端)
|
||||
// chain:各源头 IP 按首次出现顺序逗号拼接,同一 IP 只保留一次
|
||||
func resolveClientIP(req *http.Request) (best string, chain string) {
|
||||
if req == nil {
|
||||
return "", ""
|
||||
}
|
||||
eo := strings.TrimSpace(req.Header.Get("EO-Connecting-IP"))
|
||||
xri := strings.TrimSpace(req.Header.Get("X-Real-IP"))
|
||||
xffRaw := req.Header.Get("X-Forwarded-For")
|
||||
ra := remoteHost(req)
|
||||
|
||||
var xffParts []string
|
||||
if xffRaw != "" {
|
||||
for _, p := range strings.Split(xffRaw, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
xffParts = append(xffParts, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, 4)
|
||||
var ordered []string
|
||||
add := func(ip string) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
return
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
ordered = append(ordered, ip)
|
||||
}
|
||||
add(eo)
|
||||
add(xri)
|
||||
for _, p := range xffParts {
|
||||
add(p)
|
||||
}
|
||||
add(ra)
|
||||
chain = strings.Join(ordered, ",")
|
||||
|
||||
if eo != "" && !isLoopbackIP(eo) {
|
||||
return eo, chain
|
||||
}
|
||||
for _, p := range xffParts {
|
||||
if !isLoopbackIP(p) {
|
||||
return p, chain
|
||||
}
|
||||
}
|
||||
if xri != "" && !isLoopbackIP(xri) {
|
||||
return xri, chain
|
||||
}
|
||||
if ra != "" {
|
||||
return ra, chain
|
||||
}
|
||||
return "", chain
|
||||
}
|
||||
|
||||
// clientIP 取客户端真实 IP(见 resolveClientIP)
|
||||
func clientIP(req *http.Request) string {
|
||||
ip, _ := resolveClientIP(req)
|
||||
return ip
|
||||
}
|
||||
|
||||
// truncateUA 按 rune 安全截断字符串到 max 长度,避免切坏多字节字符导致 JSON 转义问题
|
||||
func truncateUA(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// shortID 生成 n 字节随机 hex(n=6 → 12 位)
|
||||
func shortID(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return Md5(strconv.Itoa(Rand(10)))[:n*2]
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// Run 启动实例
|
||||
func (that *Application) Run(router Router) {
|
||||
//如果没有设置配置自动生成配置
|
||||
@@ -359,6 +475,8 @@ func (that *Application) SetConfig(configPath ...string) {
|
||||
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
||||
if that.WebConnectLog != nil {
|
||||
that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
||||
// 访问日志量大,挂 Seq 后控制台仍保持 Warn+(业务 Logger 已放宽到 Info+)
|
||||
that.WebConnectLog.SetConsoleMinLevel(zerolog.WarnLevel)
|
||||
}
|
||||
that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance)
|
||||
}
|
||||
@@ -438,10 +556,22 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
if err != nil {
|
||||
unescapeUrl = req.RequestURI
|
||||
}
|
||||
|
||||
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头
|
||||
requestId := shortID(6)
|
||||
sid := sessionId
|
||||
if len(sid) > 12 {
|
||||
sid = sid[:12]
|
||||
}
|
||||
w.Header().Set("X-Request-Id", requestId)
|
||||
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
|
||||
dbCopy := that.Db
|
||||
dbCopy.Log = reqLog
|
||||
|
||||
//访问实例
|
||||
context := Context{SessionIns: SessionIns{SessionId: sessionId, HoTimeCache: that.HoTimeCache},
|
||||
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &that.Db,
|
||||
HandlerStr: unescapeUrl}
|
||||
Resp: w, Req: req, Application: that, RouterString: s, Config: that.Config, Db: &dbCopy,
|
||||
HandlerStr: unescapeUrl, Logger: reqLog}
|
||||
//header默认设置
|
||||
header := w.Header()
|
||||
header.Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -455,26 +585,24 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
defer func() {
|
||||
//是否展示日志
|
||||
if that.WebConnectLog != nil {
|
||||
|
||||
//负载均衡优化
|
||||
ipStr := ""
|
||||
if req.Header.Get("X-Forwarded-For") != "" {
|
||||
ipStr = req.Header.Get("X-Forwarded-For")
|
||||
} else if req.Header.Get("X-Real-IP") != "" {
|
||||
ipStr = req.Header.Get("X-Real-IP")
|
||||
}
|
||||
//负载均衡优化
|
||||
if ipStr == "" {
|
||||
//RemoteAddr := that.Req.RemoteAddr
|
||||
ipStr = Substr(context.Req.RemoteAddr, 0, strings.Index(context.Req.RemoteAddr, ":"))
|
||||
}
|
||||
|
||||
that.WebConnectLog.Info().
|
||||
Str("ip", ipStr).
|
||||
ip, ipChain := resolveClientIP(req)
|
||||
evt := that.WebConnectLog.Info().
|
||||
Str("ip", ip).
|
||||
Str("method", context.Req.Method).
|
||||
Str("sid", sid).
|
||||
Str("request_id", requestId).
|
||||
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
|
||||
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00).
|
||||
Msg(context.HandlerStr)
|
||||
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
|
||||
if ipChain != "" && ipChain != ip {
|
||||
evt = evt.Str("ip_chain", ipChain)
|
||||
}
|
||||
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
|
||||
evt = evt.Str("ip_country", country)
|
||||
}
|
||||
if ua := req.Header.Get("User-Agent"); ua != "" {
|
||||
evt = evt.Str("ua", truncateUA(ua, 200))
|
||||
}
|
||||
evt.Msg(context.HandlerStr)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -534,13 +662,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
//设置header
|
||||
// 静态资源可缓存;logLevel 只控制日志/SQL,不再改 Cache-Control
|
||||
delete(header, "Content-Type")
|
||||
if that.Config.GetCeilInt("logLevel") == 0 {
|
||||
header.Set("Cache-Control", "public")
|
||||
} else {
|
||||
header.Set("Cache-Control", "no-cache")
|
||||
}
|
||||
header.Set("Cache-Control", "public")
|
||||
|
||||
t := strings.LastIndex(path, ".")
|
||||
if t != -1 {
|
||||
@@ -785,7 +909,7 @@ func SetMysqlDB(appIns *Application, config Map) {
|
||||
appIns.Db.Log = appIns.Log
|
||||
appIns.SetConnectDB(func() (master, slave *sql.DB) {
|
||||
query := config.GetString("user") + ":" + config.GetString("password") +
|
||||
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8"
|
||||
"@tcp(" + config.GetString("host") + ":" + config.GetString("port") + ")/" + config.GetString("name") + "?charset=utf8mb4"
|
||||
DB, e := sql.Open("mysql", query)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("MySQL 主库连接失败")
|
||||
@@ -794,7 +918,7 @@ func SetMysqlDB(appIns *Application, config Map) {
|
||||
configSlave := config.GetMap("slave")
|
||||
if configSlave != nil {
|
||||
query := configSlave.GetString("user") + ":" + configSlave.GetString("password") +
|
||||
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8"
|
||||
"@tcp(" + config.GetString("host") + ":" + configSlave.GetString("port") + ")/" + configSlave.GetString("name") + "?charset=utf8mb4"
|
||||
DB1, e := sql.Open("mysql", query)
|
||||
if e != nil {
|
||||
appIns.Log.Error().Err(e).Msg("MySQL 从库连接失败")
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveClientIP_EOPreferred(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("EO-Connecting-IP", "171.1.2.3")
|
||||
req.Header.Set("X-Real-IP", "43.1.2.3")
|
||||
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
|
||||
req.RemoteAddr = "127.0.0.1:12345"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "171.1.2.3" {
|
||||
t.Fatalf("ip=%q want 171.1.2.3", ip)
|
||||
}
|
||||
// 去重:171 只出现一次
|
||||
if chain != "171.1.2.3,43.1.2.3,127.0.0.1" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_SkipLoopbackXReal(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "127.0.0.1")
|
||||
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
|
||||
req.RemoteAddr = "127.0.0.1:8080"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "171.1.2.3" {
|
||||
t.Fatalf("ip=%q want 171.1.2.3", ip)
|
||||
}
|
||||
if chain != "127.0.0.1,171.1.2.3,43.1.2.3" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_XFFBeforeMisconfiguredXReal(t *testing.T) {
|
||||
// 线上常见:X-Real-IP=CDN,XFF=客户端,CDN
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "43.1.2.3")
|
||||
req.Header.Set("X-Forwarded-For", "171.1.2.3, 43.1.2.3")
|
||||
req.RemoteAddr = "127.0.0.1:8080"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "171.1.2.3" {
|
||||
t.Fatalf("ip=%q want 171.1.2.3", ip)
|
||||
}
|
||||
if chain != "43.1.2.3,171.1.2.3,127.0.0.1" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_XRealWhenNoXFF(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Real-IP", "203.0.113.9")
|
||||
req.RemoteAddr = "10.0.0.1:80"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "203.0.113.9" {
|
||||
t.Fatalf("ip=%q want 203.0.113.9", ip)
|
||||
}
|
||||
if chain != "203.0.113.9,10.0.0.1" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_DirectRemoteAddr(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
req.RemoteAddr = "192.168.1.8:5555"
|
||||
|
||||
ip, chain := resolveClientIP(req)
|
||||
if ip != "192.168.1.8" {
|
||||
t.Fatalf("ip=%q want 192.168.1.8", ip)
|
||||
}
|
||||
if chain != "192.168.1.8" {
|
||||
t.Fatalf("chain=%q", chain)
|
||||
}
|
||||
if clientIP(req) != ip {
|
||||
t.Fatalf("clientIP mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClientIP_NilReq(t *testing.T) {
|
||||
ip, chain := resolveClientIP(nil)
|
||||
if ip != "" || chain != "" {
|
||||
t.Fatalf("want empty, got %q %q", ip, chain)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +209,17 @@ var TptProject = Proj{
|
||||
}
|
||||
}
|
||||
|
||||
columns := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetSlice("columns")
|
||||
for k := range columns {
|
||||
if columns.GetMap(k).GetString("name") == "state" {
|
||||
stateVal := inData["state"]
|
||||
if stateVal == nil || ObjToStr(stateVal) == "" {
|
||||
inData["state"] = 0
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
re := that.Db.Insert(tableName, inData)
|
||||
|
||||
if re == 0 {
|
||||
@@ -604,9 +615,13 @@ var TptProject = Proj{
|
||||
if download == 1 {
|
||||
|
||||
tableNameLabel := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetString("label")
|
||||
sheetName := tableNameLabel
|
||||
if sheetName == "" || len(sheetName) > 31 {
|
||||
sheetName = tableName
|
||||
}
|
||||
f := excelize.NewFile()
|
||||
// 创建一个工作表
|
||||
f.NewSheet(tableNameLabel)
|
||||
f.NewSheet(sheetName)
|
||||
|
||||
f.DeleteSheet("Sheet1")
|
||||
columns := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetSlice("columns")
|
||||
@@ -622,21 +637,21 @@ var TptProject = Proj{
|
||||
//单行
|
||||
for k1, v1 := range reData {
|
||||
if k1 == 0 {
|
||||
f.SetCellValue(tableNameLabel, convertToTitle(n)+"1", v.GetString("label"))
|
||||
f.SetCellValue(sheetName, convertToTitle(n)+"1", v.GetString("label"))
|
||||
}
|
||||
|
||||
if v.GetString("link") != "" {
|
||||
f.SetCellValue(tableNameLabel, convertToTitle(n)+ObjToStr(k1+2), v1.GetString(v.GetString("link")+"_"+v.GetString("name")+"_"+v.GetString("value")))
|
||||
f.SetCellValue(sheetName, convertToTitle(n)+ObjToStr(k1+2), v1.GetString(v.GetString("link")+"_"+v.GetString("name")+"_"+v.GetString("value")))
|
||||
continue
|
||||
}
|
||||
|
||||
if v.GetString("name") == "table" {
|
||||
f.SetCellValue(tableNameLabel, convertToTitle(n)+ObjToStr(k1+2), v1.GetString("table_"+v.GetString("name")+"_name"))
|
||||
f.SetCellValue(sheetName, convertToTitle(n)+ObjToStr(k1+2), v1.GetString("table_"+v.GetString("name")+"_name"))
|
||||
continue
|
||||
}
|
||||
|
||||
if v.GetString("name") == "table_id" {
|
||||
f.SetCellValue(tableNameLabel, convertToTitle(n)+ObjToStr(k1+2), v1.GetString("table_"+v.GetString("name")+"_name"))
|
||||
f.SetCellValue(sheetName, convertToTitle(n)+ObjToStr(k1+2), v1.GetString("table_"+v.GetString("name")+"_name"))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -647,7 +662,7 @@ var TptProject = Proj{
|
||||
for ok, _ := range options {
|
||||
ov := options.GetMap(ok)
|
||||
if ov.GetString("value") == v1.GetString(v.GetString("name")) {
|
||||
f.SetCellValue(tableNameLabel, convertToTitle(n)+ObjToStr(k1+2), ov.GetString("name"))
|
||||
f.SetCellValue(sheetName, convertToTitle(n)+ObjToStr(k1+2), ov.GetString("name"))
|
||||
isEnd = true
|
||||
break
|
||||
}
|
||||
@@ -659,7 +674,7 @@ var TptProject = Proj{
|
||||
|
||||
}
|
||||
|
||||
f.SetCellValue(tableNameLabel, convertToTitle(n)+ObjToStr(k1+2), v1.GetString(v.GetString("name")))
|
||||
f.SetCellValue(sheetName, convertToTitle(n)+ObjToStr(k1+2), v1.GetString(v.GetString("name")))
|
||||
}
|
||||
}
|
||||
filePath := that.Config.GetString("filePath")
|
||||
@@ -668,7 +683,7 @@ var TptProject = Proj{
|
||||
}
|
||||
|
||||
//path := time.Now().Format(filePath)
|
||||
e := os.MkdirAll(that.Config.GetString("tpt")+filePath, os.ModeDir)
|
||||
e := os.MkdirAll(that.Config.GetString("tpt")+filePath, 0755)
|
||||
if e != nil {
|
||||
that.Display(3, e)
|
||||
return
|
||||
@@ -677,7 +692,7 @@ var TptProject = Proj{
|
||||
|
||||
// 根据指定路径保存文件
|
||||
if err := f.SaveAs(that.Config.GetString("tpt") + filePath); err != nil {
|
||||
fmt.Println(err)
|
||||
fmt.Printf("SaveAs failed: %v\n", err)
|
||||
that.Display(4, "输出异常")
|
||||
return
|
||||
}
|
||||
@@ -875,7 +890,7 @@ var TptProject = Proj{
|
||||
}
|
||||
|
||||
path := time.Now().Format(filePath)
|
||||
e := os.MkdirAll(that.Config.GetString("tpt")+path, os.ModeDir)
|
||||
e := os.MkdirAll(that.Config.GetString("tpt")+path, 0755)
|
||||
if e != nil {
|
||||
that.Display(3, e)
|
||||
return
|
||||
@@ -963,7 +978,7 @@ var TptProject = Proj{
|
||||
},
|
||||
"login": func(that *Context) {
|
||||
// 限速:同一 IP 1分钟内最多尝试10次
|
||||
ip := that.Req.Header.Get("X-Forwarded-For")
|
||||
ip := clientIP(that.Req)
|
||||
if ip == "" {
|
||||
ip = that.Req.RemoteAddr
|
||||
}
|
||||
|
||||
+38
-8
@@ -175,7 +175,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
tableInfo = db.Query("pragma table_info([" + v.GetString("name") + "]);")
|
||||
}
|
||||
if db.Type == "dm" || db.Type == "dameng" {
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN USER_COL_COMMENTS m ON c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + db.DBName + `' ORDER BY c.COLUMN_ID`)
|
||||
// 必须用 ALL_COL_COMMENTS 并按 OWNER 对齐:USER_COL_COMMENTS 无 schema,
|
||||
// 在 SYSDBA 下常混入同名表空注释或读不到业务库(如 login_config)列备注。
|
||||
tableInfo = db.Query(`SELECT c.COLUMN_NAME AS "name", c.DATA_TYPE AS "type", m.COMMENTS AS "label", c.NULLABLE AS "must", c.DATA_DEFAULT AS "dflt_value" FROM ALL_TAB_COLUMNS c LEFT JOIN ALL_COL_COMMENTS m ON c.OWNER=m.OWNER AND c.TABLE_NAME=m.TABLE_NAME AND c.COLUMN_NAME=m.COLUMN_NAME WHERE c.TABLE_NAME='` + v.GetString("name") + `' AND c.OWNER='` + db.DBName + `' ORDER BY c.COLUMN_ID`)
|
||||
}
|
||||
|
||||
idSlice = append(idSlice, tableInfo)
|
||||
@@ -303,11 +305,33 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
||||
options := Slice{}
|
||||
comments := strings.Split(info.GetString("label"), ":")
|
||||
if len(comments) >= 2 {
|
||||
optionComment := strings.Split(comments[1], ",")
|
||||
// 截掉空格后附加说明 / {} 提示,避免污染 options 解析
|
||||
optPart := comments[1]
|
||||
if idx := strings.Index(optPart, " "); idx > -1 {
|
||||
optPart = optPart[:idx]
|
||||
}
|
||||
for _, pair := range [][2]string{{"{", "}"}, {"(", ")"}, {"(", ")"}} {
|
||||
if start := strings.Index(optPart, pair[0]); start != -1 {
|
||||
optPart = optPart[:start]
|
||||
break
|
||||
}
|
||||
}
|
||||
optionComment := strings.Split(optPart, ",")
|
||||
for _, v := range optionComment {
|
||||
optionSlice := strings.Split(v, "-")
|
||||
if len(optionSlice) >= 2 {
|
||||
options = append(options, Map{"name": optionSlice[1], "value": optionSlice[0]})
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// 兼容规范「值-名称」与存量「值=名称」
|
||||
sep := -1
|
||||
for i, r := range v {
|
||||
if r == '-' || r == '=' {
|
||||
sep = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sep > 0 && sep < len(v)-1 {
|
||||
options = append(options, Map{"name": strings.TrimSpace(v[sep+1:]), "value": strings.TrimSpace(v[:sep])})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -980,7 +1004,11 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
// }
|
||||
//
|
||||
reqValue := req.FormValue(v.GetString("name"))
|
||||
if v.GetString("name") != "parent_id" && reqValue != "" {
|
||||
// text 类字段只走下方模糊匹配([~]),此处不再生成等值条件,
|
||||
// 否则等值(必不命中部分关键词)与模糊匹配在 where 中以 AND 叠加,
|
||||
// 查询恒为空(见 docs/CodeGen_代码生成.md 通用 Search 筛选说明)。
|
||||
if v.GetString("name") != "parent_id" && reqValue != "" &&
|
||||
!strings.Contains(v.GetString("type"), "text") {
|
||||
data[v.GetString("name")] = reqValue
|
||||
}
|
||||
//
|
||||
@@ -1244,8 +1272,10 @@ func (that *MakeCode) Search(table string, userData Map, data Map, req *http.Req
|
||||
} else {
|
||||
where["parent_id"] = reqValue
|
||||
}
|
||||
//是否展示自己
|
||||
if req.FormValue("showself") == "1" {
|
||||
//是否展示自己:仅在 showall=1(选中节点及全部子孙)场景生效;
|
||||
//普通子级懒加载(仅 parent_id=X)不得把 id=X 的行本身混入结果,
|
||||
//否则前端树把节点当成自己的子级,造成无限嵌套
|
||||
if req.FormValue("showself") == "1" && req.FormValue("showall") == "1" {
|
||||
if len(where) == 0 {
|
||||
data["OR"] = Map{"id": reqValue}
|
||||
} else {
|
||||
|
||||
+32
-16
@@ -6,19 +6,20 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
htlog "code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
type Context struct {
|
||||
*Application
|
||||
Resp http.ResponseWriter
|
||||
Req *http.Request
|
||||
Log Map //日志有则创建
|
||||
Log Map //业务 logs 表字段(有则写入),与 Logger 不同
|
||||
Logger *htlog.Logger // 请求级日志(带 sid/request_id),业务与 SQL 共用
|
||||
RouterString []string
|
||||
Config Map
|
||||
Db *HoTimeDB
|
||||
@@ -60,7 +61,7 @@ func (that *Context) Display(statu int, data interface{}) {
|
||||
//兼容android等需要json转对象的服务
|
||||
resp["error"] = temp
|
||||
|
||||
that.Application.Log.Warn().Int("status", statu).Msg(resp.ToJsonString())
|
||||
that.reqLogger().Warn().Int("status", statu).Msg(resp.ToJsonString())
|
||||
|
||||
} else {
|
||||
resp["result"] = data
|
||||
@@ -71,6 +72,33 @@ func (that *Context) Display(statu int, data interface{}) {
|
||||
//that.Data=d;
|
||||
}
|
||||
|
||||
// reqLogger 返回请求级 Logger,nil 时回退 Application.Log
|
||||
func (that *Context) reqLogger() *htlog.Logger {
|
||||
if that.Logger != nil {
|
||||
return that.Logger
|
||||
}
|
||||
if that.Application != nil {
|
||||
return that.Application.Log
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogBind 为本请求追加自定义日志字段(如 user_id),后续业务日志与 SQL 日志均携带。
|
||||
// 供 main.go 的 connectListener 调用;nil 安全。
|
||||
func (that *Context) LogBind(key string, value interface{}) {
|
||||
if that == nil || key == "" {
|
||||
return
|
||||
}
|
||||
base := that.reqLogger()
|
||||
if base == nil {
|
||||
return
|
||||
}
|
||||
that.Logger = base.WithFields(key, ObjToStr(value))
|
||||
if that.Db != nil {
|
||||
that.Db.Log = that.Logger
|
||||
}
|
||||
}
|
||||
|
||||
func (that *Context) View() {
|
||||
if that.RespFunc != nil {
|
||||
that.RespFunc()
|
||||
@@ -87,19 +115,7 @@ func (that *Context) View() {
|
||||
if that.Session("user_id").Data != nil {
|
||||
that.Log["user_id"] = that.Session("user_id").ToCeilInt()
|
||||
}
|
||||
//负载均衡优化
|
||||
ipStr := ""
|
||||
if that.Req.Header.Get("X-Forwarded-For") != "" {
|
||||
ipStr = that.Req.Header.Get("X-Forwarded-For")
|
||||
} else if that.Req.Header.Get("X-Real-IP") != "" {
|
||||
ipStr = that.Req.Header.Get("X-Real-IP")
|
||||
}
|
||||
//负载均衡优化
|
||||
if ipStr == "" {
|
||||
//RemoteAddr := that.Req.RemoteAddr
|
||||
ipStr = Substr(that.Req.RemoteAddr, 0, strings.Index(that.Req.RemoteAddr, ":"))
|
||||
}
|
||||
that.Log["ip"] = ipStr
|
||||
that.Log["ip"] = clientIP(that.Req)
|
||||
that.Db.Insert("logs", that.Log)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
mode: set
|
||||
code.hoteas.com/golang/hotime/common/context_base.go:12.42,14.20 1 0
|
||||
code.hoteas.com/golang/hotime/common/context_base.go:14.20,16.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/context_base.go:17.2,17.17 1 0
|
||||
code.hoteas.com/golang/hotime/common/error.go:15.37,19.2 3 1
|
||||
code.hoteas.com/golang/hotime/common/error.go:21.40,26.38 4 1
|
||||
code.hoteas.com/golang/hotime/common/error.go:26.38,28.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:29.41,30.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:30.19,32.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:34.2,37.39 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:41.54,42.18 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:42.18,44.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:45.2,46.18 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:46.18,48.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:50.2,50.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:51.9,52.29 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:53.9,54.32 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:55.9,56.35 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:57.9,58.38 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:59.9,60.41 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:61.10,62.27 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:63.10,64.33 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:65.10,66.36 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:67.10,68.27 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:69.10,70.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:72.2,72.40 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:77.46,78.16 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:78.16,81.3 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:82.2,83.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:83.19,85.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:86.2,86.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:86.19,88.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:89.2,89.22 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:89.22,91.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:92.2,92.31 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:92.31,93.32 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:93.32,94.24 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:94.24,96.5 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:96.10,98.24 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:98.24,100.6 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:101.5,101.26 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:101.26,103.6 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:104.5,104.22 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:109.2,109.26 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:113.55,118.15 4 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:118.15,120.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:121.2,123.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:123.17,125.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:127.2,127.15 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:127.15,129.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:130.2,130.16 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:130.16,132.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:133.2,133.13 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:133.13,135.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:136.2,136.14 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:136.14,138.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:140.2,140.30 1 1
|
||||
code.hoteas.com/golang/hotime/common/func.go:145.40,148.35 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:148.35,150.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:152.2,154.42 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:154.42,156.14 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:156.14,158.22 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:158.22,161.18 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:161.18,162.11 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:165.4,165.13 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:165.13,167.5 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:171.2,171.11 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:175.29,180.2 4 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:183.26,185.29 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:185.29,187.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:188.2,188.22 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:190.23,193.26 3 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:193.26,195.28 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:195.28,198.9 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:200.3,200.14 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:203.2,203.10 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:208.36,211.18 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:211.18,213.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:215.2,215.6 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:215.6,217.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:217.19,218.9 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:221.2,221.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:260.49,261.37 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:261.37,263.30 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:263.30,265.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:267.3,267.16 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:268.8,268.56 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:268.56,270.32 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:270.32,272.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:274.3,274.18 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:275.8,275.63 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:275.63,277.30 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:277.30,279.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:281.3,281.16 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:283.8,283.48 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:283.48,285.32 2 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:285.32,287.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:289.3,289.18 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:292.2,292.14 1 0
|
||||
code.hoteas.com/golang/hotime/common/func.go:319.38,322.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:15.61,17.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:17.19,19.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:20.2,20.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:24.33,27.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:30.52,35.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:38.36,41.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:44.55,49.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:52.59,56.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:59.63,63.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:66.59,70.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:73.67,77.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:80.63,86.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:88.59,95.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:96.57,103.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:105.63,110.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:112.80,115.27 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:115.27,118.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:119.2,120.27 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:120.27,122.9 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:122.9,124.4 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:129.55,136.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:138.60,140.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:140.30,142.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:143.2,145.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:145.19,148.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:149.2,149.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:153.44,156.25 2 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:156.25,159.22 3 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:159.22,160.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:162.3,162.31 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:163.14,164.33 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:165.16,166.47 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:167.18,168.49 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:169.17,170.48 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:171.22,172.32 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:178.39,181.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:183.58,185.31 2 1
|
||||
code.hoteas.com/golang/hotime/common/map.go:185.31,187.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/map.go:192.83,194.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:12.40,14.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:16.42,17.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:17.19,19.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:20.2,20.41 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:23.50,24.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:24.19,26.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:27.2,27.42 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:30.46,31.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:31.19,33.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:34.2,34.43 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:37.50,38.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:38.19,40.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:41.2,41.45 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:45.55,46.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:46.19,48.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:49.2,50.10 2 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:54.33,57.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:59.42,60.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:60.19,62.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:63.2,63.41 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:66.46,67.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:67.19,69.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:70.2,70.43 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:73.49,74.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:74.19,76.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:77.2,77.46 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:80.38,83.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:86.51,87.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:87.19,89.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:90.2,91.10 2 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:96.47,97.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:97.19,99.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:100.2,101.10 2 0
|
||||
code.hoteas.com/golang/hotime/common/obj.go:106.71,108.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:13.49,17.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:17.16,20.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:20.8,21.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:22.12,23.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:24.31,25.16 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:26.15,28.56 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:28.56,31.5 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:32.11,35.18 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:35.18,38.10 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:40.4,41.49 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:41.49,44.5 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:47.2,47.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:47.17,49.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:50.2,50.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:53.56,56.30 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:56.30,58.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:59.2,59.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:63.53,67.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:67.16,70.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:70.8,71.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:72.14,73.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:74.22,75.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:76.17,78.26 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:78.26,80.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:81.15,83.41 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:84.11,88.18 4 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:88.18,89.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:91.4,91.34 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:95.2,95.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:95.17,97.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:99.2,99.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:102.57,106.15 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:106.15,111.47 4 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:111.47,114.4 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:115.3,116.32 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:116.32,117.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:117.17,118.13 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:120.4,120.21 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:120.21,122.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:124.3,126.21 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:126.21,128.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:128.16,130.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:131.9,131.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:131.28,133.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:133.16,135.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:136.9,136.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:136.28,138.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:138.16,140.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:141.9,141.27 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:141.27,143.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:143.16,145.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:146.9,146.27 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:146.27,148.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:148.16,150.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:155.2,159.18 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:159.18,163.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:163.8,163.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:163.25,167.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:167.8,167.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:167.25,171.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:171.8,171.24 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:171.24,174.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:174.8,174.24 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:174.24,176.15 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:176.15,178.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:181.2,181.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:184.57,188.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:188.16,190.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:190.8,191.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:192.12,193.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:194.14,195.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:196.15,198.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:198.17,200.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:200.10,202.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:203.16,204.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:205.16,206.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:207.15,210.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:210.17,212.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:212.10,214.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:215.11,216.54 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:220.2,220.19 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:220.19,223.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:224.2,224.22 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:224.22,227.3 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:228.2,228.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:228.17,230.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:232.2,232.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:237.77,239.19 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:239.19,241.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:242.2,243.32 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:247.57,250.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:253.61,256.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:259.53,262.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:264.53,268.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:268.16,270.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:270.8,271.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:272.12,273.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:274.14,275.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:276.15,278.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:278.17,280.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:280.10,282.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:283.15,286.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:286.17,288.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:288.10,290.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:291.16,292.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:293.16,294.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:295.11,296.54 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:299.2,299.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:299.17,301.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:302.2,302.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:305.49,308.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:310.51,314.16 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:314.16,316.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:316.8,317.28 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:318.13,319.11 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:320.11,323.32 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:323.32,325.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:325.10,325.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:325.25,327.5 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:330.2,330.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:330.17,332.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:333.2,333.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:336.39,338.16 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:338.16,340.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:341.2,341.27 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:342.11,343.26 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:344.13,345.31 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:346.13,347.35 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:348.14,349.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:350.14,351.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:352.15,353.46 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:354.15,355.55 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:356.12,357.32 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:358.10,360.17 2 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:360.17,362.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:365.2,365.12 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:369.29,373.2 3 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:376.33,378.2 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:381.41,382.16 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:382.16,383.18 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:383.18,385.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:386.3,386.25 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:386.25,388.4 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:389.3,389.20 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:390.8,392.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:393.2,393.10 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:397.41,398.17 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:398.17,400.3 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:401.2,401.22 1 1
|
||||
code.hoteas.com/golang/hotime/common/objtoobj.go:405.38,408.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/slice.go:11.60,12.19 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:12.19,14.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:15.2,15.30 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:18.62,23.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:26.54,29.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:32.58,36.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:39.62,43.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:46.58,50.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:53.66,57.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:60.62,64.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:67.82,69.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:71.58,74.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:76.56,83.2 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:85.54,89.2 2 1
|
||||
code.hoteas.com/golang/hotime/common/slice.go:91.59,93.21 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:93.21,95.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:96.2,97.19 2 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:97.19,99.3 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:100.2,100.12 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:103.51,105.2 1 0
|
||||
code.hoteas.com/golang/hotime/common/slice.go:107.41,109.2 1 0
|
||||
@@ -752,6 +752,30 @@ CREATE TABLE user (
|
||||
|
||||
表命名、外键命名与 COMMENT 约定见 [数据库设计规范](DatabaseDesign_数据库设计规范.md)。
|
||||
|
||||
#### 树形表查询参数(parent_id / showself / showall)
|
||||
|
||||
带 `parent_id` 列的表,通用 CRUD 的 `search` 接口支持树查询参数,语义如下(`code/makecode.go` 树节点分支):
|
||||
|
||||
| 请求参数组合 | WHERE 语义 | 典型场景 |
|
||||
| --- | --- | --- |
|
||||
| 不传 `parent_id` 或 `parent_id=0` | `id=当前用户锚点 OR parent_id IS NULL`(返回根层) | 树侧栏根节点加载 |
|
||||
| `parent_id=X` | `parent_id=X`,仅直接子级 | 树节点懒加载展开 |
|
||||
| `parent_id=X&showall=1` | `parent_ids LIKE '%,X,%'`,X 及其全部子孙(X 自身的 parent_ids 含 `,X,`) | 列表按树节点过滤 |
|
||||
| `parent_id=X&showall=1&showself=1` | 在 showall 基础上再 `OR id=X`,显式保证含 X 自身 | 管理端列表树筛选(Table.vue) |
|
||||
|
||||
**规则**:`showself=1` 仅在 `showall=1` 时生效。普通子级查询(仅 `parent_id=X`)**不会**把 id=X 的行本身混入结果——否则前端树会把节点当作自己的子级,造成无限嵌套(历史缺陷,已修复;回归用例见 `example/app/makecode_tree_test.go`,`go test ./app/ -count=1 -run 'TestApi/admin/department/search'`)。
|
||||
|
||||
#### 通用列表字段筛选语义(text 类型只走模糊匹配)
|
||||
|
||||
通用 CRUD 的 `search` 接口为每个 `list` 可见字段自动生成筛选条件(`code/makecode.go` `(*MakeCode).Search`),按字段最终生效的 `type`(见 [4.2 数据类型映射](#42-数据类型映射),可被 [3.3 字段规则配置(rule.json)](#33-字段规则配置rulejson) 覆盖)分两种语义:
|
||||
|
||||
| 字段 type | 请求 `?字段名=值` 的 WHERE 语义 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 含 `"text"` 子串(`text`/`textArea`) | `字段名 LIKE '%值%'`(模糊,仅此一条) | 前端筛选框允许只填部分关键词 |
|
||||
| 其他(`number`/`select`/`time`/`money`… 等非 text) | `字段名 = 值`(精确等值) | 维持现状不变 |
|
||||
|
||||
**规则**:文本类型字段**只生成模糊匹配条件,不再叠加同名等值条件**。历史缺陷:曾对所有 list 字段先生成等值条件 `字段名=值`,再对 text 类型**额外**叠加模糊条件 `字段名[~]=值`,两者在 where 里以 AND 叠加——前端筛选框只传部分关键词时等值条件必不命中,AND 之后整条查询恒为空(下游"文本列筛选查不出数据"的通用根因)。已修复;`keyword`+`keywordtable` 全局关键词搜索逻辑不受影响。回归用例见 `example/app/makecode_tree_test.go` 的「文本字段部分关键词命中模糊匹配」「非文本字段等值筛选行为不变」两个用例,`go test ./app/ -count=1 -run 'TestApi/admin/department/search'`。
|
||||
|
||||
### 4.5 生成的代码结构
|
||||
|
||||
#### 内嵌模式 (mode=0)
|
||||
|
||||
@@ -67,7 +67,9 @@ server {
|
||||
|
||||
# 其他常规配置...
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# EdgeOne 场景优先 EO-Connecting-IP;空则框架再降级到 XFF / RemoteAddr
|
||||
proxy_set_header X-Real-IP $http_eo_connecting_ip;
|
||||
proxy_set_header EO-Connecting-IP $http_eo_connecting_ip;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ HoTimeDB是一个基于Golang实现的轻量级ORM框架,参考PHP Medoo设计
|
||||
|
||||
### 初始化数据库连接
|
||||
|
||||
`Application.SetMysqlDB` 默认 DSN 带 `charset=utf8mb4`(兼容 MySQL 8.0 / 8.4,与表字符集对齐)。
|
||||
|
||||
```go
|
||||
import (
|
||||
"code.hoteas.com/golang/hotime/db"
|
||||
@@ -43,7 +45,7 @@ import (
|
||||
|
||||
// 创建连接函数
|
||||
func createConnection() (master, slave *sql.DB) {
|
||||
master, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/database")
|
||||
master, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/database?charset=utf8mb4")
|
||||
// slave是可选的,用于读写分离
|
||||
slave = master // 或者连接到从数据库
|
||||
return
|
||||
|
||||
@@ -80,7 +80,7 @@ func main() {
|
||||
| `modeRouterStrict` | false | 路由大小写敏感,false=忽略大小写 |
|
||||
| `crossDomain` | - | 跨域设置,空=不开启,auto=智能开启,或指定域名 |
|
||||
| `logFile` | - | 日志文件路径,如 `logs/20060102.txt` |
|
||||
| `logLevel` | 0 | 日志等级,0=关闭,1=打印 |
|
||||
| `logLevel` | 1 | 日志等级:0=仅错误,>=1=全部;同时控制 SQL 日志。**不**控制静态 `Cache-Control`(静态固定 `public`) |
|
||||
| `webConnectLogShow` | true | 是否显示访问日志 |
|
||||
| `defFile` | ["index.html"] | 目录默认访问文件 |
|
||||
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
# HoTime 文档索引
|
||||
|
||||
本仓采用 **Doc-Driven + TDD**:行为一变必须回写 docs;改接口必须用 `-run` 精确测试验证(见 [Testing_API测试框架.md](Testing_API测试框架.md) 文首铁律与仓规则 `hotime-doc-tdd`)。
|
||||
|
||||
推荐按路径阅读,避免同一概念在多份文档里来回找。
|
||||
|
||||
## 阅读路径
|
||||
@@ -8,7 +10,7 @@
|
||||
2. **类型与工具** → [Common_工具类.md](Common_工具类.md)
|
||||
3. **数据库 ORM** → [HoTimeDB_使用说明.md](HoTimeDB_使用说明.md)(教程)→ [HoTimeDB_API参考.md](HoTimeDB_API参考.md)(速查)
|
||||
4. **开表 / 代码生成** → [DatabaseDesign_数据库设计规范.md](DatabaseDesign_数据库设计规范.md)(前置)→ [CodeGen_代码生成.md](CodeGen_代码生成.md)
|
||||
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;全量见文末)
|
||||
5. **接口测试** → [Testing_API测试框架.md](Testing_API测试框架.md)(日常用 `-run` 单测;局限见文末;全量见文末)
|
||||
6. **运维** → [GracefulShutdown_优雅停机.md](GracefulShutdown_优雅停机.md)、[Seq_日志集成.md](Seq_日志集成.md)
|
||||
7. **规划** → [ROADMAP_改进规划.md](ROADMAP_改进规划.md)
|
||||
|
||||
@@ -22,7 +24,7 @@
|
||||
| HoTimeDB_API参考 | ORM 方法与条件语法速查 |
|
||||
| DatabaseDesign_数据库设计规范 | 表/字段/COMMENT 规范(CodeGen 输入契约) |
|
||||
| CodeGen_代码生成 | 生成器用法 + codeConfig / rule 配置 |
|
||||
| Testing_API测试框架 | API 测试;优先 `-run`,全量沉底 |
|
||||
| Testing_API测试框架 | API 测试、Flows、控制台;优先 `-run`;已知局限 |
|
||||
| GracefulShutdown_优雅停机 | 优雅停机与滚动重启 |
|
||||
| Seq_日志集成 | Seq 结构化日志 |
|
||||
| ROADMAP_改进规划 | 待改进项 |
|
||||
|
||||
@@ -266,6 +266,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
|
||||
|--------|--------|------|
|
||||
| 高 | 备注语法优化(`\|` 分隔符) | 待设计 |
|
||||
| 高 | 测试框架:响应字段部分匹配断言 | 待实现 |
|
||||
| 高 | 测试框架其它缺口(DB 断言、FromCase 无模板失败、Header API 等) | 见 [Testing 已知局限](Testing_API测试框架.md#已知局限与后续) |
|
||||
| 中 | 软删除/日志表支持 | 待设计 |
|
||||
| 低 | 多对多关联表增强 | 待评估 |
|
||||
| 低 | 自动填充字段扩展 | 待评估 |
|
||||
@@ -276,6 +277,7 @@ a.JSON(Map{"phone": "138..."}).Post("正常注册", 0, Map{"id": notEmpty}) //
|
||||
|
||||
| 日期 | 内容 |
|
||||
|------|------|
|
||||
| 2026-07-13 | 测试框架文档补齐 Flows/控制台;缺口清单链到 Testing |
|
||||
| 2026-01-24 | 初始版本,记录分析结果和待改进项 |
|
||||
| 2026-03-15 | 新增测试框架响应字段断言改进规划(七) |
|
||||
| 2026-03-20 | 完成达梦(DM8)数据库完整支持:Dialect 适配(MERGE INTO Upsert、双引号标识符)、LastInsertId、保留字自动引号、schema 正确处理、代码生成器 DM 兼容、缓存表自动建表、测试框架 DM 适配 |
|
||||
|
||||
+104
-95
@@ -19,7 +19,72 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
||||
| `seqUrl` | 空(不激活) | Seq 服务地址,空值时功能静默不生效 |
|
||||
| `seqApiKey` | 空 | API Key,免费单用户版留空 |
|
||||
|
||||
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写,同机多进程靠端口区分,跨服务器靠 IP 区分,支持未来集群扩展。
|
||||
`instance` 字段由框架自动拼接为 `ip:port`(如 `192.168.1.10:8085`),无需手动填写。
|
||||
|
||||
激活后每个 HTTP 请求会自动:
|
||||
|
||||
- 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id`
|
||||
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
||||
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
|
||||
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件
|
||||
|
||||
---
|
||||
|
||||
## 会话追踪与 LogBind
|
||||
|
||||
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
|
||||
|
||||
业务在 `SetConnectListener` 中按需绑定自定义字段(建议放在鉴权/守卫之前,这样未登录被拒的 Warning 也能带上维度字段):
|
||||
|
||||
```go
|
||||
appIns.SetConnectListener(func(context *Context) bool {
|
||||
if v := context.Session("user_id").ToCeilInt64(); v > 0 {
|
||||
context.LogBind("user_id", v)
|
||||
}
|
||||
// 其他 session / 请求参数字段同理:有值再绑
|
||||
return false
|
||||
})
|
||||
```
|
||||
|
||||
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。session 在 context 内有缓存,多次 `Session()` 只查一次库。字段名与取值由业务自行约定。
|
||||
|
||||
**不带会话字段的边界:**
|
||||
|
||||
- `fmt.Println` / stdout 捕获、panic、MySQL driver、定时任务等无请求上下文的日志
|
||||
- 直接写 `that.Application.Log` 的旧代码(应改用 `that.Logger` 或 `that.LogBind` 后的请求级 Logger)
|
||||
|
||||
---
|
||||
|
||||
## 控制台降噪(配了 seqUrl)
|
||||
|
||||
| Logger | 控制台行为 |
|
||||
|---|---|
|
||||
| 业务日志(`that.Log`) | Info+(Info / Warn / Error,含 `Display` 非 0 的 Warn;Debug/SQL 不上控制台) |
|
||||
| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) |
|
||||
|
||||
| 出口 | 行为 |
|
||||
|---|---|
|
||||
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
|
||||
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
||||
|
||||
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
||||
|
||||
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
|
||||
|
||||
---
|
||||
|
||||
## 客户端 IP 与地域
|
||||
|
||||
零配置。`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`):
|
||||
|
||||
1. `EO-Connecting-IP`(EdgeOne 真实建连 IP)
|
||||
2. `X-Forwarded-For` **从左到右第一个非回环**(反代误把 CDN 写入 `X-Real-IP` 时仍能落到客户端)
|
||||
3. `X-Real-IP`(非回环)
|
||||
4. `RemoteAddr`
|
||||
|
||||
访问日志另附 `ip_chain`:上述源头出现过的 IP 按**首次出现**顺序逗号拼接,**同一 IP 不重复**;若与 `ip` 相同则省略该字段。
|
||||
|
||||
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
|
||||
|
||||
---
|
||||
|
||||
@@ -27,26 +92,24 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
||||
|
||||
```
|
||||
业务代码
|
||||
│ l.Info().Msg("...") ← HoTime Logger 正常调用路径
|
||||
│ fmt.Println("...") ← 被 redirectStdout 捕获后转入同一路径
|
||||
│ that.Logger.Info().Msg("...") ← 请求级(含 sid/request_id)
|
||||
│ Db.Query → SQL 日志 ← 同一请求级 Logger
|
||||
│ fmt.Println("...") ← 捕获后无 sid
|
||||
↓
|
||||
multiWriter(hotimev1.5/log/logger.go)
|
||||
├─ ConsoleWriter → 彩色终端输出(不变)
|
||||
├─ FileWriter → 本地日志文件(按需,logFile 配置)
|
||||
└─ SeqWriter
|
||||
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
|
||||
├─ FileWriter → 本地文件(按需,全量)
|
||||
└─ SeqWriter → Seq(全量)
|
||||
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
||||
↓
|
||||
channel(容量 10000)
|
||||
↓ 后台 goroutine
|
||||
批量打包(100 条 或 500ms)
|
||||
↓ HTTP POST
|
||||
↓ HTTP POST(失败重试 1 次)
|
||||
Seq 服务(CLEF 格式)
|
||||
```
|
||||
|
||||
**关键特性:**
|
||||
- `SeqWriter.Write()` 仅向 channel 投递字节即返回,**绝不阻塞** web 请求处理 goroutine
|
||||
- channel 满时(Seq 宕机/网络故障)新日志被丢弃并计数,主服务完全不受影响
|
||||
- HTTP POST 设 5s 超时,失败仅打印到 stderr
|
||||
优雅停机时调用 `CloseSeq()` 冲刷残留批次,避免停机前后日志丢失。
|
||||
|
||||
---
|
||||
|
||||
@@ -54,82 +117,19 @@ multiWriter(hotimev1.5/log/logger.go)
|
||||
|
||||
| zerolog 字段 | Seq CLEF 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `time` | `@t` | 时间,自动转 ISO 8601 格式 |
|
||||
| `level` | `@l` | 级别,映射为 Debug/Information/Warning/Error/Fatal |
|
||||
| `message` / `msg` | `@mt` | 消息正文 |
|
||||
| `caller` | `caller` | 调用位置,原样保留 |
|
||||
| 其余自定义字段 | 原字段名 | 原样保留,可在 Seq 中直接查询 |
|
||||
| — | `instance` | 框架自动注入,值为 `port` 配置(如 `"8085"`) |
|
||||
| — | `source` | fmt.Println 等捕获的输出标记为 `stdout` |
|
||||
|
||||
---
|
||||
|
||||
## 单机多进程实例区分
|
||||
|
||||
框架启动时自动获取本机出口 IP,拼接为 `ip:port` 格式作为 `instance`:
|
||||
|
||||
```
|
||||
单机多进程:
|
||||
192.168.1.10:8085 ─┐
|
||||
192.168.1.10:8086 ─┼─ HTTP CLEF ──→ Seq
|
||||
192.168.1.10:8087 ─┘
|
||||
|
||||
多服务器集群:
|
||||
192.168.1.10:8085 ─┐
|
||||
192.168.1.11:8085 ─┼─ HTTP CLEF ──→ Seq(中央日志服务器)
|
||||
192.168.1.12:8085 ─┘
|
||||
```
|
||||
|
||||
Seq 中按实例筛选:
|
||||
- 单台机器所有进程:`instance like '192.168.1.10%'`
|
||||
- 精确到某个进程:`instance = '192.168.1.10:8085'`
|
||||
|
||||
---
|
||||
|
||||
## stdout / stderr 全量捕获
|
||||
|
||||
`SetConfig()` 中自动 `redirectStdout` + `redirectStderr`,并桥接 **logrus**(微信 SDK)到同一管道。
|
||||
|
||||
| 写法 | Seq 字段 | 说明 |
|
||||
|------|----------|------|
|
||||
| `fmt.Println` / `log.Println` | `source=stdout` | **不受 `logLevel` 影响**,`logLevel=0` 也会进 Seq |
|
||||
| 写 `os.Stderr` 的包 | `source=stderr` | 同上,Error 级别 |
|
||||
| `logrus.Info`(wechat) | `source=stdout` | redirect 后 `logrus.SetOutput` 桥接 |
|
||||
| `that.Log.Info/Error...` | 结构化字段 | 经 `multiWriter` → SeqWriter |
|
||||
| MySQL driver 内部错误 | `source=mysql-driver` | `SetLogger` 适配器 |
|
||||
| 框架 `recover` 到的 panic | `source=panic` + `stack` | 代码层原因与调用栈 |
|
||||
|
||||
单行日志上限约 **10MB**(适配 `GetReqMap` 打整包 body);超长会截断并标注 `...(truncated)`。
|
||||
|
||||
**已知不进 Seq(文档边界):**
|
||||
|
||||
- 未 `recover`、进程直接崩溃的 runtime 栈(写 fd2,未做 Dup2)
|
||||
- 达梦驱动自有文件日志(vendor 独立写盘)
|
||||
- `seqUrl` 挂上之前的极早期引导日志
|
||||
|
||||
---
|
||||
|
||||
## 捕获矩阵速查
|
||||
|
||||
| 来源 | 进 Seq? | 检索示例 |
|
||||
|------|----------|----------|
|
||||
| `that.Log.*` | 是 | `@mt like '%关键词%'` |
|
||||
| `log.Println` / `fmt.Println` | 是 | `source = 'stdout'` |
|
||||
| logrus / 标准 `log` | 是 | `source = 'stdout'` |
|
||||
| stderr 重定向 | 是 | `source = 'stderr'` |
|
||||
| recover panic | 是 | `source = 'panic'` |
|
||||
| MySQL driver | 是 | `source = 'mysql-driver'` |
|
||||
| 队列满丢弃 | 否(计数) | 终端可见 `[seq] queue full` |
|
||||
|
||||
---
|
||||
|
||||
## Seq 安装
|
||||
|
||||
Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数据库依赖:
|
||||
|
||||
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq),安装后自动注册为 Windows 服务
|
||||
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
||||
- 访问 `http://localhost:5341` 使用 Web UI
|
||||
| `time` | `@t` | 毫秒精度 ISO 8601 |
|
||||
| `level` | `@l` | Debug/Information/Warning/Error/Fatal |
|
||||
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
|
||||
| `caller` | `caller` | 调用位置 |
|
||||
| `sid` | `sid` | sessionId 前 12 位 |
|
||||
| `request_id` | `request_id` | 单次请求 id |
|
||||
| `ip` | `ip` | 客户端最佳 IP |
|
||||
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
|
||||
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
||||
| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) |
|
||||
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
||||
| — | `instance` | `ip:port` |
|
||||
| — | `source` | stdout / stderr / panic 等 |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,15 +137,24 @@ Seq 提供 Windows MSI 安装包和 Docker 镜像,单机免费,无外部数
|
||||
|
||||
| 目标 | 查询语句 |
|
||||
|---|---|
|
||||
| 关键词搜索 | 直接输入,如 `支付失败` |
|
||||
| 按会话追踪 | `sid = 'abc123def456'` |
|
||||
| 按单次请求 | `request_id = 'fedcba987654'` |
|
||||
| 关键词 | 直接输入,如 `支付失败` |
|
||||
| 日志级别 | `@l = 'Error'` |
|
||||
| 特定实例 | `instance = '8085'` |
|
||||
| stdout 来源 | `source = 'stdout'` |
|
||||
| panic 恢复 | `source = 'panic'` |
|
||||
| 请求体调试 | `请求参数GetReqMap` 或 `source = 'stdout'` |
|
||||
| 调用位置 | `caller like '%order.go%'` |
|
||||
| 组合查询 | `@l = 'Error' and instance = '8086' and @mt like '%超时%'` |
|
||||
| 日期范围 | 右上角时间选择器,支持精确到秒 |
|
||||
| 特定实例 | `instance = '192.168.1.10:8085'` |
|
||||
| 地域 | `ip_country = 'CN'` |
|
||||
| stdout | `source = 'stdout'` |
|
||||
| panic | `source = 'panic'` |
|
||||
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
||||
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
|
||||
|
||||
---
|
||||
|
||||
## Seq 安装
|
||||
|
||||
- Windows:[https://datalust.co/download/seq](https://datalust.co/download/seq)
|
||||
- Docker:`docker run -d --restart always --name seq -p 5341:80 -e ACCEPT_EULA=Y datalust/seq`
|
||||
- 访问 `http://localhost:5341`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+291
-192
@@ -2,30 +2,22 @@
|
||||
|
||||
不启动 HTTP 服务、自动事务回滚、链式 API、覆盖率报告、API 调试控制台生成。
|
||||
|
||||
> **测试执行铁律(日常开发 / AI 辅助必读)**
|
||||
>
|
||||
> - **只允许**用 `-run` 精确跑当前接口或当前控制器
|
||||
> - **禁止**把 `go test ./app/... -v`(不带 `-run`)当作默认命令
|
||||
> - 全量回归与覆盖率报告见文末「全量回归与覆盖率」,仅 CI / 发版前使用
|
||||
|
||||
## 目录
|
||||
|
||||
- [概述](#概述)
|
||||
- [快速开始](#快速开始)
|
||||
- [运行测试(推荐:-run 单测)](#运行测试推荐-run-单测)
|
||||
- [运行测试](#运行测试)
|
||||
- [用例编写范式](#用例编写范式)
|
||||
- [错误用例编写规范](#1-错误用例编写规范)
|
||||
- [测试数据准备](#2-测试数据准备)
|
||||
- [正确请求与响应结构校验](#3-正确请求与响应结构校验)
|
||||
- [Verify 统一校验](#4-verify-统一校验)
|
||||
- [简写支持说明](#简写支持说明)
|
||||
- [核心类型](#核心类型)
|
||||
- [业务流程联调(Flows)](#业务流程联调flows)
|
||||
- [优雅停测](#优雅停测)
|
||||
- [链式 API 参考](#链式-api-参考)
|
||||
- [事务隔离机制](#事务隔离机制)
|
||||
- [API 调试控制台](#api-调试控制台)
|
||||
- [并发保护与缓存隔离](#并发保护与缓存隔离)
|
||||
- [二进制与非 JSON 响应校验](#二进制与非-json-响应校验)
|
||||
- [全量回归与覆盖率(仅 CI / 发版前)](#全量回归与覆盖率仅-ci--发版前)
|
||||
- [全量回归与覆盖率](#全量回归与覆盖率)
|
||||
- [已知局限与后续](#已知局限与后续)
|
||||
|
||||
---
|
||||
|
||||
@@ -33,16 +25,20 @@
|
||||
|
||||
HoTime 内置 API 测试框架,核心能力:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
|
||||
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
|
||||
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
|
||||
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
|
||||
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
|
||||
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
|
||||
| **覆盖率** | 自动对比路由注册表与测试定义,输出哪些接口已覆盖、哪些未覆盖 |
|
||||
| **调试控制台** | 按模块生成独立的交互式 API 调试控制台,支持在线测试、认证管理、参数编辑 |
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------- | --------------------------------------------------------------------- |
|
||||
| **零启动** | 基于 `net/http/httptest`,不需要启动 HTTP 服务器 |
|
||||
| **事务回滚** | 每个接口方法独立开启数据库事务,测试结束自动回滚,数据库不留任何痕迹 |
|
||||
| **SAVEPOINT** | 业务代码中的 `that.Db.Action()` 在测试模式下自动用 SAVEPOINT 替代真事务,嵌套事务完整可用 |
|
||||
| **并发保护** | `testMu` 互斥锁自动保护 `testTx` 单连接,业务代码中的 `go func()` 协程不会导致 `busy buffer` |
|
||||
| **缓存隔离** | 测试启动时自动禁用 DB/Redis 缓存,避免缓存操作与测试事务锁冲突 |
|
||||
| **链式 API** | `a.JSON(...).Post("描述", 期望status)` 一行完成:数据组装 + 请求 + 断言 |
|
||||
| **Flows** | 多接口业务流程联调;`Group`/`Desc`/`Prep`;`FromCase` 绑定单接口用例并回写 |
|
||||
| **覆盖率** | 自动对比路由注册表与测试定义(**不含 Flow 步骤**,只统计单接口 path) |
|
||||
| **调试控制台** | 增量写入 swagger;流程分组 UI;发送结果 cURL 实时同步左侧参数 |
|
||||
| **优雅停测** | SIGINT/SIGTERM / `RequestStop`:停收新测、跑完当前、回滚事务、保留已落盘文档 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -184,15 +180,20 @@ var ProjectTest = ProjTest{
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Chdir("..") // 确保工作目录为项目根目录
|
||||
testApp = NewTestApp("config/config.json", TestProj{
|
||||
"app": {Proj: Project, Tests: ProjectTest},
|
||||
})
|
||||
code := m.Run()
|
||||
// 以下两行适合 CI / 全量回归;日常用 -run 单测时也会执行,但覆盖率主要在全量时有意义
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Listener(func(ctx *Context) bool {
|
||||
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
|
||||
}).
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {Proj: Project, Tests: ProjectTest},
|
||||
}).
|
||||
Flows(FlowTest{
|
||||
// "checkout": {Group: "order", Desc: "下单支付跑通", Func: checkoutFlow},
|
||||
})
|
||||
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
@@ -200,6 +201,9 @@ func TestApi(t *testing.T) {
|
||||
}
|
||||
```
|
||||
|
||||
`Run(m)` 内部会:注册优雅停测信号 → `m.Run()` → 覆盖率 → Swagger 收尾(接口级增量写入)。
|
||||
`WorkDir` / `Listener` / `Swagger` / `Flows` 均可省略;不写 `Swagger` 则不生成调试控制台。example 推荐把整条链写全便于对照。
|
||||
|
||||
### 推荐目录结构
|
||||
|
||||
```
|
||||
@@ -212,30 +216,28 @@ app/
|
||||
└── init_test.go ← 测试注册 (ProjectTest) + TestMain + TestApi
|
||||
```
|
||||
|
||||
### 运行测试(推荐:`-run` 单测)
|
||||
### 运行测试
|
||||
|
||||
`RunTests` 使用 Go 标准 `t.Run()` 子测试,**日常开发请始终加 `-run`**:
|
||||
`RunTests` 使用 Go 标准 `t.Run()` 子测试,用 `-run` 精确到单接口 / 单控制器 / 单用例:
|
||||
|
||||
```bash
|
||||
# 只跑 order/create 接口(推荐,开发默认)
|
||||
# 单接口
|
||||
go test ./app/ -v -run TestApi/app/order/create
|
||||
|
||||
# 只跑 order 控制器
|
||||
# 单控制器
|
||||
go test ./app/ -v -run TestApi/app/order
|
||||
|
||||
# 只跑某一条用例
|
||||
# 单用例
|
||||
go test ./app/ -v -run "TestApi/app/order/create/正常创建订单"
|
||||
|
||||
# 同时跑 user 和 order
|
||||
# 多控制器(正则)
|
||||
go test ./app/ -v -run "TestApi/app/(user|order)"
|
||||
|
||||
# 跑所有控制器中包含「未登录」的用例
|
||||
# 按用例名收窄(如所有「未登录」)
|
||||
go test ./app/ -v -run "TestApi/.*/.*未登录"
|
||||
```
|
||||
|
||||
> **注意**:`TestMain` 中的 `os.Chdir("..")` 确保 `go test ./app/` 的工作目录为项目根目录,使配置文件路径和模板输出路径正确。
|
||||
>
|
||||
> 全量 `go test ./app/... -v`(不带 `-run`)会跑完所有用例并触发覆盖率扫描,项目变大后很慢且容易牵连无关失败。**仅在 CI / 发版前使用**,说明见文末。
|
||||
> **注意**:`WorkDir("..")` 在 Init 前切换工作目录,确保上述 `-run` 命令下配置与模板输出路径正确。
|
||||
|
||||
---
|
||||
|
||||
@@ -253,14 +255,16 @@ go test ./app/ -v -run "TestApi/.*/.*未登录"
|
||||
Verify 统一校验(响应值断言 + 数据库状态校验,集中在一个回调中完成)
|
||||
```
|
||||
|
||||
| 环节 | 要求 | 说明 |
|
||||
|------|------|------|
|
||||
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
|
||||
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
|
||||
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
|
||||
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
|
||||
|
||||
| 环节 | 要求 | 说明 |
|
||||
| --------- | ------ | ------------------------------------------------- |
|
||||
| 错误用例 | **必须** | 覆盖权限、参数、业务逻辑等异常路径 |
|
||||
| 测试数据准备 | 按需 | 接口依赖的前置数据用 `a.DB()` 插入 |
|
||||
| Note 备注 | 按需 | 用 `.Note(...)` 补充参数含义、枚举值说明、算法描述等上下文 |
|
||||
| 响应结构校验 | **必须** | 正确请求必须用第三参数校验 result 的类型和结构 |
|
||||
| Verify 校验 | **必须** | 在 Verify 内用 `a.Resp()` 断言响应值 + 用 `a.DB()` 校验数据库状态 |
|
||||
|
||||
|
||||
### 1. 错误用例编写规范
|
||||
|
||||
**错误用例必须写在最前面**,在正确请求之前,按接口错误码顺序依次覆盖:权限校验(status 1/2)→ 参数校验(status 3)→ 业务校验(status 4)。
|
||||
@@ -298,14 +302,16 @@ resp := a.WithSession(Map{"user_id": int64(1)}).
|
||||
|
||||
第三参数作为**类型样本**,框架自动校验 result 的类型是否匹配,**不比较具体值**。第三参数支持所有 JSON 兼容类型:
|
||||
|
||||
| 样本写法 | 校验效果 |
|
||||
|----------|----------|
|
||||
| `int64(1)` | result 是整数类型 |
|
||||
| `float64(1.0)` | result 是浮点类型 |
|
||||
| `"sample"` | result 是字符串类型 |
|
||||
| `true` | result 是布尔类型 |
|
||||
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
|
||||
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
|
||||
|
||||
| 样本写法 | 校验效果 |
|
||||
| ---------------------------- | --------------------- |
|
||||
| `int64(1)` | result 是整数类型 |
|
||||
| `float64(1.0)` | result 是浮点类型 |
|
||||
| `"sample"` | result 是字符串类型 |
|
||||
| `true` | result 是布尔类型 |
|
||||
| `Map{"id": int64(1), ...}` | result 是对象,递归校验字段名+类型 |
|
||||
| `Slice{Map{"id": int64(1)}}` | result 是数组,校验首元素结构 |
|
||||
|
||||
|
||||
```go
|
||||
// result 是 Map 时:值只是类型样本,不比较具体值
|
||||
@@ -393,30 +399,36 @@ a.WithSession(Map{"user_id": int64(1)}).
|
||||
|
||||
**Verify 内可用的方法:**
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
|
||||
| `a.Resp().GetBody()` | 获取完整响应体(Map) |
|
||||
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
|
||||
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
|
||||
|
||||
| 方法 | 说明 |
|
||||
| ------------------------------------- | ---------------------- |
|
||||
| `a.Resp()` | 获取当前请求的响应,用于值断言 |
|
||||
| `a.Resp().GetBody()` | 获取完整响应体(Map) |
|
||||
| `a.Resp().GetBody().GetMap("result")` | 获取 result 对象 |
|
||||
| `a.DB()` | 获取数据库实例(在测试事务内),用于查库校验 |
|
||||
|
||||
|
||||
**Verify 校验要点:**
|
||||
|
||||
| 校验对象 | 说明 | 示例 |
|
||||
|----------|------|------|
|
||||
|
||||
| 校验对象 | 说明 | 示例 |
|
||||
| ----- | ---------------------- | -------------------------------------------------------- |
|
||||
| 响应关键值 | 业务字段的具体值(ID > 0、编号非空等) | `a.Resp().GetBody().GetMap("result").GetCeilInt64("id")` |
|
||||
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
|
||||
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
|
||||
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
|
||||
| 主表记录 | 核心数据是否写入、字段值是否正确 | `a.DB().Get("order", ...)` |
|
||||
| 关联表 | 关联数据是否同步生成 | `a.DB().Count("order_goods", ...)` |
|
||||
| 副作用 | 库存扣减、余额变化、状态流转 | `goods.GetCeilInt64("stock") != 97` |
|
||||
|
||||
|
||||
**适用场景对照:**
|
||||
|
||||
| 接口类型 | Verify 内容 |
|
||||
|----------|-------------|
|
||||
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
|
||||
|
||||
| 接口类型 | Verify 内容 |
|
||||
| ----------- | --------------------- |
|
||||
| 创建类(Insert) | 响应值断言 + 校验记录写入 + 关联表 |
|
||||
| 更新类(Update) | 响应值断言 + 校验字段变更 + 状态流转 |
|
||||
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
|
||||
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
|
||||
| 删除类(Delete) | 响应值断言 + 校验记录已删除/软删除 |
|
||||
| 纯查询(Select) | 响应值断言(无需 DB 校验) |
|
||||
|
||||
|
||||
### 5. Note 备注
|
||||
|
||||
@@ -457,13 +469,15 @@ a.WithSession(Map{"admin_id": int64(1)}).
|
||||
|
||||
**建议使用 Note 的场景:**
|
||||
|
||||
| 场景 | 示例 |
|
||||
|------|------|
|
||||
| 参数含枚举值(status/type/state 等) | `Note("type: 1=个人, 2=企业")` |
|
||||
| 涉及签名、加密、哈希等算法 | `Note("sign = MD5(appKey+timestamp+nonce)")` |
|
||||
| 测试数据有特定计算依据 | `Note("quantity=3, price=29.9, 预期 total=89.7")` |
|
||||
| 接口有调用顺序或前置依赖 | `Note("需先调用 /api/order/create 创建订单")` |
|
||||
| 接口有特殊行为(批量/异步/幂等等) | `Note("幂等接口,重复调用返回相同结果")` |
|
||||
|
||||
| 场景 | 示例 |
|
||||
| --------------------------- | ----------------------------------------------- |
|
||||
| 参数含枚举值(status/type/state 等) | `Note("type: 1=个人, 2=企业")` |
|
||||
| 涉及签名、加密、哈希等算法 | `Note("sign = MD5(appKey+timestamp+nonce)")` |
|
||||
| 测试数据有特定计算依据 | `Note("quantity=3, price=29.9, 预期 total=89.7")` |
|
||||
| 接口有调用顺序或前置依赖 | `Note("需先调用 /api/order/create 创建订单")` |
|
||||
| 接口有特殊行为(批量/异步/幂等等) | `Note("幂等接口,重复调用返回相同结果")` |
|
||||
|
||||
|
||||
> Note 是可选的,但在涉及枚举值、算法、前置依赖等场景时建议使用。好的备注能让其他开发者快速理解用例意图,也让调试控制台成为自文档化的 API 手册。
|
||||
|
||||
@@ -473,6 +487,8 @@ a.WithSession(Map{"admin_id": int64(1)}).
|
||||
|
||||
**省略错误消息(第三参数)**:`a.Post("请先登录", 2)` — 当 `status != 0` 且未传第三参数时,框架自动用 `desc`(第一参数)作为期望的错误消息。适合 desc 与实际 msg 完全一致的场景。
|
||||
|
||||
**跳过错误消息断言(AnyMsg)**:`a.AnyMsg().Post("无效授权码", 1)` — 当 `status != 0` 但错误 msg 含动态内容(网络拨号错误、第三方 API 回传文案等)无法全等断言时,用 `AnyMsg()` 仅断言 status、跳过 msg 校验(同时也不再用 desc 兜底匹配)。`Api` 与 `ApiCase` 均可链式调用,如 `a.JSON(...).AnyMsg().Post(...)`。样例见 `example/app/expect_demo_test.go` 的 `error_demo`。
|
||||
|
||||
**省略 result 结构校验(expect)**:`a.Post("创建成功", 0)` — 仅断言 `status=0`,不校验 result 的内容和结构。适合无需关注返回结构的场景,也适用于非 JSON 响应(二进制文件、纯文本等),此时在 Verify 中通过 `GetRawBody()` 做内容校验。
|
||||
|
||||
**省略 Verify**:不设置 Verify 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
|
||||
@@ -511,25 +527,87 @@ type ApiTestDef struct {
|
||||
### TestApp
|
||||
|
||||
```go
|
||||
// NewTestApp 创建测试应用
|
||||
// configPath: 配置文件路径
|
||||
// projects: TestProj 注册所有项目的路由和测试
|
||||
// listeners: 可选,与 SetConnectListener 相同的请求拦截器
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp
|
||||
// Test 链式入口(延迟 Init,便于先 WorkDir)
|
||||
func Test(configPath string) *TestApp
|
||||
|
||||
func (app *TestApp) WorkDir(dir string) *TestApp
|
||||
func (app *TestApp) Listener(fns ...func(*Context) bool) *TestApp // 可选:连接监听,可多次
|
||||
func (app *TestApp) Swagger(args ...string) *TestApp // title / version / outputDir 均可选
|
||||
func (app *TestApp) Proj(projects TestProj) *TestApp
|
||||
func (app *TestApp) Flows(flows FlowTest) *TestApp // 可选:多接口业务流程
|
||||
func (app *TestApp) Run(m *testing.M) int // 信号停测 + m.Run + 覆盖率 + Swagger 收尾
|
||||
|
||||
// RunTests 运行所有注册的测试(每个方法级别自动事务隔离)
|
||||
func (app *TestApp) RunTests(t *testing.T)
|
||||
|
||||
// PrintCoverage 输出覆盖率报告,返回报告结构体
|
||||
func (app *TestApp) PrintCoverage() CoverageReport
|
||||
|
||||
// GenerateSwagger 生成 API 调试控制台(api-spec.json + index.html)
|
||||
func (app *TestApp) GenerateSwagger(title, version, outputDir string) error
|
||||
|
||||
// DB 获取测试应用的数据库实例(在事务内)
|
||||
func (app *TestApp) DB() *HoTimeDB
|
||||
```
|
||||
|
||||
### 业务流程联调(Flows)
|
||||
|
||||
与单接口 `CtrTest` 并列,用于多接口串起来的业务跑通。整条 Flow 共用一次测试事务,结束回滚。
|
||||
|
||||
**FlowDef 字段**
|
||||
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------- | ------------------------- |
|
||||
| `Group` | 控制台侧栏分组(如 `order`) |
|
||||
| `Desc` | 流程短标题 |
|
||||
| `Prep` | 业务说明 / 前置与数据准备(控制台「业务说明」) |
|
||||
| `Func` | `func(f *Flow)` |
|
||||
|
||||
|
||||
**Flow API**
|
||||
|
||||
|
||||
| 方法 | 说明 |
|
||||
| --------------------- | ------------------------- |
|
||||
| `f.Step(name, path)` | 新步骤,返回 `*Api`(记录归入 flows) |
|
||||
| `f.AtPath(path)` | 同流程内换 path,不增步骤序号 |
|
||||
| `f.WithSession(s)` | 后续步骤携带 session |
|
||||
| `f.DB()` / `f.Resp()` | 流程事务库 / 最近一步响应 |
|
||||
|
||||
|
||||
```go
|
||||
Flows(FlowTest{
|
||||
"checkout": {
|
||||
Group: "order",
|
||||
Desc: "下单支付跑通",
|
||||
Prep: "先创建订单再支付;步骤可用 FromCase 绑定单接口验收用例。",
|
||||
Func: func(f *Flow) {
|
||||
f.Step("创建订单", "/app/order/create").
|
||||
FromCase("正常创建").
|
||||
Note("创建后取 order id 供支付步骤使用").
|
||||
WithSession(Map{"admin_id": 1}).
|
||||
Post("创建", 0, Map{"id": int64(1)})
|
||||
orderID := f.Resp().GetBody().GetMap("result").GetCeilInt64("id")
|
||||
f.Step("支付", "/app/order/pay").
|
||||
WithSession(Map{"admin_id": 1}).
|
||||
JSON(Map{"order_id": orderID}).
|
||||
Post("支付成功", 0)
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**FromCase**
|
||||
|
||||
- 按 path + 用例名加载请求模板:优先本轮已跑的单接口记录,否则读已有 `api-spec.json`
|
||||
- 步骤跑完后回写该单接口 case 的 response / passed
|
||||
- 无模板时当前为**静默不填**(见 [已知局限](#已知局限与后续));建议先用 `-run` 跑绑定的单接口再跑 flow
|
||||
- 示例:`example/app/flow_test.go`
|
||||
|
||||
控制台:侧栏「业务流程 → group → flow 名」;步骤左栏含关联验收用例 / 备注 / 请求,右栏响应;点 path 弹窗看接口只读详情。
|
||||
`-run`:`go test ./app/ -run "TestApi/flows/checkout" -v`
|
||||
|
||||
### 优雅停测
|
||||
|
||||
`Run(m)` 内监听 SIGINT/SIGTERM;测试代码可调 `testApp.RequestStop()` 模拟。
|
||||
|
||||
行为:停收后续接口/流程 → 当前子测跑完 → 回滚测试事务 → 已 flush 的 swagger **保留**(不全量 prune)。
|
||||
示例:`example/app/app_test.go` 的 `TestInterruptStop`。
|
||||
|
||||
---
|
||||
|
||||
## 链式 API 参考
|
||||
@@ -538,47 +616,56 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
|
||||
### Api — 测试入口
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
|------|------|------|
|
||||
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
|
||||
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
|
||||
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
|
||||
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
|
||||
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
|
||||
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
|
||||
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
|
||||
| `a.Delete(desc, status, expect...)` | 直接 DELETE 请求 | `*ApiResponse` |
|
||||
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/校验数据) | `*HoTimeDB` |
|
||||
| `a.Resp()` | 获取最近一次请求的响应(在 Verify 回调中用于值断言) | `*ApiResponse` |
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
| ----------------------------------- | ------------------------------------------------- | -------------- |
|
||||
| `a.JSON(body)` | 设置 JSON body | `*ApiCase` |
|
||||
| `a.Query(params)` | 设置 URL 查询参数 | `*ApiCase` |
|
||||
| `a.Form(body)` | 设置 form 表单 body | `*ApiCase` |
|
||||
| `a.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `a.Note(note)` | 为下一条用例设置备注,备注显示在调试控制台 | `*ApiCase` |
|
||||
| `a.Verify(fn)` | 设置请求后的校验函数(等同于先创建 ApiCase 再 Verify,适合纯 GET 无数据场景) | `*ApiCase` |
|
||||
| `a.FromCase(name)` | 绑定单接口验收用例:加载其请求模板;跑通后回写 response/passed | `*ApiCase` |
|
||||
| `a.WithSession(s)` | 返回携带 session 的新 Api | `*Api` |
|
||||
| `a.RunSub(desc, fn)` | 接口 Func 内再分子场景,便于 `-run` 收窄 | — |
|
||||
| `a.Get(desc, status, expect...)` | 直接 GET 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Post(desc, status, expect...)` | 直接 POST 请求(无数据场景) | `*ApiResponse` |
|
||||
| `a.Put(desc, status, expect...)` | 直接 PUT 请求 | `*ApiResponse` |
|
||||
| `a.Delete(desc, status, expect...)` | 直接 DELETE 请求 | `*ApiResponse` |
|
||||
| `a.DB()` | 获取数据库实例(在事务内,可用于准备/校验数据) | `*HoTimeDB` |
|
||||
| `a.Resp()` | 获取最近一次请求的响应(在 Verify 回调中用于值断言) | `*ApiResponse` |
|
||||
|
||||
|
||||
### ApiCase — 链式构建器
|
||||
|
||||
`Api` 的数据设置方法返回 `*ApiCase`,支持继续叠加数据:
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
|------|------|------|
|
||||
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
|
||||
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
|
||||
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
|
||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||
| `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验)) | `*ApiCase` |
|
||||
| `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
||||
| `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
||||
| `c.Put(desc, status, expect...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
||||
| `c.Delete(desc, status, expect...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
|
||||
|
||||
| 方法 | 说明 | 返回 |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------- | -------------- |
|
||||
| `c.JSON(body)` | 追加 JSON body | `*ApiCase` |
|
||||
| `c.Query(params)` | 追加 URL 查询参数 | `*ApiCase` |
|
||||
| `c.Form(body)` | 追加 form 字段 | `*ApiCase` |
|
||||
| `c.File(field, name, content)` | 设置上传文件 | `*ApiCase` |
|
||||
| `c.Note(note)` | 为本条用例添加可选备注,备注显示在调试控制台用例详情中 | `*ApiCase` |
|
||||
| `c.WithSession(s)` | 覆盖本次请求的 session | `*ApiCase` |
|
||||
| `c.FromCase(name)` | 绑定单接口验收用例名并加载请求模板(若尚未加载) | `*ApiCase` |
|
||||
| `c.Verify(fn)` | 设置请求后的统一校验函数,可用 `a.Resp()` 断言响应值 + `a.DB()` 校验数据库(详见 [Verify 统一校验](#4-verify-统一校验)) | `*ApiCase` |
|
||||
| `c.Get(desc, status, expect...)` | 终端:发送 GET 请求并断言 | `*ApiResponse` |
|
||||
| `c.Post(desc, status, expect...)` | 终端:发送 POST 请求并断言 | `*ApiResponse` |
|
||||
| `c.Put(desc, status, expect...)` | 终端:发送 PUT 请求并断言 | `*ApiResponse` |
|
||||
| `c.Delete(desc, status, expect...)` | 终端:发送 DELETE 请求并断言 | `*ApiResponse` |
|
||||
|
||||
|
||||
### 终端方法参数说明
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
||||
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| -------- | -------------- | -------------------------------- |
|
||||
| `desc` | string | 用例描述,作为 `t.Run` 的子测试名称 |
|
||||
| `status` | int | 期望的 JSON 响应体中 `status` 字段值(0=成功) |
|
||||
| `expect` | ...interface{} | 可选,行为取决于 status(见下方说明) |
|
||||
|
||||
|
||||
**expect 参数解析规则:**
|
||||
|
||||
@@ -596,16 +683,19 @@ func (app *TestApp) DB() *HoTimeDB
|
||||
|
||||
expect 参数作为**类型样本**,只比较类型大类,不比较具体值。result 不一定是 Map,可以是任意类型:
|
||||
|
||||
| 样本值类型 | 匹配的实际类型 | 说明 |
|
||||
|-----------|--------------|------|
|
||||
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
|
||||
| `float64` / `float32` | 浮点类型 | float(小数) |
|
||||
| `string` | `string` | 字符串 |
|
||||
| `bool` | `bool` | 布尔值 |
|
||||
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
|
||||
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
|
||||
|
||||
| 样本值类型 | 匹配的实际类型 | 说明 |
|
||||
| --------------------- | -------------------------------- | ------------- |
|
||||
| `int64` / `int` 等整数 | 整数类型 | number(整数) |
|
||||
| `float64` / `float32` | 浮点类型 | float(小数) |
|
||||
| `string` | `string` | 字符串 |
|
||||
| `bool` | `bool` | 布尔值 |
|
||||
| `Map{...}` | `Map` / `map[string]interface{}` | 对象,递归校验子字段 |
|
||||
| `Slice{...}` | `Slice` / `[]interface{}` | 数组,非空时校验首元素结构 |
|
||||
|
||||
|
||||
校验规则:
|
||||
|
||||
- result 是原始类型(string、int、float 等)→ 直接比较类型大类
|
||||
- result 是 Map → **多了**字段不管,**少了**预设字段报错「字段缺失」
|
||||
- 字段存在但**类型不一致** → 报错「类型不匹配」
|
||||
@@ -639,16 +729,18 @@ resp.ExpectResult(Map{"version": "sample", "features": Slice{}})
|
||||
|
||||
**ApiResponse 可用方法:**
|
||||
|
||||
| 方法 | 返回类型 | 说明 |
|
||||
|------|---------|------|
|
||||
| `resp.GetStatus()` | `int` | 业务 status |
|
||||
| `resp.GetMsg()` | `string` | 业务 msg |
|
||||
| `resp.GetResult()` | `interface{}` | result 字段原始值 |
|
||||
| `resp.GetBody()` | `Map` | 完整 JSON 响应体,可链式 `GetString("key")`、`GetMap("result")` 等 |
|
||||
| `resp.GetRawBody()` | `[]byte` | 原始响应字节(非 JSON 响应如文件下载、二进制流) |
|
||||
| `resp.RawBody` | `[]byte` | 同上,公开字段直接访问 |
|
||||
| `resp.ExpectResult(sample)` | `*ApiResponse` | 后置结构校验(类型样本) |
|
||||
| `resp.Fail(msg)` | — | 手动标记测试失败 |
|
||||
|
||||
| 方法 | 返回类型 | 说明 |
|
||||
| --------------------------- | -------------- | ------------------------------------------------------- |
|
||||
| `resp.GetStatus()` | `int` | 业务 status |
|
||||
| `resp.GetMsg()` | `string` | 业务 msg |
|
||||
| `resp.GetResult()` | `interface{}` | result 字段原始值 |
|
||||
| `resp.GetBody()` | `Map` | 完整 JSON 响应体,可链式 `GetString("key")`、`GetMap("result")` 等 |
|
||||
| `resp.GetRawBody()` | `[]byte` | 原始响应字节(非 JSON 响应如文件下载、二进制流) |
|
||||
| `resp.RawBody` | `[]byte` | 同上,公开字段直接访问 |
|
||||
| `resp.ExpectResult(sample)` | `*ApiResponse` | 后置结构校验(类型样本) |
|
||||
| `resp.Fail(msg)` | — | 手动标记测试失败 |
|
||||
|
||||
|
||||
### 常用写法速查
|
||||
|
||||
@@ -710,49 +802,47 @@ that.Db.Action(func(db HoTimeDB) bool {
|
||||
// 测试结束 → ROLLBACK testTx(回滚一切)
|
||||
```
|
||||
|
||||
| 机制 | 生产模式 | 测试模式 |
|
||||
|------|----------|----------|
|
||||
|
||||
| 机制 | 生产模式 | 测试模式 |
|
||||
| ---------- | ------------------------------- | --------------------------------------- |
|
||||
| `Action()` | `BEGIN` / `COMMIT` / `ROLLBACK` | `SAVEPOINT` / `RELEASE` / `ROLLBACK TO` |
|
||||
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
|
||||
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
|
||||
| 数据持久化 | 持久化到数据库 | 测试结束全部回滚 |
|
||||
| 对现有代码影响 | 无 | 无(`testTx` 默认 nil) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## API 调试控制台
|
||||
|
||||
`GenerateSwagger` 按模块生成独立的交互式 API 调试控制台(暗色高对比度主题),每个模块一个子目录,支持独立分发。
|
||||
通过链式 `.Swagger(title, version)` 启用。
|
||||
|
||||
```go
|
||||
testApp.GenerateSwagger(
|
||||
"My API", // 文档标题
|
||||
"2.1.0", // 版本号
|
||||
testApp.Config.GetString("tpt"), // 输出目录(如 "tpt")
|
||||
)
|
||||
```
|
||||
**增量写入**:每个接口 / 流程跑完即 flush;本轮无记录则不覆盖成「未测」;未全量跑完不 prune 已删接口;仅全量完成才清理孤儿模块。Ctrl+C / SIGTERM 走优雅停测后再收尾。
|
||||
|
||||
输出目录默认 `config` 中的 `tpt`。
|
||||
|
||||
### 生成目录结构
|
||||
|
||||
每个模块(`TestProj` 中的项目名)生成独立子目录,同时自动生成根导航页:
|
||||
|
||||
```
|
||||
{outputDir}/swagger/
|
||||
├── index.html ← 根导航页(API 文档中心,卡片式链接到各模块)
|
||||
├── api/
|
||||
│ ├── api-spec.json ← api 模块的 API 规范(路由、测试用例、请求/响应数据)
|
||||
│ └── index.html ← api 模块的调试控制台
|
||||
├── admin/
|
||||
│ ├── api-spec.json
|
||||
├── index.html ← 根导航页
|
||||
├── app/
|
||||
│ ├── api-spec.json ← endpoints + flows
|
||||
│ └── index.html
|
||||
└── portal/
|
||||
├── api-spec.json
|
||||
└── index.html
|
||||
└── ...
|
||||
```
|
||||
|
||||
访问地址:`http://localhost:8081/swagger/`(根导航页),`http://localhost:8081/swagger/api/`(api 模块)
|
||||
访问:`http://localhost:8081/swagger/`(根导航),`http://localhost:8081/swagger/app/`(模块)。
|
||||
|
||||
> **独立分发**:每个模块目录可以单独拷贝和部署,不依赖其他模块。根导航页自动扫描子目录生成链接。
|
||||
### 控制台能力
|
||||
|
||||
|
||||
| 区域 | 行为 |
|
||||
| --- | -------------------------------------------------------------- |
|
||||
| 侧栏 | 业务流程 → group → flow 名;接口仍按 project/ctr 三级 |
|
||||
| 流程页 | Prep 业务说明;步骤默认展开;左备注/关联验收用例/请求(cURL),右响应;点 path 弹窗只读详情(Esc 关闭) |
|
||||
| 单接口 | 用例预填、全局认证;**发送结果 cURL 随左侧 Header/Query/JSON/Form 实时同步** |
|
||||
| 发送 | 浏览器 `fetch` 打当前源站,**需已启动服务**(与 httptest 零启动测试不同) |
|
||||
|
||||
控制台支持三级导航、关键字搜索、用例预填、全局认证(Header/URL/Cookie)、cURL 生成、覆盖率可视化等功能。
|
||||
|
||||
---
|
||||
|
||||
@@ -761,7 +851,7 @@ testApp.GenerateSwagger(
|
||||
框架自动处理以下问题,编写测试时无需关心:
|
||||
|
||||
- **并发保护**:`testTx` 是单连接,框架通过 `testMu` 互斥锁自动序列化所有数据库操作,业务代码中的 `go func()` 协程不会导致 `busy buffer` 错误
|
||||
- **缓存隔离**:`NewTestApp` 启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
|
||||
- **缓存隔离**:启动时自动禁用 DB/Redis 缓存,避免与测试事务产生锁冲突;Session 通过 `WithSession()` 直接注入 Memory 缓存
|
||||
|
||||
### 一次性数据的处理
|
||||
|
||||
@@ -844,42 +934,31 @@ a.Verify(func(a *Api) error {
|
||||
|
||||
### RawBody vs Body 的关系
|
||||
|
||||
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
|
||||
|------|-------------|-------------------|
|
||||
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
|
||||
| 二进制文件(XLSX/PNG/PDF) | 空 Map(解析失败) | 完整二进制内容 |
|
||||
| 纯文本(CSV/TXT/HTML) | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
|
||||
| 空响应 | 空 Map | 空 `[]byte` |
|
||||
|
||||
| 场景 | `Body (Map)` | `RawBody ([]byte)` |
|
||||
| ------------------- | ------------- | ---------------------- |
|
||||
| JSON 响应 | 已解析的结构化 Map | 原始 JSON 字节 |
|
||||
| 二进制文件(XLSX/PNG/PDF) | 空 Map(解析失败) | 完整二进制内容 |
|
||||
| 纯文本(CSV/TXT/HTML) | 空 Map(非 JSON) | 完整文本字节,可 `string()` 转换 |
|
||||
| 空响应 | 空 Map | 空 `[]byte` |
|
||||
|
||||
|
||||
> **注意**:二进制/纯文本响应不走 status 校验(JSON 解析失败后 `Body` 为空 Map,`status` 默认 0),
|
||||
> 所以 `Get("描述", 0)` 只是形式上通过。**真正的校验逻辑必须写在 Verify 中**,对 `RawBody` 做内容断言。
|
||||
|
||||
## 全量回归与覆盖率(仅 CI / 发版前)
|
||||
## 全量回归与覆盖率
|
||||
|
||||
> **本节仅适用于 CI 流水线或发版前人工回归。** 日常开发与 AI 辅助请使用文首的 `-run` 单测命令,不要执行本节命令。
|
||||
|
||||
### 全量命令
|
||||
也支持对整个测试包做全量回归,**仅 CI / 发版前使用**:
|
||||
|
||||
```bash
|
||||
# 跑完当前包(或 ./app/...)下全部 API 测试——慢,且任一无关失败都会打断你
|
||||
go test ./app/... -v
|
||||
```
|
||||
|
||||
`TestMain` 里常见的收尾逻辑(全量跑完后才有意义):
|
||||
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
// ...
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage() // 全量跑完后输出覆盖率
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
}
|
||||
```
|
||||
全量跑完后 `Run` 会调用 `PrintCoverage()` 输出覆盖率报告。
|
||||
|
||||
### 覆盖率报告
|
||||
|
||||
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由):
|
||||
`PrintCoverage()` 自动对比 `Proj`(所有路由)和 `ProjTest`(有测试的路由)。**Flow 步骤不计入覆盖率**(`FlowName != ""` 的记录会被跳过)。
|
||||
|
||||
**输出示例(全部通过):**
|
||||
|
||||
@@ -913,6 +992,7 @@ func TestMain(m *testing.M) {
|
||||
```
|
||||
|
||||
报告分为四个区域:
|
||||
|
||||
- **汇总区**:接口覆盖率 + 用例通过率
|
||||
- **未通过的接口**:失败接口单独置顶,附带失败原因
|
||||
- **通过的接口**:正常通过的接口列表
|
||||
@@ -937,3 +1017,22 @@ func TestMain(m *testing.M) {
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已知局限与后续
|
||||
|
||||
|
||||
| 优先级 | 缺口 | 说明 |
|
||||
| --- | ----------------------- | ---------------------------- |
|
||||
| 高 | 值级断言助手 | 结构校验有,值断言靠 Verify 手写 |
|
||||
| 高 | DB 断言助手 | 无 AssertCount / AssertRow 一类 |
|
||||
| 高 | FromCase 无模板显式失败 | 现静默不填,易空 body 误测 |
|
||||
| 中 | 自定义 Header / Cookie API | 控制台有,测试链无 `WithHeader` |
|
||||
| 中 | HTTP 状态码 / 响应头断言 | StatusCode 有写入,无公开断言 |
|
||||
| 中 | 覆盖率含 Flow | PrintCoverage 跳过 Flow 记录 |
|
||||
| 低 | PATCH、多文件上传 | 当前边界 |
|
||||
| 低 | 框架级 Seed/Fixture | 依赖业务自写 `a.DB()` 准备 |
|
||||
|
||||
|
||||
规划表见 [ROADMAP_改进规划.md](ROADMAP_改进规划.md)。
|
||||
+118
-12
@@ -1,7 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
@@ -18,27 +21,130 @@ var ProjectTest = ProjTest{
|
||||
var testApp *TestApp
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Go test 的 CWD 是包目录 example/app/,切回 example/ 使相对路径正确
|
||||
_ = os.Chdir("..")
|
||||
|
||||
testApp = NewTestApp("config/config.json",
|
||||
TestProj{
|
||||
testApp = Test("config/config.json").
|
||||
WorkDir("..").
|
||||
Listener(func(ctx *Context) bool {
|
||||
return false // 空实现:继续进控制器;需要鉴权/拦截时在此写
|
||||
}).
|
||||
Swagger("My API", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjectTest,
|
||||
},
|
||||
},
|
||||
)
|
||||
// 通用 CRUD(codeConfig=admin)路由由 Init 时按库表生成,这里仅挂测试
|
||||
"admin": {
|
||||
Proj: Proj{},
|
||||
Tests: AdminDepartmentTest,
|
||||
},
|
||||
}).
|
||||
Flows(DemoFlows)
|
||||
|
||||
// 在测试前初始化各数据库的测试表和种子数据
|
||||
// 仅本 example:空库时灌演示数据。须连专用测试库,勿对生产库调用。
|
||||
SetupDatabase(&testApp.Db)
|
||||
|
||||
code := m.Run()
|
||||
testApp.PrintCoverage()
|
||||
testApp.GenerateSwagger("My API", "1.0.0", testApp.Config.GetString("tpt"))
|
||||
os.Exit(code)
|
||||
os.Exit(testApp.Run(m))
|
||||
}
|
||||
|
||||
func TestApi(t *testing.T) {
|
||||
testApp.RunTests(t)
|
||||
}
|
||||
|
||||
// TestListenerChain 验证链式 Listener 在请求路径上被调用。
|
||||
// 注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestListenerChain(t *testing.T) {
|
||||
hit := false
|
||||
app := Test("config/config.json").
|
||||
Listener(func(ctx *Context) bool {
|
||||
hit = true
|
||||
return false
|
||||
}).
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjTest{
|
||||
"expect_demo": CtrTest{
|
||||
"string_result": {Desc: "触发 listener", Func: func(a *Api) {
|
||||
ExpectDemoTest["string_result"].Func(a)
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
SetupDatabase(&app.Db)
|
||||
app.RunTests(t)
|
||||
|
||||
if !hit {
|
||||
t.Fatal("Listener 未被调用")
|
||||
}
|
||||
_ = app.Db.RollbackTestTx()
|
||||
}
|
||||
|
||||
// TestInterruptStop 模拟提前中断:string_result 跑完后 RequestStop,zzz_should_skip 应 Skip;
|
||||
// swagger 中 string_result 的 cases 应保留。注意:TestMain 已 Chdir 到 example/,此处不再 WorkDir。
|
||||
func TestInterruptStop(t *testing.T) {
|
||||
var app *TestApp
|
||||
secondRan := false
|
||||
|
||||
app = Test("config/config.json").
|
||||
Swagger("Interrupt Demo", "1.0.0").
|
||||
Proj(TestProj{
|
||||
"app": {
|
||||
Proj: Project,
|
||||
Tests: ProjTest{
|
||||
"expect_demo": CtrTest{
|
||||
"string_result": {Desc: "先跑并停测", Func: func(a *Api) {
|
||||
ExpectDemoTest["string_result"].Func(a)
|
||||
app.RequestStop()
|
||||
}},
|
||||
"zzz_should_skip": {Desc: "应被跳过", Func: func(a *Api) {
|
||||
secondRan = true
|
||||
t.Error("zzz_should_skip 在 RequestStop 后不应执行")
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
SetupDatabase(&app.Db)
|
||||
app.RunTests(t)
|
||||
|
||||
if secondRan {
|
||||
t.Fatal("停测后仍执行了后续接口")
|
||||
}
|
||||
_ = app.Db.RollbackTestTx()
|
||||
|
||||
// 收尾写盘(中断路径下可能未跑满 GenerateSwagger,显式补一次增量合并)
|
||||
if err := app.GenerateSwagger("Interrupt Demo", "1.0.0", app.Config.GetString("tpt")); err != nil {
|
||||
t.Fatalf("GenerateSwagger: %v", err)
|
||||
}
|
||||
|
||||
specPath := filepath.Join(app.Config.GetString("tpt"), "swagger", "app", "api-spec.json")
|
||||
data, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("应已生成 swagger %s: %v", specPath, err)
|
||||
}
|
||||
var spec struct {
|
||||
Endpoints []struct {
|
||||
Path string `json:"path"`
|
||||
Cases json.RawMessage `json:"cases"`
|
||||
} `json:"endpoints"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
t.Fatalf("解析 api-spec 失败: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, ep := range spec.Endpoints {
|
||||
if ep.Path != "/app/expect_demo/string_result" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(string(ep.Cases), `"name"`) {
|
||||
t.Fatalf("string_result 应保留测试 cases,实际: %s", string(ep.Cases))
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("swagger 中应包含已跑完的 /app/expect_demo/string_result")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ var ExpectDemoTest = CtrTest{
|
||||
// ======== 第一步:错误用例(先行) ========
|
||||
a.Form(Map{"name": ""}).Post("名称为空-缺少必填字段", 3, "名称不能为空")
|
||||
|
||||
// AnyMsg:跳过错误 msg 断言(仅断言 status)。用于响应 msg 含网络错误等
|
||||
// 动态内容的用例——desc 与实际 msg 不一致时,无 AnyMsg 会因 desc 兜底匹配而失败
|
||||
a.Form(Map{"name": ""}).AnyMsg().Post("AnyMsg跳过msg断言-desc与实际msg不一致", 3)
|
||||
|
||||
// ======== 第二步:正确请求 + 结构校验 + Verify 值断言 ========
|
||||
a.Form(Map{"name": "测试商品"}).
|
||||
Verify(func(a *Api) error {
|
||||
@@ -231,25 +235,6 @@ var ExpectDemoTest = CtrTest{
|
||||
}).
|
||||
Post("写入并校验DB+响应", 0, Map{"id": int64(1), "name": "sample"})
|
||||
|
||||
// ======== 演示故意失败的 Verify ========
|
||||
a.Form(Map{"name": "verify_fail"}).
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") <= 0 {
|
||||
return fmt.Errorf("返回的 id 必须大于 0, 实际 %d", result.GetCeilInt64("id"))
|
||||
}
|
||||
row := a.DB().Get("test_batch", "*", Map{"AND": Map{"name": "verify_fail", "title": "verify_demo"}})
|
||||
if row == nil {
|
||||
return fmt.Errorf("test_batch 表未找到 name=verify_fail 的记录")
|
||||
}
|
||||
if row.GetCeilInt64("state") != 99 {
|
||||
return fmt.Errorf("test_batch.state 期望 99, 实际 %d(接口写入 0,故意校验 99 演示失败效果)", row.GetCeilInt64("state"))
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Post("写入并校验DB-故意失败", 0, Map{"id": int64(1), "name": "sample"})
|
||||
// Verify 返回 error 会使该用例失败并写入 failReason(见覆盖率报告「未通过的接口」)
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// DemoFlows 多接口业务流程联调示例(整条 Flow 共用一次测试事务,结束回滚)
|
||||
var DemoFlows = FlowTest{
|
||||
"expect_chain": {
|
||||
Group: "expect_demo",
|
||||
Desc: "expect_demo 多接口联调",
|
||||
Prep: "依次调用 string_result 与 map_result。\n步骤通过 FromCase 复用单接口验收用例的请求模板,跑通后回写该用例的响应与通过状态。",
|
||||
Func: func(f *Flow) {
|
||||
f.Step("字符串结果", "/app/expect_demo/string_result").
|
||||
FromCase("result是字符串").
|
||||
Note("校验字符串 result:期望返回「操作成功」。").
|
||||
Verify(func(a *Api) error {
|
||||
if a.Resp().GetBody().GetString("result") != "操作成功" {
|
||||
return fmt.Errorf("result 期望 操作成功")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤1-字符串", 0, "样本")
|
||||
|
||||
f.Step("Map结果", "/app/expect_demo/map_result").
|
||||
FromCase("result是Map-校验字段名+类型+值").
|
||||
Note("校验对象 result:至少包含 id=1,用于串联后续步骤前的结构确认。").
|
||||
Verify(func(a *Api) error {
|
||||
result := a.Resp().GetBody().GetMap("result")
|
||||
if result == nil {
|
||||
return fmt.Errorf("result 为 nil")
|
||||
}
|
||||
if result.GetCeilInt64("id") != 1 {
|
||||
return fmt.Errorf("id 期望 1")
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("步骤2-Map", 0, Map{"id": int64(1), "name": "sample", "price": float64(1.0), "in_stock": true})
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package app
|
||||
|
||||
// 通用 CRUD 树表查询(MakeCode.Search 的 parent_id / showself / showall 语义)行为测试。
|
||||
// 回归背景:parent_id=X 且 showself=1 时旧逻辑拼出 OR(parent_id=X, id=X),
|
||||
// 把节点 X 自己当作 X 的子级返回,前端树无限嵌套(无限套娃)。
|
||||
// 依赖 department 树表(表名避开 admin.json 遗留 flow 对 org 的 admin_id 注入;
|
||||
// 见 setup_mysql.go;表需在 Init 前已存在于测试库)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "code.hoteas.com/golang/hotime"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
)
|
||||
|
||||
// resultIds 提取 result.data 的 id 集合
|
||||
func resultIds(a *Api) map[int64]bool {
|
||||
ids := map[int64]bool{}
|
||||
data := a.Resp().GetBody().GetMap("result").GetSlice("data")
|
||||
for k := range data {
|
||||
ids[data.GetMap(k).GetCeilInt64("id")] = true
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
var AdminDepartmentTest = ProjTest{
|
||||
"department": CtrTest{
|
||||
"search": {Desc: "树表通用查询:parent_id 子级懒加载不得返回自身", Func: func(a *Api) {
|
||||
// ======== 错误用例 ========
|
||||
a.Query(Map{"parent_id": "1", "showself": "1"}).
|
||||
Get("未登录访问", 2, "你还没有登录")
|
||||
|
||||
// ======== 准备树数据:root → nodeX → child ========
|
||||
adminId := a.DB().Insert("admin", Map{
|
||||
"name": "树测管理员", "phone": "139" + ObjToStr(RandX(10000000, 99999999)),
|
||||
"state": 1, "password": Md5("tree123"), "role_id": 1,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
session := Map{"admin_id": adminId}
|
||||
|
||||
rootId := a.DB().Insert("department", Map{
|
||||
"name": "树测根部门", "state": 0,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + ","}, Map{"id": rootId})
|
||||
|
||||
nodeX := a.DB().Insert("department", Map{
|
||||
"name": "树测节点X", "parent_id": rootId, "state": 0,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + ","}, Map{"id": nodeX})
|
||||
|
||||
childId := a.DB().Insert("department", Map{
|
||||
"name": "树测子节点", "parent_id": nodeX, "state": 0,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(childId) + ","}, Map{"id": childId})
|
||||
|
||||
rowSample := Map{"count": int64(1), "data": Slice{Map{"id": int64(1), "name": "sample"}}}
|
||||
|
||||
// ======== 核心回归:子级懒加载带 showself 不得包含节点自身 ========
|
||||
a.WithSession(session).
|
||||
Note("旧缺陷:OR(parent_id=X, id=X) 把 X 自己当作 X 的子级返回,树无限嵌套").
|
||||
Query(Map{"parent_id": nodeX, "showself": "1", "pageSize": "50"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if ids[nodeX] {
|
||||
return fmt.Errorf("子级查询结果不应包含节点自身 id=%d", nodeX)
|
||||
}
|
||||
if !ids[childId] {
|
||||
return fmt.Errorf("子级查询结果应包含直接子级 id=%d", childId)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("子级懒加载showself不返回自身", 0, rowSample)
|
||||
|
||||
// ======== 不传 showself:同样只返回直接子级 ========
|
||||
a.WithSession(session).
|
||||
Query(Map{"parent_id": nodeX, "pageSize": "50"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if ids[nodeX] || !ids[childId] {
|
||||
return fmt.Errorf("普通子级查询应只含直接子级,实际: %v", ids)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("普通子级查询只返回子级", 0, rowSample)
|
||||
|
||||
// ======== showall+showself(管理端列表按树筛选场景):含自身与全部子孙 ========
|
||||
a.WithSession(session).
|
||||
Note("Table.vue 树筛选场景:showall=1 展示选中节点及全部子孙,showself=1 保留自身").
|
||||
Query(Map{"parent_id": nodeX, "showself": "1", "showall": "1", "pageSize": "50"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if !ids[nodeX] {
|
||||
return fmt.Errorf("showall+showself 应包含节点自身 id=%d", nodeX)
|
||||
}
|
||||
if !ids[childId] {
|
||||
return fmt.Errorf("showall+showself 应包含子孙 id=%d", childId)
|
||||
}
|
||||
if ids[rootId] {
|
||||
return fmt.Errorf("showall+showself 不应包含父级 id=%d", rootId)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("showall加showself含自身与子孙", 0, rowSample)
|
||||
|
||||
// ======== 修复回归:文本字段部分关键词模糊匹配 + 非文本字段等值筛选不受影响 ========
|
||||
// 背景 bug(code/makecode.go Search):文本类字段(如 name)曾同时生成等值条件
|
||||
// name=值 与模糊条件 name[~]=值,二者在 where 中以 AND 叠加;前端筛选框传入
|
||||
// 部分关键词时等值条件必不命中,AND 之后查询恒为空。
|
||||
// 修复后:文本字段只走模糊匹配;非文本字段(如 state,规则覆盖为 select 类型)维持等值匹配现状。
|
||||
textId := a.DB().Insert("department", Map{
|
||||
"name": "综合行政部", "state": 5, "parent_id": nodeX,
|
||||
"create_time[#]": "NOW()", "modify_time[#]": "NOW()",
|
||||
})
|
||||
a.DB().Update("department", Map{"parent_ids": "," + ObjToStr(rootId) + "," + ObjToStr(nodeX) + "," + ObjToStr(textId) + ","}, Map{"id": textId})
|
||||
|
||||
a.WithSession(session).
|
||||
Note("修复前:name 等值(综合行政部≠行政)与模糊[~]同时AND叠加,传部分关键词恒查不到;修复后应命中模糊匹配").
|
||||
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "name": "行政"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if !ids[textId] {
|
||||
return fmt.Errorf("文本字段部分关键词模糊查询应命中 id=%d, 实际: %v", textId, ids)
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("文本字段部分关键词命中模糊匹配", 0, rowSample)
|
||||
|
||||
a.WithSession(session).
|
||||
Note("state 规则覆盖为 select 类型(非text),应维持等值匹配现状,只精确命中 state=5 的记录").
|
||||
Query(Map{"parent_id": rootId, "showself": "1", "showall": "1", "pageSize": "50", "state": "5"}).
|
||||
Verify(func(a *Api) error {
|
||||
ids := resultIds(a)
|
||||
if !ids[textId] {
|
||||
return fmt.Errorf("非文本字段等值筛选应命中 id=%d, 实际: %v", textId, ids)
|
||||
}
|
||||
for id := range ids {
|
||||
if id != textId {
|
||||
return fmt.Errorf("非文本字段等值筛选不应包含其它 state 的记录,实际额外命中: id=%d", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}).
|
||||
Get("非文本字段等值筛选行为不变", 0, rowSample)
|
||||
}},
|
||||
},
|
||||
}
|
||||
@@ -83,6 +83,26 @@ func createMySQLTables(db *HoTimeDB, prefix string) {
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
|
||||
}
|
||||
|
||||
// department 部门表(树形结构,供通用 CRUD 树查询测试使用;
|
||||
// 不叫 org 是因为 config/admin.json 遗留 flow 会给 org 注入 admin_id 过滤)
|
||||
// 注意:MakeCode 路由在 Init 时按库中已有表生成,
|
||||
// 全新空库首次运行建表晚于 Init,/admin/department/search 需第二次运行才可用
|
||||
tbl = prefix + "department"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
fmt.Println("[MySQL Setup] 创建表:", tbl)
|
||||
db.Exec("CREATE TABLE `" + tbl + "` (" +
|
||||
"`id` int(11) unsigned NOT NULL AUTO_INCREMENT," +
|
||||
"`name` varchar(100) DEFAULT NULL COMMENT '部门名称'," +
|
||||
"`parent_id` int(11) DEFAULT NULL COMMENT '父级ID'," +
|
||||
"`parent_ids` varchar(255) DEFAULT NULL COMMENT '层级路径'," +
|
||||
"`state` int(2) DEFAULT '0'," +
|
||||
"`create_time` datetime DEFAULT NULL," +
|
||||
"`modify_time` datetime DEFAULT NULL," +
|
||||
"PRIMARY KEY (`id`)," +
|
||||
"KEY `idx_parent_id` (`parent_id`)" +
|
||||
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表'")
|
||||
}
|
||||
|
||||
// test_batch 测试批量表
|
||||
tbl = prefix + "test_batch"
|
||||
if !mysqlTableExists(db, tbl) {
|
||||
|
||||
+3
-2
@@ -145,9 +145,10 @@ func testBasicCRUD(that *Context) Map {
|
||||
now := nowFunc(that)
|
||||
|
||||
insertTest := Map{"name": "Insert 插入测试 (admin表)"}
|
||||
uniq := time.Now().UnixNano()
|
||||
adminId := that.Db.Insert("admin", Map{
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", time.Now().Unix()),
|
||||
"phone": fmt.Sprintf("138%d", time.Now().Unix()%100000000),
|
||||
"name": "测试管理员_" + fmt.Sprintf("%d", uniq),
|
||||
"phone": fmt.Sprintf("139%08d", uniq%100000000),
|
||||
"state": 1,
|
||||
"password": "test123456",
|
||||
"role_id": 1,
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
{
|
||||
"endpoints": [
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "cache",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/cache/all",
|
||||
"project": "app",
|
||||
"summary": "缓存全部测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "cache",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/cache/batch",
|
||||
"project": "app",
|
||||
"summary": "批量缓存测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "cache",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/cache/compat",
|
||||
"project": "app",
|
||||
"summary": "缓存兼容模式测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "dmdb",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/dmdb/describe",
|
||||
"project": "app",
|
||||
"summary": "查询达梦表结构",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "dmdb",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/dmdb/rawsql",
|
||||
"project": "app",
|
||||
"summary": "达梦原生 SQL 测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "dmdb",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/dmdb/tables",
|
||||
"project": "app",
|
||||
"summary": "查询达梦所有表",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/bool_result",
|
||||
"project": "app",
|
||||
"summary": "返回布尔-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/error_demo",
|
||||
"project": "app",
|
||||
"summary": "错误先行+正确请求完整校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/float_result",
|
||||
"project": "app",
|
||||
"summary": "返回小数-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/map_result",
|
||||
"project": "app",
|
||||
"summary": "返回对象-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/nested_result",
|
||||
"project": "app",
|
||||
"summary": "返回嵌套对象-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/no_expect",
|
||||
"project": "app",
|
||||
"summary": "最小正确用例-仍需值断言",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/number_result",
|
||||
"project": "app",
|
||||
"summary": "返回整数-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/slice_result",
|
||||
"project": "app",
|
||||
"summary": "返回数组-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/string_result",
|
||||
"project": "app",
|
||||
"summary": "返回字符串-结构+值校验",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "expect_demo",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/expect_demo/verify_demo",
|
||||
"project": "app",
|
||||
"summary": "Verify完整范式演示",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "mysql",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/mysql/describe",
|
||||
"project": "app",
|
||||
"summary": "查询 MySQL 表结构",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "mysql",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/mysql/rawsql",
|
||||
"project": "app",
|
||||
"summary": "MySQL 原生 SQL 测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "mysql",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/mysql/tables",
|
||||
"project": "app",
|
||||
"summary": "查询 MySQL 所有表",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/aggregate",
|
||||
"project": "app",
|
||||
"summary": "聚合函数测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/all",
|
||||
"project": "app",
|
||||
"summary": "运行全部通用测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/batch",
|
||||
"project": "app",
|
||||
"summary": "批量插入测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/chain",
|
||||
"project": "app",
|
||||
"summary": "链式查询测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/condition",
|
||||
"project": "app",
|
||||
"summary": "条件查询语法测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/crud",
|
||||
"project": "app",
|
||||
"summary": "基础 CRUD 测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/join",
|
||||
"project": "app",
|
||||
"summary": "JOIN 查询测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/pagination",
|
||||
"project": "app",
|
||||
"summary": "分页查询测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/test",
|
||||
"project": "app",
|
||||
"summary": "固定错误返回",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/transaction",
|
||||
"project": "app",
|
||||
"summary": "事务测试",
|
||||
"tested": true
|
||||
},
|
||||
{
|
||||
"cases": null,
|
||||
"ctr": "test",
|
||||
"method": "POST",
|
||||
"params": [],
|
||||
"path": "/app/test/upsert",
|
||||
"project": "app",
|
||||
"summary": "Upsert 测试",
|
||||
"tested": true
|
||||
}
|
||||
],
|
||||
"title": "My API",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
<!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;--green:#66bb6a;--red:#ef5350;--border:#2a2a4a;--r:12px}
|
||||
*{box-sizing:border-box;margin:0;padding:0}body{display:flex;height:100vh;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,sans-serif}
|
||||
input,select,textarea,button{font:inherit}pre{margin:0}
|
||||
#side{width:260px;min-width:260px;display:flex;flex-direction:column;border-right:1px solid var(--border);overflow:hidden}
|
||||
.shd{padding:10px;border-bottom:1px solid var(--border)}.shd h3{font-size:13px;color:var(--accent);margin-bottom:6px}
|
||||
.shd input{width:100%;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
.shd input:focus{border-color:var(--accent)}.sft{display:flex;gap:2px;margin-top:5px}
|
||||
.sft button{flex:1;padding:3px;border:1px solid var(--border);border-radius:var(--r);background:transparent;color:var(--fg2);font-size:11px;cursor:pointer}
|
||||
.sft button.on{background:var(--bg3);color:var(--accent);border-color:var(--accent)}
|
||||
#tree{flex:1;overflow-y:auto;min-height:0}#tree::-webkit-scrollbar{width:4px}#tree::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
|
||||
.l1{border-bottom:1px solid var(--border)}.l1h,.l2h{display:flex;align-items:center;cursor:pointer}
|
||||
.l1h{padding:5px 8px;font-weight:700;color:var(--accent);font-size:13px}.l1h:hover,.l2h:hover{background:var(--bg2)}
|
||||
.l2h{padding:3px 8px 3px 20px;color:#81d4fa;font-size:12px}
|
||||
.ar{margin-right:4px;font-size:8px;transition:transform .1s;color:#555}.ar.o{transform:rotate(90deg);color:var(--accent)}
|
||||
.cn{margin-left:auto;font-size:10px;color:#888;background:rgba(255,255,255,.06);padding:0 5px;border-radius:8px;min-width:18px;text-align:center}.sub{display:none}.sub.o{display:block}
|
||||
.ep{display:flex;align-items:center;padding:3px 6px 3px 32px;cursor:pointer;gap:4px;color:var(--fg2)}
|
||||
.ep:hover{background:var(--bg2);color:var(--fg)}.ep.act{background:var(--bg3);color:var(--accent)}
|
||||
.ep .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mt{font-size:9px;padding:1px 4px;border-radius:2px;font-weight:700;flex-shrink:0}
|
||||
.mt-get{background:#1b5e20;color:#a5d6a7}.mt-post{background:#0d47a1;color:#90caf9}.mt-put{background:#e65100;color:#ffcc80}.mt-delete{background:#b71c1c;color:#ef9a9a}
|
||||
.tg{font-size:9px;padding:1px 4px;border-radius:2px;flex-shrink:0}
|
||||
.tg-ok{background:#1b5e20;color:#a5d6a7}.tg-err{background:#b71c1c;color:#ef9a9a}.tg-no{background:#333;color:#555}
|
||||
#main{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
#bar{padding:5px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:6px;flex-shrink:0}
|
||||
#bar label{color:var(--fg2);font-size:11px}
|
||||
#bar select,#bar input{padding:3px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
#bar input{flex:1;max-width:280px}#bar select{width:auto}
|
||||
#ct{flex:1;display:flex;flex-direction:column;overflow:hidden;padding:0}
|
||||
.ct-hd{padding:6px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.ehd{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.ehd .mt{font-size:13px;padding:3px 10px}.ehd .pa{font-size:15px;font-weight:600}.ehd .ds{color:var(--fg2);font-size:13px}
|
||||
.ehd .nt{color:var(--red);font-size:11px;padding:2px 6px;border:1px solid var(--red);border-radius:var(--r)}
|
||||
.case-cnt{font-size:11px;color:var(--fg2);padding:1px 8px;border:1px solid var(--border);border-radius:10px;white-space:nowrap;flex-shrink:0}
|
||||
.pf-wrap{position:relative;flex:1;min-width:0}.pf-btn{width:100%;display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);cursor:pointer;font-size:12px;color:var(--fg);outline:none;box-sizing:border-box}.pf-btn:hover{border-color:#555}.pf-dd{position:absolute;top:calc(100% + 2px);left:0;right:0;background:#161625;border:1px solid var(--border);border-radius:var(--r);z-index:200;list-style:none;padding:3px 0;margin:0;max-height:200px;overflow-y:auto;display:none;box-shadow:0 4px 12px rgba(0,0,0,.5)}.pf-dd.on{display:block}.pf-opt{display:flex;align-items:center;gap:6px;padding:5px 10px;cursor:pointer;font-size:12px}.pf-opt:hover{background:var(--bg3)}.pf-opt.sel{color:var(--accent)}
|
||||
.split{display:flex;flex:1;min-height:0;overflow:hidden}
|
||||
.split-l{width:560px;min-width:220px;overflow-y:auto;padding:10px 12px;border-right:1px solid var(--border);flex-shrink:0}
|
||||
.split-l::-webkit-scrollbar{width:4px}.split-l::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
|
||||
.split-r{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
|
||||
.rtabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:0 4px}
|
||||
.rtab{padding:6px 14px;cursor:pointer;color:var(--fg2);border-bottom:2px solid transparent;font-size:12px}
|
||||
.rtab:hover{color:var(--fg)}.rtab.on{color:var(--accent);border-color:var(--accent)}
|
||||
.rpn{display:none;overflow-y:auto;padding:12px;flex:1}.rpn.on{display:block}
|
||||
.rpn::-webkit-scrollbar{width:4px}.rpn::-webkit-scrollbar-thumb{background:#444;border-radius:2px}
|
||||
.cs{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
|
||||
.csh{padding:6px 10px;display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;background:var(--bg2)}
|
||||
.csh:hover{background:var(--bg3)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.dot.ok{background:var(--green)}.dot.er{background:var(--red)}.csh .ml{margin-left:auto;color:#555;font-size:11px}
|
||||
.csb{display:none;border-top:1px solid var(--border);font-size:12px}.csb.o{display:block}
|
||||
.csr{display:flex}.csr>.csc{flex:1;padding:8px 10px;min-width:0}.csr>.csc+.csc{border-left:1px solid var(--border)}
|
||||
.csc h5{color:var(--accent);margin:0 0 4px;font-size:11px;letter-spacing:.5px;display:flex;align-items:center}
|
||||
.csc h5 .cp-sm{margin-left:auto}.csc h5:not(:first-child){margin-top:8px}
|
||||
pre.j{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:12px;line-height:1.4;color:#aaa;white-space:pre-wrap;word-break:break-all;max-height:260px;overflow:auto;font-family:Consolas,Monaco,monospace}
|
||||
.resp-wrap{display:flex;gap:0}.resp-main{flex:1;min-width:0}
|
||||
.resp-main .rsp-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
.resp-main .rsp-hd .cp-sm{margin-left:auto}
|
||||
.exp-col-btn{flex-shrink:0;width:36px;display:flex;align-items:stretch;justify-content:center;margin:0 2px}
|
||||
.exp-col-btn button{writing-mode:vertical-rl;background:var(--accent);border:none;color:#fff;font-size:10px;padding:6px 3px;border-radius:var(--r) 0 0 var(--r);cursor:pointer;user-select:none;line-height:1.2;letter-spacing:1px;opacity:.85}
|
||||
.exp-col-btn button:hover{opacity:1}
|
||||
.exp-panel{flex:1;min-width:0;display:none;padding-left:6px}
|
||||
.resp-wrap.exp-on .exp-panel{display:block}
|
||||
.exp-panel .ep-hd{display:flex;align-items:center;margin-bottom:3px;font-size:10px;color:var(--fg2);letter-spacing:.5px}
|
||||
.exp-panel .ep-hd .cp-sm{margin-left:auto}
|
||||
.exp-panel pre.j{border-left:2px solid var(--accent)}
|
||||
pre.j.resp-j{max-height:calc(100vh - 280px);min-height:120px}
|
||||
.verify-err{margin-top:8px;padding:8px 10px;background:rgba(255,77,79,0.08);border:1px solid rgba(255,77,79,0.3);border-radius:var(--r);font-size:12px;color:#ff6b6b;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
.verify-label{display:inline-block;background:var(--red);color:#fff;font-size:10px;padding:1px 6px;border-radius:var(--r);margin-right:8px;vertical-align:middle}
|
||||
.sec{margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}.sec-title{color:var(--fg2);font-size:11px;letter-spacing:.5px;margin-bottom:4px}
|
||||
.row-hd{display:flex;align-items:center;margin-bottom:4px;padding:4px 8px;background:var(--bg2);border-radius:var(--r)}.row-hd .sec-title{flex:1;margin:0}
|
||||
.kv{display:flex;gap:4px;margin-bottom:3px;align-items:center}.kv input{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;outline:none}
|
||||
.kv input[type="file"]{padding:3px 4px}
|
||||
.kv input:focus{border-color:var(--accent)}.kv button{padding:2px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--red);border-radius:var(--r);cursor:pointer}
|
||||
.addbtn{padding:2px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);color:var(--fg);cursor:pointer;font-size:10px;white-space:nowrap}
|
||||
.addbtn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.sbtn{padding:6px 18px;background:var(--accent);color:var(--bg);border:none;border-radius:var(--r);cursor:pointer;font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.sbtn:hover{opacity:.9}.sbtn:disabled{opacity:.4;cursor:not-allowed}
|
||||
.curl{background:#0a0a1a;padding:6px 8px;border-radius:var(--r);font-size:11px;color:#888;white-space:pre-wrap;word-break:break-all;font-family:Consolas,Monaco,monospace}
|
||||
.cp-sm{padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);cursor:pointer;font-size:10px}
|
||||
.cp-sm:hover{color:var(--accent);border-color:var(--accent)}
|
||||
.pc{border:1px solid var(--border);border-radius:var(--r);margin-bottom:6px;overflow:hidden}
|
||||
.pc-hd{padding:3px 8px;background:var(--bg2);color:var(--fg2);font-size:10px;letter-spacing:.5px;border-bottom:1px solid var(--border)}.pc-bd{padding:6px 8px}
|
||||
.kv-ro{display:flex;gap:4px;margin-bottom:3px;align-items:center}
|
||||
.kv-ro .kk{min-width:80px;max-width:140px;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg3);color:var(--accent);font-size:12px;word-break:break-all}
|
||||
.kv-ro .vv{flex:1;padding:4px 6px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;word-break:break-all}
|
||||
.bd-acc{border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
|
||||
.bd-hd{padding:6px 8px;cursor:pointer;display:flex;align-items:center;gap:5px;color:var(--fg2);font-size:11px;background:var(--bg2);user-select:none}
|
||||
.bd-hd+.bd-hd,.bd-bd+.bd-hd{border-top:1px solid var(--border)}
|
||||
.bd-hd.on{color:var(--accent)}.bd-hd .ar{font-size:8px;transition:transform .15s;color:#555;margin-right:2px}.bd-hd.on .ar{transform:rotate(90deg);color:var(--accent)}
|
||||
.bd-hd .addbtn{margin-left:auto}
|
||||
.bd-bd{display:none;padding:8px;border-top:1px solid var(--border)}.bd-bd.on{display:block}
|
||||
.bd-bd textarea{width:100%;min-height:36px;max-height:40vh;padding:5px 8px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2);color:var(--fg);font-size:12px;font-family:Consolas,Monaco,monospace;resize:vertical;outline:none;overflow-y:auto}
|
||||
.bd-bd textarea:focus{border-color:var(--accent)}
|
||||
.req-star{color:var(--red);font-size:10px;font-weight:700;margin-right:2px;flex-shrink:0;line-height:1.2;align-self:center}
|
||||
.jp-panel{margin-bottom:6px;border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
|
||||
.jp-hd{padding:3px 8px;background:var(--bg2);cursor:pointer;display:flex;align-items:center;font-size:11px;color:var(--fg2);user-select:none}
|
||||
.jp-bd{padding:6px 8px;display:flex;flex-wrap:wrap;gap:4px;border-top:1px solid var(--border)}
|
||||
.jp-tag{font-size:11px;padding:1px 6px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--r);color:var(--fg2);display:inline-flex;align-items:center;gap:2px}
|
||||
.jp-req{color:var(--accent);border-color:rgba(79,195,247,.3)}
|
||||
.jp-type{color:#555;font-size:10px}
|
||||
</style></head><body>
|
||||
|
||||
<div id="side">
|
||||
<div class="shd">
|
||||
<h3 id="dt"></h3>
|
||||
<input id="q" placeholder="搜索接口..." oninput="render()">
|
||||
<div class="sft">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<div id="bar">
|
||||
<label>认证</label>
|
||||
<select id="at" onchange="syncAuth()"><option value="none">无认证</option><option value="header">Header (Authorization)</option><option value="query">URL (?token=)</option><option value="cookie">Cookie</option></select>
|
||||
<input id="tk" placeholder="Token(登录后返回的 32 位字符串)" oninput="syncAuth()">
|
||||
</div>
|
||||
<div id="ct"><div style="text-align:center;color:var(--fg2);padding:80px 20px">← 选择一个接口开始</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let D=null,F='all',C=null;
|
||||
|
||||
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)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);
|
||||
}
|
||||
let h='';
|
||||
for(const p of Object.keys(T).sort()){let ph='';
|
||||
for(const c of Object.keys(T[p]).sort()){const es=T[p][c];let eh='';
|
||||
for(const e of es){const m=e.method.toLowerCase();
|
||||
let tg='';if(!e.tested)tg='<span class="tg tg-no">未测试</span>';
|
||||
else if(e.cases?.length){const pc=e.cases.filter(x=>x.passed).length;tg=pc===e.cases.length?'<span class="tg tg-ok">'+pc+'/'+e.cases.length+'</span>':'<span class="tg tg-err">'+pc+'/'+e.cases.length+'</span>';}
|
||||
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">▶</span>'+c+'<span class="cn">'+es.length+'</span></div><div class="sub">'+eh+'</div>';
|
||||
}
|
||||
const totalEps=Object.values(T[p]).reduce((s,es)=>s+es.length,0);
|
||||
h+='<div class="l1"><div class="l1h" onclick="tog(this)"><span class="ar o">▶</span>'+p+'<span class="cn">'+totalEps+'</span></div><div class="sub o">'+ph+'</div></div>';
|
||||
}
|
||||
document.getElementById('tree').innerHTML=h||'<div style="padding:30px;color:#555;text-align:center">无匹配</div>';
|
||||
}
|
||||
function sf(f,b){F=f;document.querySelectorAll('.sft button').forEach(x=>x.classList.remove('on'));b.classList.add('on');render();}
|
||||
function tog(e){e.querySelector('.ar').classList.toggle('o');e.nextElementSibling.classList.toggle('o');}
|
||||
function go(el){C=D.endpoints.find(e=>e.path===el.dataset.p);if(!C)return;document.querySelectorAll('.ep').forEach(e=>e.classList.remove('act'));el.classList.add('act');show();}
|
||||
|
||||
function show(){
|
||||
const e=C;if(!e)return;const m=e.method.toLowerCase();const cnt=e.cases?.length||0;
|
||||
let h='<div class="ct-hd"><div class="ehd">';
|
||||
h+='<span class="mt mt-'+m+'" style="font-size:13px;padding:3px 8px">'+e.method+'</span>';
|
||||
h+='<span class="pa">'+esc(e.path)+'</span><span class="ds">'+esc(e.summary)+'</span>';
|
||||
if(cnt)h+='<span class="case-cnt">'+cnt+' 用例</span>';
|
||||
if(!e.tested)h+='<span class="nt">未测试</span>';
|
||||
h+='</div></div>';
|
||||
h+='<div class="split">';
|
||||
h+='<div class="split-l">'+buildLeft(e)+'</div>';
|
||||
h+='<div class="split-r">';
|
||||
h+='<div class="rtabs"><div class="rtab on" id="rt0" onclick="rswitch(0)">用例结果</div><div class="rtab" id="rt1" onclick="rswitch(1)">发送结果</div></div>';
|
||||
h+='<div class="rpn on" id="rp0"><div id="case-panel" style="padding:4px 0"></div></div>';
|
||||
h+='<div class="rpn" id="rp1">'+respPanel()+'</div>';
|
||||
h+='</div></div>';
|
||||
document.getElementById('ct').innerHTML=h;
|
||||
buildPfList(e);
|
||||
let defIdx=-1;
|
||||
if(cnt>0){for(let i=e.cases.length-1;i>=0;i--){if(e.cases[i].passed){defIdx=i;break;}}if(defIdx<0)defIdx=e.cases.length-1;}
|
||||
pfSelect(defIdx>=0?defIdx:'');
|
||||
}
|
||||
function rswitch(i){
|
||||
document.querySelectorAll('.rtab').forEach((t,j)=>t.classList.toggle('on',j===i));
|
||||
document.querySelectorAll('.rpn').forEach((p,j)=>p.classList.toggle('on',j===i));
|
||||
}
|
||||
|
||||
function caseCurl(e,c){
|
||||
const base=window.location.origin;
|
||||
let url=base+e.path;
|
||||
const m=c.method||'POST';
|
||||
if(c.query){const qs=Object.keys(c.query).filter(k=>c.query[k]!==undefined&&c.query[k]!=='').map(k=>encodeURIComponent(k)+'='+encodeURIComponent(c.query[k])).join('&');if(qs)url+='?'+qs;}
|
||||
let cmd="curl -X "+m+" '"+url+"'";
|
||||
if(c.json){cmd+="\\\n -H 'Content-Type: application/json'";cmd+="\\\n -d '"+JSON.stringify(c.json).replace(/'/g,"'\\''")+"'";}
|
||||
else if(c.form){const fd=Object.keys(c.form).map(k=>"-F '"+k+"="+c.form[k]+"'").join("\\\n ");if(fd)cmd+="\\\n "+fd;}
|
||||
return cmd;
|
||||
}
|
||||
function cases(e){
|
||||
if(!e.cases?.length)return '<div style="color:#555;padding:20px">暂无测试用例</div>';
|
||||
let h='';
|
||||
for(let i=0;i<e.cases.length;i++){const c=e.cases[i];
|
||||
h+='<div class="cs"><div class="csh" onclick="accordion(this)"><span class="ar'+(i===0?' o':'')+'">▶</span><span class="dot '+(c.passed?'ok':'er')+'"></span>'+esc(c.name)+'<span class="ml">'+c.method+'</span></div>';
|
||||
h+='<div class="csb'+(i===0?' o':'')+'"><div style="padding:8px 10px">';
|
||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
|
||||
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||
const curlId='cc'+i;const respId='cr'+i;
|
||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+curlId+'\')">复制</button></div>';
|
||||
h+='<div class="curl" id="'+curlId+'">'+esc(caseCurl(e,c))+'</div>';
|
||||
const hasDetail=c.expect&&c.expect.result!==undefined;const expId='ce'+i;
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span></div>';
|
||||
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+respId+'\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';h+='</div>';
|
||||
h+='<div class="exp-col-btn"><button class="exp-btn" style=" width: 100%; box-sizing: border-box; " onclick="event.stopPropagation();toggleExpect()">'+(vis?'▶ 收起 预期响应':'◀ 展开 预期响应')+'</button></div>';
|
||||
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="event.stopPropagation();copyEl(\''+expId+'\')">复制</button></div><pre class="j resp-j" id="'+expId+'">'+fj(c.expect)+'</pre></div>';
|
||||
h+='</div>';}
|
||||
else{if(c.response)h+='<pre class="j" id="'+respId+'">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="'+respId+'">无响应</span>';}
|
||||
h+='</div></div></div>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function accordion(el){
|
||||
const bd=el.nextElementSibling;const wasOpen=bd.classList.contains('o');
|
||||
const panel=el.closest('.rpn');
|
||||
panel.querySelectorAll('.csb').forEach(b=>b.classList.remove('o'));
|
||||
panel.querySelectorAll('.csh .ar').forEach(a=>a.classList.remove('o'));
|
||||
if(!wasOpen){bd.classList.add('o');el.querySelector('.ar').classList.add('o');}
|
||||
}
|
||||
|
||||
function respPanel(){
|
||||
let r='';
|
||||
r+='<div id="note-box" style="display:none;color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5"></div>';
|
||||
r+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><button class="cp-sm" onclick="copyEl(\'curl-box\')">复制</button></div>';
|
||||
r+='<div class="curl" id="curl-box">发送请求后生成</div>';
|
||||
r+='<div class="row-hd" style="margin:10px 0 4px"><span class="sec-title">响应 <span id="resp-st"></span></span><button class="cp-sm" onclick="copyEl(\'resp-body\')">复制</button></div>';
|
||||
r+='<pre class="j" id="resp-body" style="min-height:60px;max-height:calc(100vh - 200px)">发送请求后显示</pre>';
|
||||
return r;
|
||||
}
|
||||
|
||||
function buildLeft(e){
|
||||
const hasForm=e.cases?.some(c=>c.form)||e.params?.some(p=>p.in==='form');
|
||||
const defBody=hasForm?'form':'json';
|
||||
const tk=document.getElementById('tk').value||'';
|
||||
const at=document.getElementById('at').value;
|
||||
let l='';
|
||||
l+='<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px">';
|
||||
l+='<span style="color:var(--fg2);font-size:11px;white-space:nowrap">测试用例选择</span>';
|
||||
l+='<div class="pf-wrap"><button class="pf-btn" id="pf-btn" onclick="togglePfDd(event)"><span class="dot" id="pf-dot" style="display:none;flex-shrink:0"></span><span id="pf-label" style="flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--fg2)">选择用例...</span><span style="color:var(--fg2);font-size:10px;flex-shrink:0">▾</span></button><ul class="pf-dd" id="pf-dd"></ul></div>';
|
||||
l+='<button class="sbtn" onclick="send()" id="sbtn">发送</button>';
|
||||
l+='</div>';
|
||||
let defH='<div class="kv"><input value="Content-Type"><input id="ct-val" value="'+(defBody==='json'?'application/json':'application/x-www-form-urlencoded')+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='header')defH+='<div class="kv" data-auth="1"><input value="Authorization"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
if(tk&&at==='cookie')defH+='<div class="kv" data-auth="1"><input value="Cookie"><input value="HOTIME='+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
l+='<div class="sec" style="margin-top:0"><div class="row-hd"><span class="sec-title">请求头</span><button class="addbtn" onclick="addKV(\'h-rows\')">+ 添加</button></div><div id="h-rows">'+defH+'</div></div>';
|
||||
let qDef='';
|
||||
if(tk&&at==='query')qDef='<div class="kv" data-auth="1"><input value="token"><input value="'+esc(tk)+'"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
qDef+='<div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</button></div>';
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">Query 参数</span><button class="addbtn" onclick="addKV(\'q-rows\')">+ 添加</button></div><div id="q-rows">'+qDef+'</div></div>';
|
||||
l+='<div class="sec"><div class="row-hd"><span class="sec-title">文件上传</span><button class="addbtn" onclick="addFileRow()">+ 添加</button></div><div id="file-rows"><div class="kv"><input placeholder="字段名" value="" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">×</button></div></div></div>';
|
||||
let jsonPH='';
|
||||
if(e.params){
|
||||
const jp=e.params.filter(p=>p.in==='json');
|
||||
if(jp.length){const obj={};for(const p of jp)obj[(p.required?'*':'')+p.name]=p.example!==undefined?p.example:'';jsonPH=JSON.stringify(obj,null,2);}
|
||||
}
|
||||
l+='<div class="sec"><div class="sec-title" style="margin-bottom:4px">请求体</div><div class="bd-acc">';
|
||||
l+='<div class="bd-hd'+(defBody==='form'?' on':'')+'" data-t="form" onclick="togBody(\'form\')"><span class="ar'+(defBody==='form'?' o':'')+'">▶</span>表单 (Form)<button class="addbtn" onclick="event.stopPropagation();setAcc(\'form\');addKV(\'f-rows\')">+ 添加</button></div>';
|
||||
l+='<div id="bt-form" class="bd-bd'+(defBody==='form'?' on':'')+'"><div id="f-rows"><div class="kv"><input placeholder="key"><input placeholder="value"><button onclick="this.parentElement.remove()">×</button></div></div></div>';
|
||||
l+='<div class="bd-hd'+(defBody==='json'?' on':'')+'" data-t="json" onclick="togBody(\'json\')"><span class="ar'+(defBody==='json'?' o':'')+'">▶</span>JSON</div>';
|
||||
l+='<div id="bt-json" class="bd-bd'+(defBody==='json'?' on':'')+'">';
|
||||
if(e.params){
|
||||
const jp=e.params.filter(p=>p.in==='json');
|
||||
if(jp.length){
|
||||
const jpVis=localStorage.getItem('json_params_vis')!=='0';
|
||||
l+='<div class="jp-panel"><div class="jp-hd" onclick="togJP()"><span class="ar jp-ar'+(jpVis?' o':'')+'">▶</span><span>JSON 参数</span><span style="margin-left:auto;font-size:10px" class="jp-toggle">'+(jpVis?'收起':'展开')+'</span></div>';
|
||||
l+='<div class="jp-bd"'+(jpVis?'':' style="display:none"')+'>';
|
||||
for(const p of jp){
|
||||
const star=p.required?'<span class="req-star">*</span>':'';
|
||||
const cls=p.required?'jp-tag jp-req':'jp-tag';
|
||||
l+=star+'<span class="'+cls+'">'+esc(p.name);
|
||||
if(p.type)l+=' <span class="jp-type">'+p.type+'</span>';
|
||||
l+='</span>';
|
||||
}
|
||||
l+='</div></div>';
|
||||
}
|
||||
}
|
||||
l+='<textarea id="xb" oninput="autoH(this)" placeholder="'+(jsonPH?esc(jsonPH):'{"key":"value"}')+'"></textarea></div>';
|
||||
l+='</div></div>';
|
||||
return l;
|
||||
}
|
||||
|
||||
function addKV(id,k,v){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
row.innerHTML='<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">×</button>';
|
||||
document.getElementById(id).appendChild(row);
|
||||
}
|
||||
function addKVReq(id,k,v,req){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
const star=req?'<span class="req-star">*</span>':'';
|
||||
row.innerHTML=star+'<input placeholder="key" value="'+esc(k||'')+'"><input placeholder="value" value="'+esc(v||'')+'"><button onclick="this.parentElement.remove()">×</button>';
|
||||
document.getElementById(id).appendChild(row);
|
||||
}
|
||||
function addFileRow(field){
|
||||
const row=document.createElement('div');row.className='kv';
|
||||
row.innerHTML='<input placeholder="字段名" value="'+esc(field||'')+'" style="max-width:120px"><input type="file"><button onclick="this.parentElement.remove()">×</button>';
|
||||
document.getElementById('file-rows').appendChild(row);
|
||||
}
|
||||
function setAcc(t){
|
||||
document.querySelectorAll('.bd-hd').forEach(h=>{h.classList.toggle('on',h.dataset.t===t);const a=h.querySelector('.ar');if(a)a.classList.toggle('o',h.dataset.t===t);});
|
||||
document.getElementById('bt-json').classList.toggle('on',t==='json');
|
||||
document.getElementById('bt-form').classList.toggle('on',t==='form');
|
||||
const ctVal=document.getElementById('ct-val');
|
||||
if(ctVal)ctVal.value=t==='json'?'application/json':'application/x-www-form-urlencoded';
|
||||
}
|
||||
function togBody(t){
|
||||
const cur=document.getElementById(t==='json'?'bt-json':'bt-form').classList.contains('on');
|
||||
setAcc(cur?(t==='json'?'form':'json'):t);
|
||||
}
|
||||
function autoH(el){el.style.height='36px';el.style.height=Math.min(el.scrollHeight,window.innerHeight*0.4)+'px';}
|
||||
function syncAuth(){
|
||||
const tk=document.getElementById('tk').value;const at=document.getElementById('at').value;
|
||||
const hr=document.getElementById('h-rows');const qr=document.getElementById('q-rows');
|
||||
if(!hr||!qr)return;
|
||||
hr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
||||
qr.querySelectorAll('[data-auth]').forEach(e=>e.remove());
|
||||
if(!tk)return;
|
||||
function mkAuth(k,v,parent){const d=document.createElement('div');d.className='kv';d.dataset.auth='1';d.innerHTML='<input value="'+esc(k)+'"><input value="'+esc(v)+'"><button onclick="this.parentElement.remove()">×</button>';parent.insertBefore(d,parent.querySelector('.kv:last-child'));}
|
||||
if(at==='header')mkAuth('Authorization',tk,hr);
|
||||
if(at==='cookie')mkAuth('Cookie','HOTIME='+tk,hr);
|
||||
if(at==='query')mkAuth('token',tk,qr);
|
||||
}
|
||||
|
||||
function buildPfList(e){
|
||||
const dd=document.getElementById('pf-dd');if(!dd)return;
|
||||
dd.innerHTML='';
|
||||
if(e.cases){
|
||||
for(let i=e.cases.length-1;i>=0;i--){
|
||||
const c=e.cases[i];
|
||||
const li=document.createElement('li');li.className='pf-opt';li.dataset.idx=String(i);
|
||||
li.innerHTML='<span class="dot '+(c.passed?'ok':'er')+'"></span><span>'+esc(c.name)+'</span>';
|
||||
li.onclick=ev=>{ev.stopPropagation();pfSelect(i);};
|
||||
dd.appendChild(li);
|
||||
}
|
||||
}
|
||||
const manual=document.createElement('li');manual.className='pf-opt';manual.dataset.idx='';
|
||||
manual.innerHTML='<span style="color:var(--fg2);font-style:italic">-- 手动填写 --</span>';
|
||||
manual.onclick=ev=>{ev.stopPropagation();pfSelect('');};
|
||||
dd.appendChild(manual);
|
||||
}
|
||||
function togglePfDd(ev){ev.stopPropagation();const dd=document.getElementById('pf-dd');if(dd)dd.classList.toggle('on');}
|
||||
document.addEventListener('click',()=>{document.getElementById('pf-dd')?.classList.remove('on');});
|
||||
function pfSelect(i){
|
||||
const e=C;if(!e)return;
|
||||
const idxStr=String(i);
|
||||
document.querySelectorAll('.pf-opt').forEach(el=>el.classList.toggle('sel',el.dataset.idx===idxStr));
|
||||
const c=(i!==''&&e.cases?.[i])?e.cases[i]:null;
|
||||
const dot=document.getElementById('pf-dot');
|
||||
const lbl=document.getElementById('pf-label');
|
||||
if(dot&&lbl){
|
||||
if(c){dot.style.display='';dot.className='dot '+(c.passed?'ok':'er');lbl.style.color='var(--fg)';lbl.textContent=c.name;}
|
||||
else{dot.style.display='none';lbl.style.color='var(--fg2)';lbl.textContent='-- 手动填写 --';}
|
||||
}
|
||||
document.getElementById('pf-dd')?.classList.remove('on');
|
||||
updateCasePanel(e,i);
|
||||
pfFill(i);
|
||||
}
|
||||
function updateCasePanel(e,i){
|
||||
const panel=document.getElementById('case-panel');if(!panel)return;
|
||||
if(i===''||!e?.cases?.[i]){panel.innerHTML='<div style="color:#555;padding:16px 10px">选择一个测试用例查看响应</div>';return;}
|
||||
const c=e.cases[i];
|
||||
let h='<div style="padding:8px 10px">';
|
||||
if(c.note)h+='<div style="color:#aaa;font-size:12px;margin-bottom:8px;line-height:1.5">备注: '+esc(c.note)+'</div>';
|
||||
if(!c.passed&&c.failReason)h+='<div class="verify-err"><span class="verify-label">失败原因</span>'+esc(c.failReason)+'</div>';
|
||||
else if(c.verifyError)h+='<div class="verify-err"><span class="verify-label">Verify 校验失败</span>'+esc(c.verifyError)+'</div>';
|
||||
h+='<div class="row-hd" style="margin-bottom:4px"><span class="sec-title">cURL</span><span class="'+(c.passed?'tg tg-ok':'tg tg-err')+'" style="margin-left:6px">'+(c.passed?'通过':'失败')+'</span><button class="cp-sm" onclick="copyEl(\'c-curl\')" style="margin-left:auto">复制</button></div>';
|
||||
h+='<div class="curl" id="c-curl">'+esc(caseCurl(e,c))+'</div>';
|
||||
const hasDetail=c.expect&&c.expect.result!==undefined;
|
||||
h+='<div class="row-hd" style="margin:8px 0 4px"><span class="sec-title">响应</span></div>';
|
||||
if(hasDetail){const vis=getExpVis();h+='<div class="resp-wrap'+(vis?' exp-on':'')+'">';
|
||||
h+='<div class="resp-main"><div class="rsp-hd"><span>实际响应</span><button class="cp-sm" onclick="copyEl(\'c-resp\')">复制</button></div>';if(c.response)h+='<pre class="j resp-j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';h+='</div>';
|
||||
h+='<div class="exp-col-btn"><button class="exp-btn" style=" width: 100%; box-sizing: border-box; " onclick="toggleExpect()">'+(vis?'▶ 收起 预期响应':'◀ 展开 预期响应')+'</button></div>';
|
||||
h+='<div class="exp-panel"><div class="ep-hd"><span>预期响应</span><button class="cp-sm" onclick="copyEl(\'c-exp\')">复制</button></div><pre class="j resp-j" id="c-exp">'+fj(c.expect)+'</pre></div>';
|
||||
h+='</div>';}
|
||||
else{if(c.response)h+='<pre class="j" id="c-resp">'+fj(c.response)+'</pre>';else h+='<span style="color:#555" id="c-resp">无响应</span>';}
|
||||
h+='</div>';
|
||||
panel.innerHTML=h;
|
||||
}
|
||||
function pfFill(v){
|
||||
const qr=document.getElementById('q-rows');
|
||||
const fr=document.getElementById('f-rows');
|
||||
if(qr)qr.innerHTML='';
|
||||
if(v===''){
|
||||
if(C?.params){
|
||||
for(const p of C.params.filter(p=>p.in==='query'))addKVReq('q-rows',p.name,p.example!==undefined?String(p.example):'',p.required);
|
||||
}
|
||||
addKV('q-rows');
|
||||
const el=document.getElementById('xb');if(el)el.value='';
|
||||
if(fr){
|
||||
fr.innerHTML='';
|
||||
if(C?.params){for(const p of C.params.filter(p=>p.in==='form'))addKVReq('f-rows',p.name,p.example!==undefined?String(p.example):'',p.required);}
|
||||
addKV('f-rows');
|
||||
}
|
||||
document.getElementById('file-rows').innerHTML='';addFileRow();
|
||||
const nb0=document.getElementById('note-box');if(nb0){nb0.style.display='none';nb0.textContent='';}
|
||||
syncAuth();return;
|
||||
}
|
||||
const i=parseInt(v);
|
||||
if(isNaN(i)||i<0||!C?.cases?.[i])return;
|
||||
const c=C.cases[i];
|
||||
const nb=document.getElementById('note-box');
|
||||
if(nb){if(c.note){nb.textContent='备注: '+c.note;nb.style.display='block';}else{nb.textContent='';nb.style.display='none';}}
|
||||
if(c.query){
|
||||
for(const[k,val]of Object.entries(c.query)){
|
||||
const req=C?.params?.find(p=>p.name===k)?.required||false;
|
||||
addKVReq('q-rows',k,String(val),req);
|
||||
}
|
||||
}
|
||||
addKV('q-rows');
|
||||
if(c.json){
|
||||
setAcc('json');const el=document.getElementById('xb');
|
||||
if(el){
|
||||
const rq=new Set((C?.params||[]).filter(p=>p.required).map(p=>p.name));
|
||||
let obj=c.json;
|
||||
if(rq.size&&typeof obj==='object'&&obj!==null&&!Array.isArray(obj)){
|
||||
const sorted={};
|
||||
for(const k of Object.keys(obj).sort((a,b)=>(rq.has(b)?1:0)-(rq.has(a)?1:0)))sorted[k]=obj[k];
|
||||
obj=sorted;
|
||||
}
|
||||
el.value=JSON.stringify(obj,null,2);autoH(el);
|
||||
}
|
||||
}else if(c.form){
|
||||
setAcc('form');
|
||||
if(fr){
|
||||
fr.innerHTML='';
|
||||
for(const[k,val]of Object.entries(c.form)){
|
||||
const req=C?.params?.find(p=>p.name===k)?.required||false;
|
||||
addKVReq('f-rows',k,String(val),req);
|
||||
}
|
||||
addKV('f-rows');
|
||||
}
|
||||
}else{
|
||||
const el=document.getElementById('xb');if(el)el.value='';
|
||||
if(fr){fr.innerHTML='';addKV('f-rows');}
|
||||
}
|
||||
document.getElementById('file-rows').innerHTML='';
|
||||
if(c.hasFile&&c.fileField)addFileRow(c.fileField);
|
||||
addFileRow();syncAuth();
|
||||
}
|
||||
|
||||
function getKV(id){
|
||||
const obj={};
|
||||
document.querySelectorAll('#'+id+' .kv').forEach(r=>{const ins=r.querySelectorAll('input');const k=ins[0].value.trim(),v=ins[1]?.value;if(k)obj[k]=v||'';});
|
||||
return Object.keys(obj).length?obj:null;
|
||||
}
|
||||
|
||||
async function send(){
|
||||
if(!C)return;const btn=document.getElementById('sbtn');btn.disabled=true;btn.textContent='请求中...';
|
||||
let url=C.path;
|
||||
const qp=getKV('q-rows');
|
||||
if(qp){const ps=new URLSearchParams();for(const[k,v]of Object.entries(qp))ps.set(k,v);url+='?'+ps.toString();}
|
||||
const token=document.getElementById('tk').value;
|
||||
const authType=document.getElementById('at').value;
|
||||
const opts={method:C.method,headers:{}};
|
||||
if(authType==='cookie'){opts.credentials='same-origin';if(token)document.cookie='HOTIME='+token+';path=/';}
|
||||
else{opts.credentials='omit';}
|
||||
const ch=getKV('h-rows');if(ch)for(const[k,v]of Object.entries(ch)){
|
||||
if(k.toLowerCase()==='authorization'&&token&&authType==='header')continue;
|
||||
if(k.toLowerCase()==='cookie'&&token&&authType==='cookie')continue;
|
||||
opts.headers[k]=v;
|
||||
}
|
||||
if(token&&authType==='header')opts.headers['Authorization']=token;
|
||||
const fileEls=document.querySelectorAll('#file-rows .kv');
|
||||
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='';
|
||||
if(files.length){
|
||||
const fd=new FormData();for(const f of files)fd.append(f.field,f.file);
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))fd.append(k,v);
|
||||
opts.body=fd;delete opts.headers['Content-Type'];
|
||||
for(const f of files)curlParts+=' -F "'+f.field+'=@'+f.file.name+'"';
|
||||
if(!isJson&&formKV)for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}else if(isJson){
|
||||
const b=bodyEl?.value?.trim();
|
||||
if(b&&C.method!=='GET'){opts.body=b;curlParts=" -d '"+b+"'";}
|
||||
}else if(formKV){
|
||||
const fd=new FormData();for(const[k,v]of Object.entries(formKV))fd.append(k,v);opts.body=fd;delete opts.headers['Content-Type'];
|
||||
for(const[k,v]of Object.entries(formKV))curlParts+=' -F "'+k+'='+v+'"';
|
||||
}
|
||||
let curl='curl -X '+C.method;
|
||||
for(const[k,v]of Object.entries(opts.headers)){if(k&&v)curl+=" -H '"+k+": "+v+"'";}
|
||||
if(token&&authType==='cookie')curl+=" --cookie 'HOTIME="+token+"'";
|
||||
curl+=curlParts+' '+location.origin+url;
|
||||
const curlBox=document.getElementById('curl-box');if(curlBox)curlBox.textContent=curl;
|
||||
rswitch(1);
|
||||
const t0=Date.now();
|
||||
try{
|
||||
const r=await fetch(url,opts);const ms=Date.now()-t0;const txt=await r.text();let pretty=txt;
|
||||
try{pretty=JSON.stringify(JSON.parse(txt),null,2);}catch(e){}
|
||||
const st=document.getElementById('resp-st');if(st)st.innerHTML='<span style="color:'+(r.ok?'var(--green)':'var(--red)')+'">HTTP '+r.status+'</span> <span style="color:#555">'+ms+'ms</span>';
|
||||
const rb=document.getElementById('resp-body');if(rb)rb.textContent=pretty;
|
||||
}catch(e){
|
||||
const st=document.getElementById('resp-st');if(st)st.innerHTML='<span style="color:var(--red)">失败</span>';
|
||||
const rb=document.getElementById('resp-body');if(rb)rb.textContent=e.message;
|
||||
}
|
||||
btn.disabled=false;btn.textContent='发送';
|
||||
}
|
||||
|
||||
function copyEl(id){navigator.clipboard.writeText(document.getElementById(id).textContent).then(()=>{const b=event.target;b.textContent='已复制';setTimeout(()=>{b.textContent='复制'},1200);});}
|
||||
function cpNext(btn){let el=btn.closest('h5').nextElementSibling;let t='';while(el&&el.tagName!=='H5'){if(el.tagName==='PRE')t+=(t?'\n':'')+el.textContent;el=el.nextElementSibling;}navigator.clipboard.writeText(t).then(()=>{btn.textContent='已复制';setTimeout(()=>{btn.textContent='复制'},1200);});}
|
||||
function getExpVis(){const v=localStorage.getItem('expect_vis');return v===null||v==='1';}
|
||||
function toggleExpect(){const vis=getExpVis();const nv=!vis;localStorage.setItem('expect_vis',nv?'1':'0');document.querySelectorAll('.resp-wrap').forEach(w=>{w.classList.toggle('exp-on',nv);});document.querySelectorAll('.exp-btn').forEach(b=>{b.textContent=nv?'▶ 收起 预期响应':'◀ 展开 预期响应';});}
|
||||
function togJP(){const bd=document.querySelector('.jp-bd');const ar=document.querySelector('.jp-ar');const tg=document.querySelector('.jp-toggle');if(!bd)return;const vis=bd.style.display!=='none';bd.style.display=vis?'none':'flex';if(ar)ar.classList.toggle('o',!vis);if(tg)tg.textContent=vis?'展开':'收起';localStorage.setItem('json_params_vis',vis?'0':'1');}
|
||||
function fj(o){return esc(JSON.stringify(o,null,2));}
|
||||
function fjp(o,params){
|
||||
if(!params||!params.length||typeof o!=='object'||o===null||Array.isArray(o))return fj(o);
|
||||
const rq=new Set(params.filter(p=>p.required).map(p=>p.name));
|
||||
const keys=Object.keys(o).sort((a,b)=>(rq.has(b)?1:0)-(rq.has(a)?1:0));
|
||||
const lines=keys.map(k=>' "'+(rq.has(k)?'*':'')+k+'": '+JSON.stringify(o[k]));
|
||||
return esc('{\n'+lines.join(',\n')+'\n}');
|
||||
}
|
||||
function kvList(o,params){
|
||||
const rq=new Set((params||[]).filter(p=>p.required).map(p=>p.name));
|
||||
let h='';
|
||||
for(const[k,v]of Object.entries(o)){
|
||||
const star=rq.has(k)?'<span class="req-star">*</span>':'';
|
||||
h+='<div class="kv-ro">'+star+'<span class="kk">'+esc(k)+'</span><span class="vv">'+esc(String(v))+'</span></div>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
</script></body></html>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!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"><a class="card" href="app/index.html"><div class="icon">App</div><div class="title">My API</div><div class="sub">app/</div></a></div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
package log
|
||||
|
||||
import "testing"
|
||||
|
||||
// isInfrastructureFile 的入参是 shortenPath 后的短路径(最后两段)
|
||||
func TestIsInfrastructureFile(t *testing.T) {
|
||||
cases := []struct {
|
||||
file string
|
||||
want bool
|
||||
}{
|
||||
// hotime 框架的 session/context 属中间层,应跳过
|
||||
{"hotime@v1.7.302/session.go", true},
|
||||
{"hotimev1.5/session.go", true},
|
||||
{"hotime@v1.7.302/context.go", true},
|
||||
// 框架基础设施目录
|
||||
{"db/db.go", true},
|
||||
{"cache/cache_db.go", true},
|
||||
{"zerolog/log.go", true},
|
||||
// hotime 根包其余文件不跳过(application.go、code.go 的日志是框架自身行为)
|
||||
{"hotime@v1.7.302/application.go", false},
|
||||
{"hotime@v1.7.302/code.go", false},
|
||||
// 业务侧同名文件不受影响(路径不含 hotime)
|
||||
{"wx/session.go", false},
|
||||
{"app/user.go", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isInfrastructureFile(c.file); got != c.want {
|
||||
t.Errorf("isInfrastructureFile(%q) = %v, want %v", c.file, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
-2
@@ -96,8 +96,8 @@ func TestToClef_PlainText(t *testing.T) {
|
||||
if err := json.Unmarshal(clef, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["@mt"] != "plain log line" {
|
||||
t.Fatalf("@mt=%v", m["@mt"])
|
||||
if m["@m"] != "plain log line" {
|
||||
t.Fatalf("@m=%v", m["@m"])
|
||||
}
|
||||
if m["source"] != "stdout" {
|
||||
t.Fatalf("source=%v", m["source"])
|
||||
@@ -107,6 +107,191 @@ func TestToClef_PlainText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithFields_ChildHasFieldsParentClean(t *testing.T) {
|
||||
parent, buf := newLoggerWithCapture(1)
|
||||
child := parent.WithFields("sid", "abc123def456", "request_id", "fedcba987654")
|
||||
child.Info().Msg("child-msg")
|
||||
m := buf.lastJSON()
|
||||
if m == nil {
|
||||
t.Fatal("no output")
|
||||
}
|
||||
if m["sid"] != "abc123def456" || m["request_id"] != "fedcba987654" {
|
||||
t.Fatalf("child fields missing: %v", m)
|
||||
}
|
||||
parent.Info().Msg("parent-msg")
|
||||
m2 := buf.lastJSON()
|
||||
if m2["sid"] != nil || m2["request_id"] != nil {
|
||||
t.Fatalf("parent polluted: %v", m2)
|
||||
}
|
||||
// 错误历史父子共享
|
||||
parent2 := NewLogger(1, "", 10)
|
||||
child2 := parent2.WithFields("sid", "x")
|
||||
child2.Error("shared-err")
|
||||
if n := len(parent2.GetRecentErrors()); n != 1 {
|
||||
t.Fatalf("shared error store want 1 got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToClef_MillisecondAndAtM(t *testing.T) {
|
||||
sw := &SeqWriter{instance: "test:1"}
|
||||
in := []byte(`{"level":"info","time":"2026-07-23 04:50:01.123","message":"hello {name}"}`)
|
||||
clef := sw.toClef(in)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(clef, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m["@m"] != "hello {name}" {
|
||||
t.Fatalf("@m=%v", m["@m"])
|
||||
}
|
||||
if m["@mt"] != nil {
|
||||
t.Fatalf("should not have @mt: %v", m["@mt"])
|
||||
}
|
||||
at, ok := m["@t"].(string)
|
||||
if !ok || !strings.Contains(at, ".123") {
|
||||
t.Fatalf("@t missing ms: %v", m["@t"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqWriter_RetryOnce(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
var gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
calls++
|
||||
n := calls
|
||||
if n == 1 {
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
gotBody = string(body)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
l := NewLogger(1, "", 0)
|
||||
l.SetSeqWriter(srv.URL, "", "test:retry")
|
||||
l.Info().Msg("retry-me")
|
||||
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
ok := calls >= 2 && strings.Contains(gotBody, "retry-me")
|
||||
mu.Unlock()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Fatalf("retry failed: calls=%d body=%q", calls, gotBody)
|
||||
}
|
||||
|
||||
func TestLevelFilterWriter_WarnOnly(t *testing.T) {
|
||||
buf := &captureBuf{}
|
||||
w := newLevelFilterWriter(buf, zerolog.WarnLevel)
|
||||
mw := &multiWriter{writers: []io.Writer{w}}
|
||||
zl := zerolog.New(mw).Level(zerolog.DebugLevel)
|
||||
zl.Info().Msg("info-hidden")
|
||||
zl.Warn().Msg("warn-shown")
|
||||
buf.mu.Lock()
|
||||
n := len(buf.lines)
|
||||
buf.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 console line got %d: %v", n, buf.lines)
|
||||
}
|
||||
if !strings.Contains(buf.lines[0], "warn-shown") {
|
||||
t.Fatalf("unexpected: %v", buf.lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetSeqWriter_QuietsConsoleKeepsSeqFull 验证挂 Seq 后控制台放宽到 Info+:
|
||||
// Debug 不上控制台,Info/Warn 均可见,Seq 侧全量收到(含 Debug)。
|
||||
func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotBody string
|
||||
srv := startFakeSeqServer(t, func(body string) {
|
||||
mu.Lock()
|
||||
gotBody += body
|
||||
mu.Unlock()
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
l := NewLogger(1, "", 0)
|
||||
consoleBuf := &captureBuf{}
|
||||
l.console.inner = consoleBuf
|
||||
l.SetSeqWriter(srv.URL, "", "test:quiet")
|
||||
l.Debug().Msg("debug-to-seq-only")
|
||||
l.Info().Msg("info-both")
|
||||
l.Warn().Msg("warn-both")
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
ok := strings.Contains(gotBody, "debug-to-seq-only") && strings.Contains(gotBody, "info-both") && strings.Contains(gotBody, "warn-both")
|
||||
mu.Unlock()
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
body := gotBody
|
||||
mu.Unlock()
|
||||
if !strings.Contains(body, "debug-to-seq-only") || !strings.Contains(body, "info-both") || !strings.Contains(body, "warn-both") {
|
||||
t.Fatalf("Seq incomplete: %q", body)
|
||||
}
|
||||
consoleBuf.mu.Lock()
|
||||
defer consoleBuf.mu.Unlock()
|
||||
for _, line := range consoleBuf.lines {
|
||||
if strings.Contains(line, "debug-to-seq-only") {
|
||||
t.Fatalf("Debug leaked to console: %v", consoleBuf.lines)
|
||||
}
|
||||
}
|
||||
foundInfo, foundWarn := false, false
|
||||
for _, line := range consoleBuf.lines {
|
||||
if strings.Contains(line, "info-both") {
|
||||
foundInfo = true
|
||||
}
|
||||
if strings.Contains(line, "warn-both") {
|
||||
foundWarn = true
|
||||
}
|
||||
}
|
||||
if !foundInfo {
|
||||
t.Fatalf("Info missing on console: %v", consoleBuf.lines)
|
||||
}
|
||||
if !foundWarn {
|
||||
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
|
||||
}
|
||||
l.CloseSeq()
|
||||
}
|
||||
|
||||
func TestSeqWriter_CloseFlushes(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var gotBody string
|
||||
srv := startFakeSeqServer(t, func(body string) {
|
||||
mu.Lock()
|
||||
gotBody += body
|
||||
mu.Unlock()
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
l := NewLogger(1, "", 0)
|
||||
l.SetSeqWriter(srv.URL, "", "test:close")
|
||||
l.Info().Msg("flush-on-close")
|
||||
l.CloseSeq()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !strings.Contains(gotBody, "flush-on-close") {
|
||||
t.Fatalf("Close did not flush: %q", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdoutCapture_LongLine(t *testing.T) {
|
||||
l, buf := newLoggerWithCapture(1)
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// TestSetSeqWriter_ConsoleMinLevelBecomesInfo 验证挂 Seq 后控制台门槛降到 Info(而非 Warn)
|
||||
func TestSetSeqWriter_ConsoleMinLevelBecomesInfo(t *testing.T) {
|
||||
l := NewLogger(1, "", 0)
|
||||
if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.DebugLevel {
|
||||
t.Fatalf("初始 console minLevel=%v want Debug", got)
|
||||
}
|
||||
// seqUrl 不必可达:SeqWriter 异步 channel 推送,Write 不阻塞
|
||||
l.SetSeqWriter("http://127.0.0.1:1", "", "test:level")
|
||||
defer l.CloseSeq()
|
||||
if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.InfoLevel {
|
||||
t.Fatalf("挂 Seq 后 console minLevel=%v want Info", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetConsoleMinLevel_OverridesToWarn 验证导出方法可将 console 门槛单独收紧到 Warn,
|
||||
// 用于访问日志等高噪声 Logger 在挂 Seq 后仍保持 Warn+。
|
||||
func TestSetConsoleMinLevel_OverridesToWarn(t *testing.T) {
|
||||
l := NewLogger(1, "", 0)
|
||||
l.SetSeqWriter("http://127.0.0.1:1", "", "test:override")
|
||||
defer l.CloseSeq()
|
||||
|
||||
l.SetConsoleMinLevel(zerolog.WarnLevel)
|
||||
if got := zerolog.Level(l.console.minLevel.Load()); got != zerolog.WarnLevel {
|
||||
t.Fatalf("SetConsoleMinLevel 后 minLevel=%v want Warn", got)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
l.console.inner = &buf
|
||||
if _, err := l.console.WriteLevel(zerolog.InfoLevel, []byte("info-line\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("Info 应被 Warn 门槛丢弃,实际写入: %q", buf.String())
|
||||
}
|
||||
if _, err := l.console.WriteLevel(zerolog.WarnLevel, []byte("warn-line\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("warn-line")) {
|
||||
t.Fatalf("Warn 应放行,实际: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLevelFilterWriter_WriteLevel_DropAndPass 直接验证 levelFilterWriter.WriteLevel
|
||||
// 对低于门槛的级别丢弃(但仍返回成功),达到门槛的放行。
|
||||
func TestLevelFilterWriter_WriteLevel_DropAndPass(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
w := newLevelFilterWriter(&buf, zerolog.WarnLevel)
|
||||
|
||||
n, err := w.WriteLevel(zerolog.DebugLevel, []byte("debug-msg"))
|
||||
if err != nil || n != len("debug-msg") {
|
||||
t.Fatalf("Debug 丢弃返回值异常: n=%d err=%v", n, err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("Debug 不应写入 inner: %q", buf.String())
|
||||
}
|
||||
|
||||
n, err = w.WriteLevel(zerolog.InfoLevel, []byte("info-msg"))
|
||||
if err != nil || n != len("info-msg") {
|
||||
t.Fatalf("Info 丢弃返回值异常: n=%d err=%v", n, err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("Info 不应写入 inner: %q", buf.String())
|
||||
}
|
||||
|
||||
if _, err := w.WriteLevel(zerolog.WarnLevel, []byte("warn-msg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("warn-msg")) {
|
||||
t.Fatalf("Warn 应放行: %q", buf.String())
|
||||
}
|
||||
|
||||
if _, err := w.WriteLevel(zerolog.ErrorLevel, []byte("error-msg")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("error-msg")) {
|
||||
t.Fatalf("Error 应放行: %q", buf.String())
|
||||
}
|
||||
}
|
||||
+208
-62
@@ -47,18 +47,65 @@ type ErrorRecord struct {
|
||||
Caller string
|
||||
}
|
||||
|
||||
// Logger 日志核心结构体,封装 zerolog
|
||||
type Logger struct {
|
||||
zl zerolog.Logger
|
||||
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
|
||||
logLevel int
|
||||
// errorStore 错误历史环形缓冲(指针共享,供 WithFields 派生子 Logger 共用)
|
||||
type errorStore struct {
|
||||
mu sync.RWMutex
|
||||
errors []ErrorRecord
|
||||
errIdx int // 环形缓冲写入位置
|
||||
errCount int // 实际写入总数(用于判断缓冲是否满)
|
||||
errorsMu sync.RWMutex
|
||||
errIdx int
|
||||
errCount int
|
||||
maxErrors int
|
||||
}
|
||||
|
||||
// Logger 日志核心结构体,封装 zerolog
|
||||
type Logger struct {
|
||||
zl zerolog.Logger
|
||||
mw *multiWriter // 动态多输出,支持运行时追加 SeqWriter 等
|
||||
console *levelFilterWriter // 控制台出口,挂 Seq 后可降到 Warn+
|
||||
logLevel int
|
||||
store *errorStore
|
||||
seqs []*SeqWriter // 本 Logger 挂载过的 SeqWriter,供 CloseSeq 冲刷
|
||||
seqsMu sync.Mutex
|
||||
}
|
||||
|
||||
// levelFilterWriter 按级别过滤的 Writer(实现 zerolog.LevelWriter)
|
||||
type levelFilterWriter struct {
|
||||
inner io.Writer
|
||||
minLevel atomic.Int32 // zerolog.Level 存为 int32
|
||||
}
|
||||
|
||||
func newLevelFilterWriter(inner io.Writer, min zerolog.Level) *levelFilterWriter {
|
||||
w := &levelFilterWriter{inner: inner}
|
||||
w.minLevel.Store(int32(min))
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *levelFilterWriter) setMinLevel(min zerolog.Level) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.minLevel.Store(int32(min))
|
||||
}
|
||||
|
||||
func (w *levelFilterWriter) Write(p []byte) (int, error) {
|
||||
// 无级别信息时按 Info 处理:低于门槛则丢弃
|
||||
if zerolog.Level(w.minLevel.Load()) > zerolog.InfoLevel {
|
||||
return len(p), nil
|
||||
}
|
||||
return w.inner.Write(p)
|
||||
}
|
||||
|
||||
func (w *levelFilterWriter) WriteLevel(l zerolog.Level, p []byte) (int, error) {
|
||||
if l < zerolog.Level(w.minLevel.Load()) {
|
||||
return len(p), nil
|
||||
}
|
||||
if lw, ok := w.inner.(zerolog.LevelWriter); ok {
|
||||
return lw.WriteLevel(l, p)
|
||||
}
|
||||
return w.inner.Write(p)
|
||||
}
|
||||
|
||||
const timeFormatMS = "2006-01-02 15:04:05.000"
|
||||
|
||||
// NewLogger 创建日志实例
|
||||
// logLevel: 0=仅 error,>=1=全部
|
||||
// logFile: 文件路径模板(如 "logs/20060102.txt"),空则不写文件
|
||||
@@ -66,7 +113,7 @@ type Logger struct {
|
||||
func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
saveOrigStderr()
|
||||
zerolog.CallerMarshalFunc = callerMarshalFunc
|
||||
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
|
||||
zerolog.TimeFieldFormat = timeFormatMS
|
||||
|
||||
var level zerolog.Level
|
||||
if logLevel == 0 {
|
||||
@@ -77,7 +124,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
TimeFormat: timeFormatMS,
|
||||
NoColor: false,
|
||||
FormatLevel: formatLevelColor,
|
||||
FormatCaller: func(i interface{}) string {
|
||||
@@ -87,9 +134,10 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
return fmt.Sprintf("[%s]", i)
|
||||
},
|
||||
}
|
||||
console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel)
|
||||
|
||||
var writers []io.Writer
|
||||
writers = append(writers, consoleWriter)
|
||||
writers = append(writers, console)
|
||||
|
||||
if logFile != "" {
|
||||
fw := &TemplateFileWriter{pathTemplate: logFile}
|
||||
@@ -105,13 +153,14 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
Logger()
|
||||
|
||||
l := &Logger{
|
||||
zl: zl,
|
||||
mw: mw,
|
||||
logLevel: logLevel,
|
||||
maxErrors: maxErrors,
|
||||
zl: zl,
|
||||
mw: mw,
|
||||
console: console,
|
||||
logLevel: logLevel,
|
||||
store: &errorStore{maxErrors: maxErrors},
|
||||
}
|
||||
if maxErrors > 0 {
|
||||
l.errors = make([]ErrorRecord, maxErrors)
|
||||
l.store.errors = make([]ErrorRecord, maxErrors)
|
||||
}
|
||||
|
||||
return l
|
||||
@@ -121,7 +170,7 @@ func NewLogger(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
saveOrigStderr()
|
||||
zerolog.CallerMarshalFunc = callerMarshalFunc
|
||||
zerolog.TimeFieldFormat = "2006-01-02 15:04:05"
|
||||
zerolog.TimeFieldFormat = timeFormatMS
|
||||
|
||||
var level zerolog.Level
|
||||
if logLevel == 0 {
|
||||
@@ -132,13 +181,14 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
TimeFormat: timeFormatMS,
|
||||
NoColor: false,
|
||||
FormatLevel: formatLevelColor,
|
||||
}
|
||||
console := newLevelFilterWriter(consoleWriter, zerolog.DebugLevel)
|
||||
|
||||
var writers []io.Writer
|
||||
writers = append(writers, consoleWriter)
|
||||
writers = append(writers, console)
|
||||
|
||||
if logFile != "" {
|
||||
fw := &TemplateFileWriter{pathTemplate: logFile}
|
||||
@@ -153,18 +203,40 @@ func NewLoggerNoCaller(logLevel int, logFile string, maxErrors int) *Logger {
|
||||
Logger()
|
||||
|
||||
l := &Logger{
|
||||
zl: zl,
|
||||
mw: mw,
|
||||
logLevel: logLevel,
|
||||
maxErrors: maxErrors,
|
||||
zl: zl,
|
||||
mw: mw,
|
||||
console: console,
|
||||
logLevel: logLevel,
|
||||
store: &errorStore{maxErrors: maxErrors},
|
||||
}
|
||||
if maxErrors > 0 {
|
||||
l.errors = make([]ErrorRecord, maxErrors)
|
||||
l.store.errors = make([]ErrorRecord, maxErrors)
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// WithFields 派生子 Logger,附加成对 key,value 字段;mw/错误历史与父共享,父不受污染。
|
||||
func (l *Logger) WithFields(kvs ...string) *Logger {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := l.zl.With()
|
||||
for i := 0; i+1 < len(kvs); i += 2 {
|
||||
if kvs[i] == "" {
|
||||
continue
|
||||
}
|
||||
ctx = ctx.Str(kvs[i], kvs[i+1])
|
||||
}
|
||||
return &Logger{
|
||||
zl: ctx.Logger(),
|
||||
mw: l.mw,
|
||||
console: l.console,
|
||||
logLevel: l.logLevel,
|
||||
store: l.store,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestLogger 创建用于测试的静默 Logger(输出到 io.Discard)
|
||||
func NewTestLogger() *Logger {
|
||||
zl := zerolog.New(io.Discard).Level(zerolog.Disabled)
|
||||
@@ -180,7 +252,7 @@ func (l *Logger) SetOutput(w io.Writer) {
|
||||
}
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: w,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
TimeFormat: timeFormatMS,
|
||||
NoColor: true,
|
||||
FormatLevel: formatLevelPlain,
|
||||
FormatCaller: func(i interface{}) string {
|
||||
@@ -295,14 +367,14 @@ func (l *Logger) Warn(args ...interface{}) *zerolog.Event {
|
||||
func (l *Logger) Error(args ...interface{}) *zerolog.Event {
|
||||
if len(args) > 0 {
|
||||
msg := fmt.Sprint(args...)
|
||||
if l.maxErrors > 0 {
|
||||
if l.store != nil && l.store.maxErrors > 0 {
|
||||
l.recordError(msg, nil)
|
||||
}
|
||||
l.zl.Error().Msg(msg)
|
||||
nop := zerolog.Nop()
|
||||
return nop.Error()
|
||||
}
|
||||
if l.maxErrors > 0 {
|
||||
if l.store != nil && l.store.maxErrors > 0 {
|
||||
l.recordError("", nil)
|
||||
}
|
||||
return l.zl.Error()
|
||||
@@ -310,19 +382,20 @@ func (l *Logger) Error(args ...interface{}) *zerolog.Event {
|
||||
|
||||
// RecordError 手动记录一条错误到历史(用于需要指定详情的场景)
|
||||
func (l *Logger) RecordError(err error, msg string, caller string) {
|
||||
if l.maxErrors <= 0 {
|
||||
if l == nil || l.store == nil || l.store.maxErrors <= 0 {
|
||||
return
|
||||
}
|
||||
l.errorsMu.Lock()
|
||||
defer l.errorsMu.Unlock()
|
||||
l.errors[l.errIdx] = ErrorRecord{
|
||||
s := l.store
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.errors[s.errIdx] = ErrorRecord{
|
||||
Err: err,
|
||||
Msg: msg,
|
||||
Time: time.Now(),
|
||||
Caller: caller,
|
||||
}
|
||||
l.errIdx = (l.errIdx + 1) % l.maxErrors
|
||||
l.errCount++
|
||||
s.errIdx = (s.errIdx + 1) % s.maxErrors
|
||||
s.errCount++
|
||||
}
|
||||
|
||||
func (l *Logger) recordError(msg string, err error) {
|
||||
@@ -336,15 +409,16 @@ func (l *Logger) recordError(msg string, err error) {
|
||||
|
||||
// GetRecentErrors 获取最近 N 条错误(不传则返回全部已存储的)
|
||||
func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
|
||||
if l.maxErrors <= 0 {
|
||||
if l == nil || l.store == nil || l.store.maxErrors <= 0 {
|
||||
return nil
|
||||
}
|
||||
l.errorsMu.RLock()
|
||||
defer l.errorsMu.RUnlock()
|
||||
s := l.store
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
total := l.errCount
|
||||
if total > l.maxErrors {
|
||||
total = l.maxErrors
|
||||
total := s.errCount
|
||||
if total > s.maxErrors {
|
||||
total = s.maxErrors
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
@@ -356,13 +430,12 @@ func (l *Logger) GetRecentErrors(n ...int) []ErrorRecord {
|
||||
}
|
||||
|
||||
result := make([]ErrorRecord, 0, want)
|
||||
// 从最新的往前读
|
||||
for i := 0; i < want; i++ {
|
||||
idx := (l.errIdx - 1 - i + l.maxErrors) % l.maxErrors
|
||||
if l.errors[idx].Time.IsZero() {
|
||||
idx := (s.errIdx - 1 - i + s.maxErrors) % s.maxErrors
|
||||
if s.errors[idx].Time.IsZero() {
|
||||
break
|
||||
}
|
||||
result = append(result, l.errors[idx])
|
||||
result = append(result, s.errors[idx])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -379,7 +452,7 @@ func (l *Logger) Warnf(format string, v ...interface{}) {
|
||||
l.zl.Warn().Msgf(format, v...)
|
||||
}
|
||||
func (l *Logger) Errorf(format string, v ...interface{}) {
|
||||
if l.maxErrors > 0 {
|
||||
if l.store != nil && l.store.maxErrors > 0 {
|
||||
l.recordError(fmt.Sprintf(format, v...), nil)
|
||||
}
|
||||
l.zl.Error().Msgf(format, v...)
|
||||
@@ -560,6 +633,8 @@ type SeqWriter struct {
|
||||
queue chan []byte
|
||||
dropped int64
|
||||
client *http.Client
|
||||
closed int32
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
||||
@@ -569,6 +644,7 @@ func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
||||
instance: instance,
|
||||
queue: make(chan []byte, 10000),
|
||||
client: &http.Client{Timeout: 5 * time.Second},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go w.run()
|
||||
return w
|
||||
@@ -576,6 +652,9 @@ func newSeqWriter(seqUrl, apiKey, instance string) *SeqWriter {
|
||||
|
||||
// Write 将字节塞入 channel 即返回,由后台 goroutine 消费发送
|
||||
func (w *SeqWriter) Write(p []byte) (int, error) {
|
||||
if atomic.LoadInt32(&w.closed) != 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
clef := w.toClef(p)
|
||||
select {
|
||||
case w.queue <- clef:
|
||||
@@ -588,15 +667,31 @@ func (w *SeqWriter) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Close 停止接收新条目,并在约 2s 内冲刷残留批次(幂等)
|
||||
func (w *SeqWriter) Close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !atomic.CompareAndSwapInt32(&w.closed, 0, 1) {
|
||||
return
|
||||
}
|
||||
close(w.queue)
|
||||
select {
|
||||
case <-w.done:
|
||||
case <-time.After(2 * time.Second):
|
||||
seqWriteStderr("[seq] Close timeout, some events may be dropped\n")
|
||||
}
|
||||
}
|
||||
|
||||
// toClef 将 zerolog JSON 字段映射到 Seq CLEF 格式
|
||||
// zerolog: time/level/message(msg) → CLEF: @t/@l/@mt
|
||||
// zerolog: time/level/message(msg) → CLEF: @t/@l/@m
|
||||
func (w *SeqWriter) toClef(p []byte) []byte {
|
||||
trimmed := bytes.TrimSpace(p)
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(trimmed, &m); err != nil {
|
||||
// 非 JSON(如 fmt.Println 直接输出),包装为纯文本日志
|
||||
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@mt":%q,"source":"stdout","instance":%q}`,
|
||||
time.Now().Format(time.RFC3339), strings.TrimSpace(string(p)), w.instance))
|
||||
return []byte(fmt.Sprintf(`{"@t":%q,"@l":"Information","@m":%q,"source":"stdout","instance":%q}`,
|
||||
time.Now().Format("2006-01-02T15:04:05.000Z07:00"), strings.TrimSpace(string(p)), w.instance))
|
||||
}
|
||||
|
||||
clef := make(map[string]interface{}, len(m)+3)
|
||||
@@ -604,16 +699,18 @@ func (w *SeqWriter) toClef(p []byte) []byte {
|
||||
clef[k] = v
|
||||
}
|
||||
|
||||
// time → @t(zerolog 格式 "2006-01-02 15:04:05" → ISO 8601)
|
||||
// time → @t(优先毫秒格式,回退秒级)
|
||||
if t, ok := m["time"].(string); ok {
|
||||
delete(clef, "time")
|
||||
if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
|
||||
clef["@t"] = pt.Format(time.RFC3339)
|
||||
if pt, err := time.ParseInLocation(timeFormatMS, t, time.Local); err == nil {
|
||||
clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00")
|
||||
} else if pt, err := time.ParseInLocation("2006-01-02 15:04:05", t, time.Local); err == nil {
|
||||
clef["@t"] = pt.Format("2006-01-02T15:04:05.000Z07:00")
|
||||
} else {
|
||||
clef["@t"] = t
|
||||
}
|
||||
} else {
|
||||
clef["@t"] = time.Now().Format(time.RFC3339)
|
||||
clef["@t"] = time.Now().Format("2006-01-02T15:04:05.000Z07:00")
|
||||
}
|
||||
|
||||
// level → @l
|
||||
@@ -635,13 +732,13 @@ func (w *SeqWriter) toClef(p []byte) []byte {
|
||||
}
|
||||
}
|
||||
|
||||
// message/msg → @mt
|
||||
// message/msg → @m(不用 @mt,避免 Seq 把 {xxx} 当消息模板)
|
||||
if msg, ok := m["message"].(string); ok {
|
||||
delete(clef, "message")
|
||||
clef["@mt"] = msg
|
||||
clef["@m"] = msg
|
||||
} else if msg, ok := m["msg"].(string); ok {
|
||||
delete(clef, "msg")
|
||||
clef["@mt"] = msg
|
||||
clef["@m"] = msg
|
||||
}
|
||||
|
||||
if w.instance != "" {
|
||||
@@ -652,14 +749,21 @@ func (w *SeqWriter) toClef(p []byte) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq
|
||||
// run 后台 goroutine:每 100 条或 500ms 批量 POST 到 Seq;queue 关闭后冲刷并退出
|
||||
func (w *SeqWriter) run() {
|
||||
defer close(w.done)
|
||||
batch := make([][]byte, 0, 100)
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case entry := <-w.queue:
|
||||
case entry, ok := <-w.queue:
|
||||
if !ok {
|
||||
if len(batch) > 0 {
|
||||
w.flush(batch)
|
||||
}
|
||||
return
|
||||
}
|
||||
batch = append(batch, entry)
|
||||
if len(batch) >= 100 {
|
||||
w.flush(batch)
|
||||
@@ -675,6 +779,15 @@ func (w *SeqWriter) run() {
|
||||
}
|
||||
|
||||
func (w *SeqWriter) flush(batch [][]byte) {
|
||||
if err := w.postBatch(batch); err != nil {
|
||||
time.Sleep(1 * time.Second)
|
||||
if err2 := w.postBatch(batch); err2 != nil {
|
||||
seqWriteStderr("[seq] send failed after retry: %v (dropped=%d)\n", err2, atomic.LoadInt64(&w.dropped))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *SeqWriter) postBatch(batch [][]byte) error {
|
||||
var buf bytes.Buffer
|
||||
for _, entry := range batch {
|
||||
buf.Write(entry)
|
||||
@@ -682,8 +795,7 @@ func (w *SeqWriter) flush(batch [][]byte) {
|
||||
}
|
||||
req, err := http.NewRequest("POST", w.seqUrl+"/api/events/raw?clef", &buf)
|
||||
if err != nil {
|
||||
seqWriteStderr("[seq] build request error: %v\n", err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/vnd.serilog.clef")
|
||||
if w.apiKey != "" {
|
||||
@@ -691,14 +803,14 @@ func (w *SeqWriter) flush(batch [][]byte) {
|
||||
}
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
seqWriteStderr("[seq] send error: %v (dropped=%d)\n", err, atomic.LoadInt64(&w.dropped))
|
||||
return
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
seqWriteStderr("[seq] server returned %d: %s\n", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSeqWriter 将 Seq HTTP 推送器附加到当前 Logger(不影响已有的控制台/文件输出)
|
||||
@@ -706,11 +818,40 @@ func (w *SeqWriter) flush(batch [][]byte) {
|
||||
// apiKey: Seq API Key,免费单用户版留空
|
||||
// instance: 实例标识,建议用 config 的 port 字段区分同机多进程,如 "8085"
|
||||
func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
|
||||
if seqUrl == "" || l.mw == nil {
|
||||
if l == nil || seqUrl == "" || l.mw == nil {
|
||||
return
|
||||
}
|
||||
sw := newSeqWriter(seqUrl, apiKey, instance)
|
||||
l.mw.add(sw)
|
||||
l.seqsMu.Lock()
|
||||
l.seqs = append(l.seqs, sw)
|
||||
l.seqsMu.Unlock()
|
||||
// 挂上 Seq 后控制台放宽到 Info+(Debug/SQL 不上控制台),仍全量进 Seq 与文件;
|
||||
// 访问日志等高噪声 Logger 可再调用 SetConsoleMinLevel 单独收紧。
|
||||
l.console.setMinLevel(zerolog.InfoLevel)
|
||||
}
|
||||
|
||||
// SetConsoleMinLevel 单独设置本 Logger 控制台出口的最低级别,不影响 Seq/文件等其他出口。
|
||||
// 用于访问日志等高噪声场景在挂 Seq 后仍保持更高的控制台门槛(如 Warn+)。
|
||||
func (l *Logger) SetConsoleMinLevel(level zerolog.Level) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.console.setMinLevel(level)
|
||||
}
|
||||
|
||||
// CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全)
|
||||
func (l *Logger) CloseSeq() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.seqsMu.Lock()
|
||||
seqs := l.seqs
|
||||
l.seqs = nil
|
||||
l.seqsMu.Unlock()
|
||||
for _, sw := range seqs {
|
||||
sw.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// isInfrastructureFile 判断文件是否为基础设施(日志/DB/缓存/通用工具)管线,
|
||||
@@ -749,6 +890,11 @@ func isInfrastructureFile(file string) bool {
|
||||
if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") {
|
||||
return true
|
||||
}
|
||||
// session.go 是 session 存取中间层,永远不是业务发起点;
|
||||
// 跳过后 session 读写触发的 SQL 日志 caller 直接落业务帧(而非 session.go:29 <- 业务帧)
|
||||
if strings.HasSuffix(file, "/session.go") || strings.HasSuffix(file, "\\session.go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/cache"
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
"code.hoteas.com/golang/hotime/log"
|
||||
)
|
||||
|
||||
// TestStaticFile_CacheControlPublic_WithLogLevel1
|
||||
// 静态文件在 logLevel>=1 时仍应 Cache-Control: public(与日志级别解耦)。
|
||||
func TestStaticFile_CacheControlPublic_WithLogLevel1(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := []byte("<!doctype html><title>static-cache</title>")
|
||||
if err := os.WriteFile(filepath.Join(dir, "hello.html"), body, 0o644); err != nil {
|
||||
t.Fatalf("写静态文件失败: %v", err)
|
||||
}
|
||||
|
||||
app := &Application{
|
||||
Config: Map{
|
||||
"logLevel": 1,
|
||||
"tpt": dir,
|
||||
"sessionName": "HOTIME",
|
||||
"defFile": Slice{"index.html"},
|
||||
"crossDomain": "",
|
||||
},
|
||||
Log: log.NewLogger(1, "", 0),
|
||||
MethodRouter: MethodRouter{},
|
||||
HoTimeCache: &HoTimeCache{},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/hello.html", nil)
|
||||
req.RequestURI = "/hello.html"
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,实际 %d loc=%q cc=%q body=%q", w.Code, w.Header().Get("Location"), w.Header().Get("Cache-Control"), w.Body.String())
|
||||
}
|
||||
cc := w.Header().Get("Cache-Control")
|
||||
if !strings.EqualFold(cc, "public") {
|
||||
t.Fatalf("期望 Cache-Control=public,实际 %q", cc)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "static-cache") {
|
||||
t.Fatalf("响应体未包含静态内容: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
+118
-27
@@ -44,30 +44,51 @@ type ApiTestDef struct {
|
||||
|
||||
// Api 测试 API 入口,由 RunTests 自动创建并注入
|
||||
type Api struct {
|
||||
app *TestApp
|
||||
path string
|
||||
t *testing.T
|
||||
session Map
|
||||
lastResp *ApiResponse
|
||||
app *TestApp
|
||||
path string
|
||||
t *testing.T
|
||||
session Map
|
||||
lastResp *ApiResponse
|
||||
flowName string
|
||||
flowGroup string
|
||||
flowDesc string
|
||||
flowPrep string
|
||||
flowStep string
|
||||
flowStepIndex int
|
||||
flowParent *Flow // 业务流程内执行时回写 lastResp
|
||||
}
|
||||
|
||||
// WithSession 返回一个携带 session 的新 Api 实例
|
||||
func (a *Api) WithSession(s Map) *Api {
|
||||
return &Api{
|
||||
app: a.app,
|
||||
path: a.path,
|
||||
t: a.t,
|
||||
session: s,
|
||||
app: a.app,
|
||||
path: a.path,
|
||||
t: a.t,
|
||||
session: s,
|
||||
flowName: a.flowName,
|
||||
flowGroup: a.flowGroup,
|
||||
flowDesc: a.flowDesc,
|
||||
flowPrep: a.flowPrep,
|
||||
flowStep: a.flowStep,
|
||||
flowStepIndex: a.flowStepIndex,
|
||||
flowParent: a.flowParent,
|
||||
}
|
||||
}
|
||||
|
||||
// AtPath 返回指向其它 API 路径的 Api(跨接口断言时用)
|
||||
func (a *Api) AtPath(path string) *Api {
|
||||
return &Api{
|
||||
app: a.app,
|
||||
path: path,
|
||||
t: a.t,
|
||||
session: a.session,
|
||||
app: a.app,
|
||||
path: path,
|
||||
t: a.t,
|
||||
session: a.session,
|
||||
flowName: a.flowName,
|
||||
flowGroup: a.flowGroup,
|
||||
flowDesc: a.flowDesc,
|
||||
flowPrep: a.flowPrep,
|
||||
flowStep: a.flowStep,
|
||||
flowStepIndex: a.flowStepIndex,
|
||||
flowParent: a.flowParent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,14 +100,29 @@ func (a *Api) RunSub(desc string, fn func(a *Api)) {
|
||||
}
|
||||
a.t.Run(desc, func(t *testing.T) {
|
||||
fn(&Api{
|
||||
app: a.app,
|
||||
path: a.path,
|
||||
t: t,
|
||||
session: a.session,
|
||||
app: a.app,
|
||||
path: a.path,
|
||||
t: t,
|
||||
session: a.session,
|
||||
flowName: a.flowName,
|
||||
flowGroup: a.flowGroup,
|
||||
flowDesc: a.flowDesc,
|
||||
flowPrep: a.flowPrep,
|
||||
flowStep: a.flowStep,
|
||||
flowStepIndex: a.flowStepIndex,
|
||||
flowParent: a.flowParent,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// FromCase 绑定单接口已有验收用例:加载其请求模板;跑通后回写该 case 的响应
|
||||
func (a *Api) FromCase(caseName string) *ApiCase {
|
||||
c := a.newCase()
|
||||
c.bindCase = caseName
|
||||
c.applyCaseTemplate(a.app.findCaseTemplate(a.path, caseName))
|
||||
return c
|
||||
}
|
||||
|
||||
// JSON 设置 JSON body,返回 ApiCase 构建器
|
||||
func (a *Api) JSON(body interface{}) *ApiCase {
|
||||
c := a.newCase()
|
||||
@@ -153,6 +189,13 @@ func (a *Api) Note(note string) *ApiCase {
|
||||
return c
|
||||
}
|
||||
|
||||
// AnyMsg 跳过错误 msg 断言(仅断言 status),用于响应 msg 含网络错误等动态内容的用例
|
||||
func (a *Api) AnyMsg() *ApiCase {
|
||||
c := a.newCase()
|
||||
c.skipMsg = true
|
||||
return c
|
||||
}
|
||||
|
||||
// DB 获取数据库实例(在测试事务内)
|
||||
func (a *Api) DB() *HoTimeDB {
|
||||
return &a.app.Db
|
||||
@@ -184,6 +227,35 @@ type ApiCase struct {
|
||||
fileContent []byte
|
||||
note string
|
||||
verifyFn func(a *Api) error
|
||||
bindCase string // 绑定的单接口验收用例名(FromCase)
|
||||
skipMsg bool // AnyMsg:跳过错误 msg 断言(用于网络错误等动态消息)
|
||||
}
|
||||
|
||||
// FromCase 在已有 ApiCase 上绑定验收用例名(若尚未加载模板则加载)
|
||||
func (c *ApiCase) FromCase(caseName string) *ApiCase {
|
||||
c.bindCase = caseName
|
||||
if c.query == nil && c.jsonBody == nil && c.formBody == nil {
|
||||
c.applyCaseTemplate(c.api.app.findCaseTemplate(c.api.path, caseName))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ApiCase) applyCaseTemplate(tmpl *caseTemplate) {
|
||||
if tmpl == nil {
|
||||
return
|
||||
}
|
||||
if c.query == nil && tmpl.Query != nil {
|
||||
c.query = tmpl.Query
|
||||
}
|
||||
if c.jsonBody == nil && tmpl.Json != nil {
|
||||
c.jsonBody = tmpl.Json
|
||||
}
|
||||
if c.formBody == nil && tmpl.Form != nil {
|
||||
c.formBody = tmpl.Form
|
||||
}
|
||||
if c.note == "" && tmpl.Note != "" {
|
||||
c.note = tmpl.Note
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 叠加 JSON body
|
||||
@@ -236,6 +308,12 @@ func (c *ApiCase) Note(note string) *ApiCase {
|
||||
return c
|
||||
}
|
||||
|
||||
// AnyMsg 跳过错误 msg 断言(仅断言 status),可继续链式
|
||||
func (c *ApiCase) AnyMsg() *ApiCase {
|
||||
c.skipMsg = true
|
||||
return c
|
||||
}
|
||||
|
||||
// Verify 设置请求后的自定义校验函数(如查库验证数据状态)
|
||||
// 函数返回 nil 表示校验通过,返回 error 则用例失败并记录错误原因
|
||||
func (c *ApiCase) Verify(fn func(a *Api) error) *ApiCase {
|
||||
@@ -279,7 +357,7 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
hasExpectResult = true
|
||||
}
|
||||
}
|
||||
if expectStatus != 0 && expectMsg == "" {
|
||||
if expectStatus != 0 && expectMsg == "" && !c.skipMsg {
|
||||
expectMsg = desc
|
||||
}
|
||||
|
||||
@@ -294,15 +372,22 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
|
||||
defer func() {
|
||||
record := TestRecord{
|
||||
Path: c.api.path,
|
||||
Desc: desc,
|
||||
CaseName: desc,
|
||||
Passed: passed,
|
||||
Duration: time.Since(start),
|
||||
Method: method,
|
||||
Note: c.note,
|
||||
VerifyError: verifyError,
|
||||
FailReason: failReason,
|
||||
Path: c.api.path,
|
||||
Desc: desc,
|
||||
CaseName: desc,
|
||||
Passed: passed,
|
||||
Duration: time.Since(start),
|
||||
Method: method,
|
||||
Note: c.note,
|
||||
VerifyError: verifyError,
|
||||
FailReason: failReason,
|
||||
FlowName: c.api.flowName,
|
||||
FlowGroup: c.api.flowGroup,
|
||||
FlowDesc: c.api.flowDesc,
|
||||
FlowPrep: c.api.flowPrep,
|
||||
FlowStep: c.api.flowStep,
|
||||
FlowStepIndex: c.api.flowStepIndex,
|
||||
BindCase: c.bindCase,
|
||||
}
|
||||
if c.query != nil {
|
||||
record.Query = c.query
|
||||
@@ -336,6 +421,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
}
|
||||
record.ExpectResult = expectResp
|
||||
c.api.app.collector.Add(record)
|
||||
if c.bindCase != "" && c.api.flowName != "" {
|
||||
c.api.app.syncBoundCaseToEndpoint(record)
|
||||
}
|
||||
}()
|
||||
|
||||
c.api.app.Db.ResetTestSQLErrorCount()
|
||||
@@ -390,6 +478,9 @@ func (c *ApiCase) execute(method, desc string, expectStatus int, expectArgs ...i
|
||||
}
|
||||
|
||||
c.api.lastResp = resp
|
||||
if c.api.flowParent != nil {
|
||||
c.api.flowParent.SetLastResp(resp)
|
||||
}
|
||||
if passed && c.verifyFn != nil {
|
||||
if err := c.verifyFn(c.api); err != nil {
|
||||
t.Errorf("Verify 校验失败: %s", err.Error())
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package hotime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "code.hoteas.com/golang/hotime/common"
|
||||
. "code.hoteas.com/golang/hotime/db"
|
||||
)
|
||||
|
||||
// FlowTest 业务流程测试集合(流程名 → 定义)
|
||||
type FlowTest map[string]FlowDef
|
||||
|
||||
// FlowDef 单条业务流程定义
|
||||
type FlowDef struct {
|
||||
Group string // 左侧分组,如 "user" / "expect_demo"
|
||||
Desc string // 流程短标题(右侧标题)
|
||||
Prep string // 业务说明与前置/数据准备(右侧描述)
|
||||
Func func(f *Flow)
|
||||
}
|
||||
|
||||
// Flow 业务流程测试入口,整条流程共用一次测试事务
|
||||
type Flow struct {
|
||||
app *TestApp
|
||||
t *testing.T
|
||||
name string
|
||||
group string
|
||||
desc string
|
||||
prep string
|
||||
session Map
|
||||
stepIdx int
|
||||
lastResp *ApiResponse
|
||||
}
|
||||
|
||||
// WithSession 返回携带 session 的 Flow
|
||||
func (f *Flow) WithSession(s Map) *Flow {
|
||||
return &Flow{
|
||||
app: f.app,
|
||||
t: f.t,
|
||||
name: f.name,
|
||||
group: f.group,
|
||||
desc: f.desc,
|
||||
prep: f.prep,
|
||||
session: s,
|
||||
stepIdx: f.stepIdx,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 开始流程中的一步,返回指向指定路径的 Api(记录会归入 flows)
|
||||
func (f *Flow) Step(stepName, path string) *Api {
|
||||
f.stepIdx++
|
||||
return &Api{
|
||||
app: f.app,
|
||||
path: path,
|
||||
t: f.t,
|
||||
session: f.session,
|
||||
flowName: f.name,
|
||||
flowGroup: f.group,
|
||||
flowDesc: f.desc,
|
||||
flowPrep: f.prep,
|
||||
flowStep: stepName,
|
||||
flowStepIndex: f.stepIdx,
|
||||
flowParent: f,
|
||||
}
|
||||
}
|
||||
|
||||
// AtPath 在当前流程上下文中切换路径(不增加步骤序号)
|
||||
func (f *Flow) AtPath(path string) *Api {
|
||||
return &Api{
|
||||
app: f.app,
|
||||
path: path,
|
||||
t: f.t,
|
||||
session: f.session,
|
||||
flowName: f.name,
|
||||
flowGroup: f.group,
|
||||
flowDesc: f.desc,
|
||||
flowPrep: f.prep,
|
||||
flowStep: "",
|
||||
flowStepIndex: f.stepIdx,
|
||||
flowParent: f,
|
||||
}
|
||||
}
|
||||
|
||||
// DB 获取测试库(流程级事务内)
|
||||
func (f *Flow) DB() *HoTimeDB {
|
||||
return f.app.DB()
|
||||
}
|
||||
|
||||
// Resp 最近一次步骤响应
|
||||
func (f *Flow) Resp() *ApiResponse {
|
||||
return f.lastResp
|
||||
}
|
||||
|
||||
// SetLastResp 由框架在步骤执行后更新
|
||||
func (f *Flow) SetLastResp(r *ApiResponse) {
|
||||
f.lastResp = r
|
||||
}
|
||||
+316
-87
@@ -7,8 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,8 +22,183 @@ import (
|
||||
// TestApp 测试应用,封装 Application 提供测试能力
|
||||
type TestApp struct {
|
||||
*Application
|
||||
projs TestProj
|
||||
collector *TestCollector
|
||||
configPath string
|
||||
listeners []func(*Context) bool
|
||||
workDir string
|
||||
projs TestProj
|
||||
flows FlowTest
|
||||
collector *TestCollector
|
||||
|
||||
swaggerEnabled bool
|
||||
swaggerTitle string
|
||||
swaggerVersion string
|
||||
swaggerDir string
|
||||
|
||||
stopNewTests atomic.Bool
|
||||
swaggerMu sync.Mutex
|
||||
inited bool
|
||||
initOnce sync.Once
|
||||
}
|
||||
|
||||
// Test 创建测试应用(链式入口,延迟 Init 以便先 WorkDir)
|
||||
func Test(configPath string) *TestApp {
|
||||
return &TestApp{
|
||||
configPath: configPath,
|
||||
collector: &TestCollector{},
|
||||
}
|
||||
}
|
||||
|
||||
// WorkDir 设置工作目录(在 Init 前 chdir)
|
||||
func (that *TestApp) WorkDir(dir string) *TestApp {
|
||||
that.workDir = dir
|
||||
return that
|
||||
}
|
||||
|
||||
// Listener 追加连接监听(可选;可多次调用或一次传多个)。
|
||||
// Init 前仅暂存,ensureInit 时批量 SetConnectListener;Init 后立刻挂上。
|
||||
// 返回 true 表示已处理并停止进入控制器,false 继续。
|
||||
func (that *TestApp) Listener(fns ...func(*Context) bool) *TestApp {
|
||||
that.listeners = append(that.listeners, fns...)
|
||||
if that.inited && that.Application != nil {
|
||||
for _, fn := range fns {
|
||||
that.SetConnectListener(fn)
|
||||
}
|
||||
}
|
||||
return that
|
||||
}
|
||||
|
||||
// Swagger 启用接口级增量 Swagger 写入。
|
||||
// 参数可选:Swagger() / Swagger(title) / Swagger(title, version) / Swagger(title, version, outputDir)
|
||||
func (that *TestApp) Swagger(args ...string) *TestApp {
|
||||
that.swaggerEnabled = true
|
||||
that.swaggerTitle = "API"
|
||||
that.swaggerVersion = "1.0.0"
|
||||
if len(args) > 0 && args[0] != "" {
|
||||
that.swaggerTitle = args[0]
|
||||
}
|
||||
if len(args) > 1 && args[1] != "" {
|
||||
that.swaggerVersion = args[1]
|
||||
}
|
||||
if len(args) > 2 && args[2] != "" {
|
||||
that.swaggerDir = args[2]
|
||||
}
|
||||
return that
|
||||
}
|
||||
|
||||
// Proj 注册路由与单接口测试,并完成 Init
|
||||
func (that *TestApp) Proj(projects TestProj) *TestApp {
|
||||
that.projs = projects
|
||||
that.ensureInit()
|
||||
return that
|
||||
}
|
||||
|
||||
// Flows 注册多接口业务流程测试(可选)
|
||||
func (that *TestApp) Flows(flows FlowTest) *TestApp {
|
||||
that.flows = flows
|
||||
return that
|
||||
}
|
||||
|
||||
// Run 执行测试:注册优雅停测信号 → m.Run → 覆盖率 → Swagger 收尾,返回 exit code
|
||||
func (that *TestApp) Run(m *testing.M) int {
|
||||
that.ensureInit()
|
||||
that.installStopSignals()
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if that.Application != nil {
|
||||
_ = that.Db.RollbackTestTx()
|
||||
}
|
||||
|
||||
that.PrintCoverage()
|
||||
if that.swaggerEnabled {
|
||||
dir := that.swaggerOutputDir()
|
||||
if err := that.GenerateSwagger(that.swaggerTitle, that.swaggerVersion, dir); err != nil {
|
||||
fmt.Printf("Swagger 收尾失败: %v\n", err)
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func (that *TestApp) swaggerOutputDir() string {
|
||||
if that.swaggerDir != "" {
|
||||
return that.swaggerDir
|
||||
}
|
||||
if that.Application != nil && that.Config != nil {
|
||||
if d := that.Config.GetString("tpt"); d != "" {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return "tpt"
|
||||
}
|
||||
|
||||
func (that *TestApp) installStopSignals() {
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
fmt.Println("\n收到停测信号,将在当前接口/流程结束后停止并回滚测试事务…")
|
||||
that.RequestStop()
|
||||
}()
|
||||
}
|
||||
|
||||
// RequestStop 模拟优雅停测(等同收到 SIGINT):不再开启新的接口/流程,当前跑完后回滚并收尾
|
||||
func (that *TestApp) RequestStop() {
|
||||
that.stopNewTests.Store(true)
|
||||
}
|
||||
|
||||
func (that *TestApp) shouldStop() bool {
|
||||
return that.stopNewTests.Load()
|
||||
}
|
||||
|
||||
func (that *TestApp) ensureInit() {
|
||||
that.initOnce.Do(func() {
|
||||
if that.workDir != "" {
|
||||
if err := os.Chdir(that.workDir); err != nil {
|
||||
fmt.Printf("WorkDir(%s) 失败: %v\n", that.workDir, err)
|
||||
}
|
||||
}
|
||||
if that.configPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
devNull, _ := os.Open(os.DevNull)
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
|
||||
app := Init(that.configPath)
|
||||
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
devNull.Close()
|
||||
|
||||
app.WebConnectLog = nil
|
||||
app.Log.SetOutput(io.Discard)
|
||||
|
||||
for _, lis := range that.listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
that.Application = app
|
||||
if that.collector == nil {
|
||||
that.collector = &TestCollector{}
|
||||
}
|
||||
|
||||
if that.projs != nil {
|
||||
router := Router{}
|
||||
for projName, projDef := range that.projs {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
}
|
||||
|
||||
if app.HoTimeCache != nil {
|
||||
app.HoTimeCache.DisableDbCache()
|
||||
}
|
||||
app.Log.SetOutput(os.Stderr)
|
||||
that.inited = true
|
||||
})
|
||||
}
|
||||
|
||||
// TestResponse httptest 的原始响应封装
|
||||
@@ -59,29 +238,37 @@ type FailedCase struct {
|
||||
|
||||
// TestRecord 单条测试记录
|
||||
type TestRecord struct {
|
||||
Path string
|
||||
Desc string
|
||||
CaseName string
|
||||
Passed bool
|
||||
Duration time.Duration
|
||||
Method string
|
||||
Query map[string]interface{}
|
||||
JsonBody interface{}
|
||||
FormBody map[string]interface{}
|
||||
HasFile bool
|
||||
FileField string
|
||||
ResponseBody map[string]interface{}
|
||||
Note string
|
||||
ExpectResult interface{}
|
||||
VerifyError string
|
||||
FailReason string
|
||||
Path string
|
||||
Desc string
|
||||
CaseName string
|
||||
Passed bool
|
||||
Duration time.Duration
|
||||
Method string
|
||||
Query map[string]interface{}
|
||||
JsonBody interface{}
|
||||
FormBody map[string]interface{}
|
||||
HasFile bool
|
||||
FileField string
|
||||
ResponseBody map[string]interface{}
|
||||
Note string
|
||||
ExpectResult interface{}
|
||||
VerifyError string
|
||||
FailReason string
|
||||
FlowName string
|
||||
FlowGroup string
|
||||
FlowDesc string
|
||||
FlowPrep string
|
||||
FlowStep string
|
||||
FlowStepIndex int
|
||||
BindCase string // FromCase 绑定的单接口验收用例名
|
||||
}
|
||||
|
||||
// TestCollector 线程安全的测试记录收集器
|
||||
type TestCollector struct {
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
Visited map[string]bool // 被 RunTests 实际调用过的路径(不论是否产生记录)
|
||||
mu sync.Mutex
|
||||
Records []TestRecord
|
||||
Visited map[string]bool
|
||||
VisitedFlows map[string]bool
|
||||
}
|
||||
|
||||
func (c *TestCollector) Add(r TestRecord) {
|
||||
@@ -90,7 +277,7 @@ func (c *TestCollector) Add(r TestRecord) {
|
||||
c.Records = append(c.Records, r)
|
||||
}
|
||||
|
||||
// MarkVisited 标记路径已被测试框架调用(由 RunTests 在进入方法级 t.Run 时调用)
|
||||
// MarkVisited 标记路径已被测试框架调用
|
||||
func (c *TestCollector) MarkVisited(path string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -100,47 +287,14 @@ func (c *TestCollector) MarkVisited(path string) {
|
||||
c.Visited[path] = true
|
||||
}
|
||||
|
||||
// NewTestApp 创建测试应用实例
|
||||
// configPath: 配置文件路径(如 "../config/config.json")
|
||||
// projects: 项目定义(路由 + 测试)
|
||||
// listeners: 可选的请求拦截器(与 SetConnectListener 相同)
|
||||
func NewTestApp(configPath string, projects TestProj, listeners ...func(*Context) bool) *TestApp {
|
||||
origStdout := os.Stdout
|
||||
origStderr := os.Stderr
|
||||
devNull, _ := os.Open(os.DevNull)
|
||||
os.Stdout = devNull
|
||||
os.Stderr = devNull
|
||||
|
||||
app := Init(configPath)
|
||||
|
||||
os.Stdout = origStdout
|
||||
os.Stderr = origStderr
|
||||
devNull.Close()
|
||||
|
||||
app.WebConnectLog = nil
|
||||
app.Log.SetOutput(io.Discard)
|
||||
|
||||
for _, lis := range listeners {
|
||||
app.SetConnectListener(lis)
|
||||
}
|
||||
|
||||
router := Router{}
|
||||
for projName, projDef := range projects {
|
||||
router[projName] = projDef.Proj
|
||||
}
|
||||
app.SetupForTest(router)
|
||||
|
||||
if app.HoTimeCache != nil {
|
||||
app.HoTimeCache.DisableDbCache()
|
||||
}
|
||||
|
||||
app.Log.SetOutput(os.Stderr)
|
||||
|
||||
return &TestApp{
|
||||
Application: app,
|
||||
projs: projects,
|
||||
collector: &TestCollector{},
|
||||
// MarkFlowVisited 标记业务流程已运行
|
||||
func (c *TestCollector) MarkFlowVisited(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.VisitedFlows == nil {
|
||||
c.VisitedFlows = map[string]bool{}
|
||||
}
|
||||
c.VisitedFlows[name] = true
|
||||
}
|
||||
|
||||
// SetupForTest 初始化路由(复用 Run 的路由注册逻辑,但不启动 HTTP 服务)
|
||||
@@ -199,34 +353,48 @@ func (that *TestApp) TestRequest(method, path string, body *http.Request) TestRe
|
||||
}
|
||||
}
|
||||
|
||||
// RunTests 运行所有注册的测试,每个方法级别使用事务隔离
|
||||
// RunTests 运行所有注册的测试;方法级事务隔离;另运行 Flows
|
||||
func (that *TestApp) RunTests(t *testing.T) {
|
||||
for projName, projDef := range that.projs {
|
||||
projName := projName
|
||||
projDef := projDef
|
||||
that.ensureInit()
|
||||
|
||||
projNames := sortedKeys(that.projs)
|
||||
for _, projName := range projNames {
|
||||
projDef := that.projs[projName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余测试")
|
||||
return
|
||||
}
|
||||
t.Run(projName, func(t *testing.T) {
|
||||
for ctrName, ctrTest := range projDef.Tests {
|
||||
ctrName := ctrName
|
||||
ctrTest := ctrTest
|
||||
ctrNames := sortedKeys(projDef.Tests)
|
||||
for _, ctrName := range ctrNames {
|
||||
ctrTest := projDef.Tests[ctrName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余测试")
|
||||
return
|
||||
}
|
||||
t.Run(ctrName, func(t *testing.T) {
|
||||
for methodName, apiTest := range ctrTest {
|
||||
methodName := methodName
|
||||
apiTest := apiTest
|
||||
t.Run(methodName, func(t *testing.T) {
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
that.collector.MarkVisited(path)
|
||||
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启测试事务失败:", err)
|
||||
methodNames := sortedKeys(ctrTest)
|
||||
for _, methodName := range methodNames {
|
||||
apiTest := ctrTest[methodName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余接口")
|
||||
return
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
t.Run(methodName, func(t *testing.T) {
|
||||
path := "/" + projName + "/" + ctrName + "/" + methodName
|
||||
that.collector.MarkVisited(path)
|
||||
|
||||
methodBuf := &bytes.Buffer{}
|
||||
that.Db.SetTestLogBuffer(methodBuf)
|
||||
defer that.Db.SetTestLogBuffer(nil)
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启测试事务失败:", err)
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
|
||||
api := &Api{
|
||||
methodBuf := &bytes.Buffer{}
|
||||
that.Db.SetTestLogBuffer(methodBuf)
|
||||
defer that.Db.SetTestLogBuffer(nil)
|
||||
|
||||
api := &Api{
|
||||
app: that,
|
||||
path: path,
|
||||
t: t,
|
||||
@@ -235,12 +403,73 @@ func (that *TestApp) RunTests(t *testing.T) {
|
||||
api.t = t
|
||||
apiTest.Func(api)
|
||||
})
|
||||
|
||||
if that.swaggerEnabled {
|
||||
that.flushEndpointSwagger(projName, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(that.flows) == 0 {
|
||||
return
|
||||
}
|
||||
t.Run("flows", func(t *testing.T) {
|
||||
flowNames := sortedKeys(that.flows)
|
||||
for _, flowName := range flowNames {
|
||||
flowDef := that.flows[flowName]
|
||||
if that.shouldStop() {
|
||||
t.Skip("收到停测信号,跳过剩余流程")
|
||||
return
|
||||
}
|
||||
t.Run(flowName, func(t *testing.T) {
|
||||
that.collector.MarkFlowVisited(flowName)
|
||||
|
||||
err := that.Db.BeginTestTx()
|
||||
if err != nil {
|
||||
t.Fatal("开启流程测试事务失败:", err)
|
||||
}
|
||||
defer that.Db.RollbackTestTx()
|
||||
|
||||
methodBuf := &bytes.Buffer{}
|
||||
that.Db.SetTestLogBuffer(methodBuf)
|
||||
defer that.Db.SetTestLogBuffer(nil)
|
||||
|
||||
flow := &Flow{
|
||||
app: that,
|
||||
t: t,
|
||||
name: flowName,
|
||||
group: flowDef.Group,
|
||||
desc: flowDef.Desc,
|
||||
prep: flowDef.Prep,
|
||||
}
|
||||
desc := flowDef.Desc
|
||||
if desc == "" {
|
||||
desc = flowName
|
||||
}
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
flow.t = t
|
||||
flowDef.Func(flow)
|
||||
})
|
||||
|
||||
if that.swaggerEnabled {
|
||||
that.flushFlowSwagger(flowName)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func sortedKeys[M ~map[string]V, V any](m M) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// PrintCoverage 输出 API 测试覆盖率报告
|
||||
@@ -250,6 +479,9 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
pathRecords := map[string][]TestRecord{}
|
||||
that.collector.mu.Lock()
|
||||
for _, r := range that.collector.Records {
|
||||
if r.FlowName != "" {
|
||||
continue
|
||||
}
|
||||
pathRecords[r.Path] = append(pathRecords[r.Path], r)
|
||||
}
|
||||
visited := that.collector.Visited
|
||||
@@ -296,7 +528,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总全部用例统计
|
||||
for _, d := range report.Details {
|
||||
report.TotalCases += d.CaseCount
|
||||
report.PassedCases += d.Passed
|
||||
@@ -306,7 +537,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
ranCount := len(visited)
|
||||
isPartialRun := ranCount > 0 && ranCount < report.Covered
|
||||
|
||||
// 分拣本次运行中通过/失败的接口
|
||||
var passedDetails, failedDetails []MethodCoverage
|
||||
for _, d := range report.Details {
|
||||
if !d.HasTest || d.CaseCount == 0 {
|
||||
@@ -322,7 +552,6 @@ func (that *TestApp) PrintCoverage() CoverageReport {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算本次运行的用例统计(部分运行时只统计实际运行的接口)
|
||||
ranInterfaces := len(failedDetails) + len(passedDetails)
|
||||
var ranTotalCases, ranPassedCases, ranFailedCases int
|
||||
if isPartialRun {
|
||||
|
||||
+797
-121
File diff suppressed because it is too large
Load Diff
+41
@@ -0,0 +1,41 @@
|
||||
package hotime
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTruncateUA_ShortUnchanged(t *testing.T) {
|
||||
ua := "Mozilla/5.0 (Test)"
|
||||
if got := truncateUA(ua, 200); got != ua {
|
||||
t.Fatalf("短串不应被截断: got=%q want=%q", got, ua)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUA_LongTruncatedTo200(t *testing.T) {
|
||||
long := ""
|
||||
for i := 0; i < 300; i++ {
|
||||
long += "a"
|
||||
}
|
||||
got := truncateUA(long, 200)
|
||||
if len([]rune(got)) != 200 {
|
||||
t.Fatalf("截断后长度=%d want 200", len([]rune(got)))
|
||||
}
|
||||
if got != long[:200] {
|
||||
t.Fatalf("ASCII 截断内容不符: got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUA_MultiByteNotBroken(t *testing.T) {
|
||||
// 前 199 个 ASCII + 1 个多字节字符("中"占 3 字节),max=200 时应完整保留该 rune 而非切碎字节
|
||||
prefix := ""
|
||||
for i := 0; i < 199; i++ {
|
||||
prefix += "a"
|
||||
}
|
||||
long := prefix + "中" + "extra-should-be-cut"
|
||||
got := truncateUA(long, 200)
|
||||
runes := []rune(got)
|
||||
if len(runes) != 200 {
|
||||
t.Fatalf("按 rune 截断后应为 200 个字符, got=%d (%q)", len(runes), got)
|
||||
}
|
||||
if runes[199] != '中' {
|
||||
t.Fatalf("第 200 个字符应为完整的 '中',实际=%q", string(runes[199]))
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,12 @@ var Config = Map{
|
||||
}
|
||||
|
||||
var ConfigNote = Map{
|
||||
"logLevel": "默认1,必须,0=仅打印错误日志,>=1=打印全部日志(debug/info/warn/error),同时控制SQL日志和Cache-Control(0=public,>=1=no-cache)",
|
||||
"logLevel": "默认1,必须,0=仅打印错误日志,>=1=打印全部日志(debug/info/warn/error),同时控制 SQL 日志;不控制静态 Cache-Control(静态固定 public)",
|
||||
"logFile": "无默认,非必须,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
|
||||
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
|
||||
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
|
||||
"webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
|
||||
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port(如 192.168.1.10:8085),支持单机多进程和多服务器集群",
|
||||
"seqUrl": "无默认,非必须,Seq 日志平台地址,如 http://127.0.0.1:5341,填写后自动将所有日志通过异步 channel 队列推送到 Seq,空值则不激活;同时自动将 fmt.Println/标准log 包的输出也一并捕获推送;instance 字段自动拼接为 ip:port;请求日志自动带 sid(sessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志 ip 按 EO-Connecting-IP→XFF非回环→X-Real-IP→RemoteAddr 选取,多源去重记 ip_chain,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/Error,Seq 与文件仍按 logLevel 全量",
|
||||
"seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写",
|
||||
//"codeConfig": Map{
|
||||
// "注释": "配置即启用,非必须,默认无",
|
||||
|
||||
Reference in New Issue
Block a user