Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3e71decf4 | |||
| e22df36d3f | |||
| 3e08b20698 | |||
| c8382200a0 | |||
| b951ba027e | |||
| a0719dc72d | |||
| 332c2414bc | |||
| 853940c795 | |||
| b5b764e3f9 | |||
| 25faa93724 |
@@ -1,32 +1,45 @@
|
|||||||
---
|
---
|
||||||
description: HoTime Doc-Driven + TDD 总门禁
|
description: HoTime Doc-Driven + TDD 门禁
|
||||||
alwaysApply: true
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# HoTime 工作流
|
# HoTime 工作流
|
||||||
|
|
||||||
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
|
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④跨模块边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
|
||||||
|
|
||||||
## 定位
|
## 定位
|
||||||
|
|
||||||
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。
|
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增或修改的**测试文件计入锁定范围**。先读流程与规则边界再改。口径不清 **先问**。
|
||||||
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
|
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
|
||||||
|
- 配置 / 部署 / 发版 / 密钥 / 脚本等运维操作:**先查仓内相关 docs**(计入锁定)定可改边界与回滚点,未读禁动手;偏离文档先说明并回写。
|
||||||
|
|
||||||
## 改与测
|
## 改与测
|
||||||
|
|
||||||
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**,跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
|
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` 中**无则补、有则改**用例,并跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
|
||||||
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条。
|
- 单接口样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到 1~N 个相关子用例(更长 `-run`;名以 `*_test.go` / `go test -list` 为准);**改哪测哪**。
|
||||||
|
- 用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条执行。
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- **有旧改旧**;无篇目且确有缺口才新建。禁不查就建、禁同一入口重复建档。旧文只补受影响节;只写最终态;对照源码,禁臆测。
|
- **有旧改旧**;无篇目且确有缺口才新建,禁不查就建、禁同一入口重复建档;旧文日常只补受影响节。
|
||||||
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md`。
|
- 业务/工程文写**流程与口径、规则边界、职责与影响、怎么测**,字段按需、必要处配 mermaid;**禁** UI 皮相与实现转法(for/if、组包细节堆砌);对照源码是核验不是抄写。只写最终态、禁臆测。
|
||||||
|
- **触改即升级**:回写正文时该篇明显不合规 → 先通读全文并核验源码/测试/必要上下游,**整篇重写**为该入口完整最终态;**禁**删成只剩本次内容、禁借机扫仓瘦身。
|
||||||
|
- 路径用相对 Markdown 链接;**禁**绝对路径、`file:///`、`~/`。archive / 审计目录 **不作**依据。新建须回写 `docs/README.md`。
|
||||||
|
|
||||||
|
## Git 合并冲突
|
||||||
|
|
||||||
|
- 解冲突时:**禁止**用 `ours` / `theirs` 或「以一侧为准」整文件覆盖,从而丢掉另一侧有效改动。
|
||||||
|
- **尽量保存两边代码**:对照两侧 diff,把两边意图合进同一最终文件。
|
||||||
|
- **业务冲突由 AI 合成**:读懂两边改动后产出可运行结果;仅当同一字段互斥才给出合成结论,并在回报里说明取舍理由。
|
||||||
|
- modify/delete:先判断删除侧是否为有意清理、修改侧是否仍有业务价值;能保留则保留或迁到合理路径,禁不读 diff 就整侧丢弃。
|
||||||
|
- **Swagger 例外(硬规则)**:`tpt/swagger/`、`example/tpt/swagger/` 等为测试框架生成物,**永不**纳入版本管理、**永不**为 swagger 解冲突或花精力合并;遇相关冲突直接按「移出跟踪 + 忽略」处理。
|
||||||
|
|
||||||
## 回复
|
## 回复
|
||||||
|
|
||||||
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
|
- 先结论;对话摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
|
||||||
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。
|
- 排障四层:正常行为 → 断点 → 故障点 → 修正与复验。
|
||||||
|
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。勾选空洞/矛盾/丢格式 → 宜新开窗口。
|
||||||
|
|
||||||
## 批量补文档(仅整包)
|
## 批量补文档(仅整包)
|
||||||
|
|
||||||
一文一题;最少 2 轮自检。日常小改不适用。
|
一文一题;按分型写完整,密度自检以「无 UI 皮相 / 无实现复述」为准;最少 2 轮自检。日常小改不适用。
|
||||||
|
|||||||
+140
-19
@@ -108,20 +108,24 @@ func (that *Application) initiateGracefulShutdown(drain, shutdown time.Duration)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIP 取客户端真实 IP:X-Real-IP → X-Forwarded-For 第一段 → RemoteAddr
|
// isLoopbackIP 判断是否为回环地址(空串视为不可用)
|
||||||
func clientIP(req *http.Request) string {
|
func isLoopbackIP(ip string) bool {
|
||||||
if req == nil {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
if ip := strings.TrimSpace(req.Header.Get("X-Real-IP")); ip != "" {
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
if xff := req.Header.Get("X-Forwarded-For"); xff != "" {
|
|
||||||
if i := strings.Index(xff, ","); i >= 0 {
|
|
||||||
return strings.TrimSpace(xff[:i])
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(xff)
|
|
||||||
}
|
|
||||||
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
host, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
if err == nil && host != "" {
|
if err == nil && host != "" {
|
||||||
return host
|
return host
|
||||||
@@ -129,6 +133,105 @@ func clientIP(req *http.Request) string {
|
|||||||
return req.RemoteAddr
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeRequestId 校验前端传入的 X-Request-Id。
|
||||||
|
// 仅接受标准 UUID 形态(36 位,8-4-4-4-12,hex+连字符),统一转小写;
|
||||||
|
// 其余(超长、控制符、任意注入串)一律丢弃返回空,保证日志字段安全且低基数可控。
|
||||||
|
func normalizeRequestId(v string) string {
|
||||||
|
if len(v) != 36 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := 0; i < 36; i++ {
|
||||||
|
c := v[i]
|
||||||
|
switch i {
|
||||||
|
case 8, 13, 18, 23:
|
||||||
|
if c != '-' {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||||
|
if !isHex {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.ToLower(v)
|
||||||
|
}
|
||||||
|
|
||||||
// shortID 生成 n 字节随机 hex(n=6 → 12 位)
|
// shortID 生成 n 字节随机 hex(n=6 → 12 位)
|
||||||
func shortID(n int) string {
|
func shortID(n int) string {
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
@@ -396,6 +499,8 @@ func (that *Application) SetConfig(configPath ...string) {
|
|||||||
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
that.Log.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
||||||
if that.WebConnectLog != nil {
|
if that.WebConnectLog != nil {
|
||||||
that.WebConnectLog.SetSeqWriter(seqUrl, that.Config.GetString("seqApiKey"), instance)
|
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)
|
that.Log.Infof("Seq 日志推送已启动: url=%s instance=%s", seqUrl, instance)
|
||||||
}
|
}
|
||||||
@@ -476,13 +581,20 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
unescapeUrl = req.RequestURI
|
unescapeUrl = req.RequestURI
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头
|
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头。
|
||||||
requestId := shortID(6)
|
// 前后端共用一个 request_id:前端带合法 X-Request-Id(UUID)则沿用,非法/缺失才服务端自生成,
|
||||||
|
// 严格格式校验杜绝日志注入。
|
||||||
|
requestId := normalizeRequestId(req.Header.Get("X-Request-Id"))
|
||||||
|
if requestId == "" {
|
||||||
|
requestId = shortID(6)
|
||||||
|
}
|
||||||
sid := sessionId
|
sid := sessionId
|
||||||
if len(sid) > 12 {
|
if len(sid) > 12 {
|
||||||
sid = sid[:12]
|
sid = sid[:12]
|
||||||
}
|
}
|
||||||
w.Header().Set("X-Request-Id", requestId)
|
w.Header().Set("X-Request-Id", requestId)
|
||||||
|
// 服务端接收时间(ms),与 View() 写出的 X-Server-Sent-Ms 组成四时间点时钟校准
|
||||||
|
w.Header().Set("X-Server-Received-Ms", strconv.FormatInt(nowUnixTime.UnixMilli(), 10))
|
||||||
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
|
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
|
||||||
dbCopy := that.Db
|
dbCopy := that.Db
|
||||||
dbCopy.Log = reqLog
|
dbCopy.Log = reqLog
|
||||||
@@ -504,16 +616,23 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
//是否展示日志
|
//是否展示日志
|
||||||
if that.WebConnectLog != nil {
|
if that.WebConnectLog != nil {
|
||||||
|
ip, ipChain := resolveClientIP(req)
|
||||||
evt := that.WebConnectLog.Info().
|
evt := that.WebConnectLog.Info().
|
||||||
Str("ip", clientIP(req)).
|
Str("ip", ip).
|
||||||
Str("method", context.Req.Method).
|
Str("method", context.Req.Method).
|
||||||
Str("sid", sid).
|
Str("sid", sid).
|
||||||
Str("request_id", requestId).
|
Str("request_id", requestId).
|
||||||
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
|
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
|
||||||
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
|
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 != "" {
|
if country := req.Header.Get("EO-Client-IPCountry"); country != "" {
|
||||||
evt = evt.Str("ip_country", 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)
|
evt.Msg(context.HandlerStr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -628,8 +747,9 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
|||||||
//header.Set("Access-Control-Allow-Origin", "*")
|
//header.Set("Access-Control-Allow-Origin", "*")
|
||||||
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
||||||
header.Set("Access-Control-Allow-Credentials", "true")
|
header.Set("Access-Control-Allow-Credentials", "true")
|
||||||
header.Set("Access-Control-Expose-Headers", "*")
|
// credentials 模式下浏览器不认 "*",必须显式列出可读响应头
|
||||||
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie")
|
header.Set("Access-Control-Expose-Headers", "X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms")
|
||||||
|
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie,X-Request-Id")
|
||||||
|
|
||||||
if sessionId != "" {
|
if sessionId != "" {
|
||||||
//跨域允许需要设置cookie的允许跨域https才有效果
|
//跨域允许需要设置cookie的允许跨域https才有效果
|
||||||
@@ -675,8 +795,9 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
|
|||||||
|
|
||||||
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
|
||||||
header.Set("Access-Control-Allow-Credentials", "true")
|
header.Set("Access-Control-Allow-Credentials", "true")
|
||||||
header.Set("Access-Control-Expose-Headers", "*")
|
// credentials 模式下浏览器不认 "*",必须显式列出可读响应头
|
||||||
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie")
|
header.Set("Access-Control-Expose-Headers", "X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms")
|
||||||
|
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie,X-Request-Id")
|
||||||
|
|
||||||
if sessionId != "" {
|
if sessionId != "" {
|
||||||
//跨域允许需要设置cookie的允许跨域https才有效果
|
//跨域允许需要设置cookie的允许跨域https才有效果
|
||||||
|
|||||||
@@ -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)
|
re := that.Db.Insert(tableName, inData)
|
||||||
|
|
||||||
if re == 0 {
|
if re == 0 {
|
||||||
@@ -604,9 +615,13 @@ var TptProject = Proj{
|
|||||||
if download == 1 {
|
if download == 1 {
|
||||||
|
|
||||||
tableNameLabel := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetString("label")
|
tableNameLabel := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetString("label")
|
||||||
|
sheetName := tableNameLabel
|
||||||
|
if sheetName == "" || len(sheetName) > 31 {
|
||||||
|
sheetName = tableName
|
||||||
|
}
|
||||||
f := excelize.NewFile()
|
f := excelize.NewFile()
|
||||||
// 创建一个工作表
|
// 创建一个工作表
|
||||||
f.NewSheet(tableNameLabel)
|
f.NewSheet(sheetName)
|
||||||
|
|
||||||
f.DeleteSheet("Sheet1")
|
f.DeleteSheet("Sheet1")
|
||||||
columns := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetSlice("columns")
|
columns := that.MakeCodeRouter[hotimeName].TableConfig.GetMap(tableName).GetSlice("columns")
|
||||||
@@ -622,21 +637,21 @@ var TptProject = Proj{
|
|||||||
//单行
|
//单行
|
||||||
for k1, v1 := range reData {
|
for k1, v1 := range reData {
|
||||||
if k1 == 0 {
|
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") != "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.GetString("name") == "table" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.GetString("name") == "table_id" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -647,7 +662,7 @@ var TptProject = Proj{
|
|||||||
for ok, _ := range options {
|
for ok, _ := range options {
|
||||||
ov := options.GetMap(ok)
|
ov := options.GetMap(ok)
|
||||||
if ov.GetString("value") == v1.GetString(v.GetString("name")) {
|
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
|
isEnd = true
|
||||||
break
|
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")
|
filePath := that.Config.GetString("filePath")
|
||||||
@@ -668,7 +683,7 @@ var TptProject = Proj{
|
|||||||
}
|
}
|
||||||
|
|
||||||
//path := time.Now().Format(filePath)
|
//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 {
|
if e != nil {
|
||||||
that.Display(3, e)
|
that.Display(3, e)
|
||||||
return
|
return
|
||||||
@@ -677,7 +692,7 @@ var TptProject = Proj{
|
|||||||
|
|
||||||
// 根据指定路径保存文件
|
// 根据指定路径保存文件
|
||||||
if err := f.SaveAs(that.Config.GetString("tpt") + filePath); err != nil {
|
if err := f.SaveAs(that.Config.GetString("tpt") + filePath); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Printf("SaveAs failed: %v\n", err)
|
||||||
that.Display(4, "输出异常")
|
that.Display(4, "输出异常")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -875,7 +890,7 @@ var TptProject = Proj{
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := time.Now().Format(filePath)
|
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 {
|
if e != nil {
|
||||||
that.Display(3, e)
|
that.Display(3, e)
|
||||||
return
|
return
|
||||||
@@ -963,7 +978,7 @@ var TptProject = Proj{
|
|||||||
},
|
},
|
||||||
"login": func(that *Context) {
|
"login": func(that *Context) {
|
||||||
// 限速:同一 IP 1分钟内最多尝试10次
|
// 限速:同一 IP 1分钟内最多尝试10次
|
||||||
ip := that.Req.Header.Get("X-Forwarded-For")
|
ip := clientIP(that.Req)
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = that.Req.RemoteAddr
|
ip = that.Req.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-6
@@ -175,7 +175,9 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
|||||||
tableInfo = db.Query("pragma table_info([" + v.GetString("name") + "]);")
|
tableInfo = db.Query("pragma table_info([" + v.GetString("name") + "]);")
|
||||||
}
|
}
|
||||||
if db.Type == "dm" || db.Type == "dameng" {
|
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)
|
idSlice = append(idSlice, tableInfo)
|
||||||
@@ -303,11 +305,33 @@ func (that *MakeCode) Db2JSON(db *db.HoTimeDB, config Map) {
|
|||||||
options := Slice{}
|
options := Slice{}
|
||||||
comments := strings.Split(info.GetString("label"), ":")
|
comments := strings.Split(info.GetString("label"), ":")
|
||||||
if len(comments) >= 2 {
|
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 {
|
for _, v := range optionComment {
|
||||||
optionSlice := strings.Split(v, "-")
|
v = strings.TrimSpace(v)
|
||||||
if len(optionSlice) >= 2 {
|
if v == "" {
|
||||||
options = append(options, Map{"name": optionSlice[1], "value": optionSlice[0]})
|
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"))
|
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
|
data[v.GetString("name")] = reqValue
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -127,6 +128,9 @@ func (that *Context) View() {
|
|||||||
}
|
}
|
||||||
that.DataSize = len(d)
|
that.DataSize = len(d)
|
||||||
that.RespData = nil
|
that.RespData = nil
|
||||||
|
// 服务端发送时间(ms):与 X-Server-Received-Ms 组成四时间点,供前端时钟校准;
|
||||||
|
// 必须在首次 Write 之前设置,否则 header 已锁定
|
||||||
|
that.Resp.Header().Set("X-Server-Sent-Ms", strconv.FormatInt(time.Now().UnixMilli(), 10))
|
||||||
that.Resp.Write(d)
|
that.Resp.Write(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -765,6 +765,17 @@ CREATE TABLE user (
|
|||||||
|
|
||||||
**规则**:`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'`)。
|
**规则**:`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 生成的代码结构
|
### 4.5 生成的代码结构
|
||||||
|
|
||||||
#### 内嵌模式 (mode=0)
|
#### 内嵌模式 (mode=0)
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ server {
|
|||||||
|
|
||||||
# 其他常规配置...
|
# 其他常规配置...
|
||||||
proxy_set_header Host $host;
|
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;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-17
@@ -23,10 +23,28 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
|||||||
|
|
||||||
激活后每个 HTTP 请求会自动:
|
激活后每个 HTTP 请求会自动:
|
||||||
|
|
||||||
- 生成 `request_id`(12 位 hex),回写响应头 `X-Request-Id`
|
- 确定 `request_id`(前端带合法 UUID 则沿用,否则自生成 12 位 hex),回写响应头 `X-Request-Id`
|
||||||
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
- 将 `sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
|
||||||
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
|
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
|
||||||
- **控制台自动降噪为 Warn+**(Info/Debug/SQL/访问日志仍进 Seq 与文件,零额外配置)
|
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 请求追踪与前端串联(共用 request_id)
|
||||||
|
|
||||||
|
前后端**共用一个 `request_id`**:前端为每次请求生成 UUID 并经 `X-Request-Id` 请求头带上,服务端严格校验(36 位 8-4-4-4-12 hex+连字符,统一小写)通过则直接沿用为本请求 `request_id`;非法或缺失(含浏览器直接访问、老前端)则服务端自生成 12 位 hex。最终值回写响应头 `X-Request-Id`,业务/SQL/访问日志统一携带——Seq 里一条 `request_id = '<uuid>'` 即可同时串出前端事件与后端全链路日志。
|
||||||
|
|
||||||
|
- 严格格式白名单杜绝日志注入(换行/引号/超长一律丢弃走自生成分支)
|
||||||
|
- 前端伪造/重复 UUID 只污染其自身请求的串联,不影响其他请求;接受该风险换取单字段简洁
|
||||||
|
|
||||||
|
同时每个请求回写**服务端收发时间**响应头,供前端做四时间点时钟校准(前端日志的 `@t` 对齐服务器时间轴):
|
||||||
|
|
||||||
|
- `X-Server-Received-Ms`:handler 入口时间(ms)
|
||||||
|
- `X-Server-Sent-Ms`:响应体写出前时间(ms,`context.View()` 设置;静态文件不带)
|
||||||
|
|
||||||
|
CORS 已相应放行/暴露:`Access-Control-Allow-Headers` 增加 `X-Request-Id`;`Access-Control-Expose-Headers` 由 `*` 改为显式 `X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms`(credentials 模式下浏览器不认 `*`)。
|
||||||
|
|
||||||
|
**怎么测**:`request_trace_test.go` — `go test . -count=1 -run 'TestNormalizeRequestId|TestRequestIdSharing|TestViewSetsServerSentMsHeader'`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,17 +52,19 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
|
|||||||
|
|
||||||
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
|
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
|
||||||
|
|
||||||
业务在 `SetConnectListener` 鉴权通过后绑定(xbc `main.go`):
|
业务在 `SetConnectListener` 中按需绑定自定义字段(建议放在鉴权/守卫之前,这样未登录被拒的 Warning 也能带上维度字段):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// app 鉴权通过后
|
appIns.SetConnectListener(func(context *Context) bool {
|
||||||
context.LogBind("user_id", context.Session("user_id").ToCeilInt64())
|
if v := context.Session("user_id").ToCeilInt64(); v > 0 {
|
||||||
|
context.LogBind("user_id", v)
|
||||||
// admin 鉴权通过后(或 session 已有 admin_id)
|
}
|
||||||
context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
|
// 其他 session / 请求参数字段同理:有值再绑
|
||||||
|
return false
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。
|
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。session 在 context 内有缓存,多次 `Session()` 只查一次库。字段名与取值由业务自行约定。
|
||||||
|
|
||||||
**不带会话字段的边界:**
|
**不带会话字段的边界:**
|
||||||
|
|
||||||
@@ -55,23 +75,32 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
|
|||||||
|
|
||||||
## 控制台降噪(配了 seqUrl)
|
## 控制台降噪(配了 seqUrl)
|
||||||
|
|
||||||
|
| Logger | 控制台行为 |
|
||||||
|
|---|---|
|
||||||
|
| 业务日志(`that.Log`) | Info+(Info / Warn / Error,含 `Display` 非 0 的 Warn;Debug/SQL 不上控制台) |
|
||||||
|
| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) |
|
||||||
|
|
||||||
| 出口 | 行为 |
|
| 出口 | 行为 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 控制台 | 仅 Warn / Error(含 `Display` 非 0 的 Warn) |
|
|
||||||
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
|
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
|
||||||
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
| 本地文件 | 与原先一致,不受控制台过滤影响 |
|
||||||
|
|
||||||
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
|
||||||
|
|
||||||
|
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 客户端 IP 与地域
|
## 客户端 IP 与地域
|
||||||
|
|
||||||
零配置,优先级:
|
零配置。`ip` 选取优先级(跳过空值与回环 `127.0.0.1` / `::1`):
|
||||||
|
|
||||||
1. `X-Real-IP`(EdgeOne / 反代透传的真实 IP)
|
1. `EO-Connecting-IP`(EdgeOne 真实建连 IP)
|
||||||
2. `X-Forwarded-For` **第一段**
|
2. `X-Forwarded-For` **从左到右第一个非回环**(反代误把 CDN 写入 `X-Real-IP` 时仍能落到客户端)
|
||||||
3. `RemoteAddr`
|
3. `X-Real-IP`(非回环)
|
||||||
|
4. `RemoteAddr`
|
||||||
|
|
||||||
|
访问日志另附 `ip_chain`:上述源头出现过的 IP 按**首次出现**顺序逗号拼接,**同一 IP 不重复**;若与 `ip` 相同则省略该字段。
|
||||||
|
|
||||||
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
|
请求头有 `EO-Client-IPCountry` 时,访问日志追加 `ip_country`(两位国家码);没有则不记。
|
||||||
|
|
||||||
@@ -86,7 +115,7 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
|
|||||||
│ fmt.Println("...") ← 捕获后无 sid
|
│ fmt.Println("...") ← 捕获后无 sid
|
||||||
↓
|
↓
|
||||||
multiWriter(hotimev1.5/log/logger.go)
|
multiWriter(hotimev1.5/log/logger.go)
|
||||||
├─ Console(挂 Seq 后 Warn+)→ 终端
|
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
|
||||||
├─ FileWriter → 本地文件(按需,全量)
|
├─ FileWriter → 本地文件(按需,全量)
|
||||||
└─ SeqWriter → Seq(全量)
|
└─ SeqWriter → Seq(全量)
|
||||||
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
│ Write() 只做 channel <- bytes,O(1) 非阻塞
|
||||||
@@ -111,8 +140,11 @@ multiWriter(hotimev1.5/log/logger.go)
|
|||||||
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
|
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
|
||||||
| `caller` | `caller` | 调用位置 |
|
| `caller` | `caller` | 调用位置 |
|
||||||
| `sid` | `sid` | sessionId 前 12 位 |
|
| `sid` | `sid` | sessionId 前 12 位 |
|
||||||
| `request_id` | `request_id` | 单次请求 id |
|
| `request_id` | `request_id` | 单次请求 id(前端合法 UUID 沿用,否则服务端 12 位 hex) |
|
||||||
|
| `ip` | `ip` | 客户端最佳 IP |
|
||||||
|
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
|
||||||
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
|
||||||
|
| `ua` | `ua` | 访问日志携带,User-Agent 原文截断 200 字符(按 rune 安全截断) |
|
||||||
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
| 其余自定义字段 | 原字段名 | `LogBind` 追加的字段原样保留 |
|
||||||
| — | `instance` | `ip:port` |
|
| — | `instance` | `ip:port` |
|
||||||
| — | `source` | stdout / stderr / panic 等 |
|
| — | `source` | stdout / stderr / panic 等 |
|
||||||
@@ -124,7 +156,7 @@ multiWriter(hotimev1.5/log/logger.go)
|
|||||||
| 目标 | 查询语句 |
|
| 目标 | 查询语句 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 按会话追踪 | `sid = 'abc123def456'` |
|
| 按会话追踪 | `sid = 'abc123def456'` |
|
||||||
| 按单次请求 | `request_id = 'fedcba987654'` |
|
| 按单次请求(前后端同 id 串联) | `request_id = '6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c'`(服务端自生成时为 12 位 hex) |
|
||||||
| 关键词 | 直接输入,如 `支付失败` |
|
| 关键词 | 直接输入,如 `支付失败` |
|
||||||
| 日志级别 | `@l = 'Error'` |
|
| 日志级别 | `@l = 'Error'` |
|
||||||
| 特定实例 | `instance = '192.168.1.10:8085'` |
|
| 特定实例 | `instance = '192.168.1.10:8085'` |
|
||||||
@@ -132,6 +164,7 @@ multiWriter(hotimev1.5/log/logger.go)
|
|||||||
| stdout | `source = 'stdout'` |
|
| stdout | `source = 'stdout'` |
|
||||||
| panic | `source = 'panic'` |
|
| panic | `source = 'panic'` |
|
||||||
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
|
||||||
|
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,46 @@ var AdminDepartmentTest = ProjTest{
|
|||||||
return nil
|
return nil
|
||||||
}).
|
}).
|
||||||
Get("showall加showself含自身与子孙", 0, rowSample)
|
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)
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-6
@@ -209,6 +209,8 @@ func TestLevelFilterWriter_WarnOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSetSeqWriter_QuietsConsoleKeepsSeqFull 验证挂 Seq 后控制台放宽到 Info+:
|
||||||
|
// Debug 不上控制台,Info/Warn 均可见,Seq 侧全量收到(含 Debug)。
|
||||||
func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var gotBody string
|
var gotBody string
|
||||||
@@ -223,13 +225,14 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
|||||||
consoleBuf := &captureBuf{}
|
consoleBuf := &captureBuf{}
|
||||||
l.console.inner = consoleBuf
|
l.console.inner = consoleBuf
|
||||||
l.SetSeqWriter(srv.URL, "", "test:quiet")
|
l.SetSeqWriter(srv.URL, "", "test:quiet")
|
||||||
l.Info().Msg("info-to-seq-only")
|
l.Debug().Msg("debug-to-seq-only")
|
||||||
|
l.Info().Msg("info-both")
|
||||||
l.Warn().Msg("warn-both")
|
l.Warn().Msg("warn-both")
|
||||||
|
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
ok := strings.Contains(gotBody, "info-to-seq-only") && strings.Contains(gotBody, "warn-both")
|
ok := strings.Contains(gotBody, "debug-to-seq-only") && strings.Contains(gotBody, "info-both") && strings.Contains(gotBody, "warn-both")
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
if ok {
|
if ok {
|
||||||
break
|
break
|
||||||
@@ -239,22 +242,28 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
body := gotBody
|
body := gotBody
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
if !strings.Contains(body, "info-to-seq-only") || !strings.Contains(body, "warn-both") {
|
if !strings.Contains(body, "debug-to-seq-only") || !strings.Contains(body, "info-both") || !strings.Contains(body, "warn-both") {
|
||||||
t.Fatalf("Seq incomplete: %q", body)
|
t.Fatalf("Seq incomplete: %q", body)
|
||||||
}
|
}
|
||||||
consoleBuf.mu.Lock()
|
consoleBuf.mu.Lock()
|
||||||
defer consoleBuf.mu.Unlock()
|
defer consoleBuf.mu.Unlock()
|
||||||
for _, line := range consoleBuf.lines {
|
for _, line := range consoleBuf.lines {
|
||||||
if strings.Contains(line, "info-to-seq-only") {
|
if strings.Contains(line, "debug-to-seq-only") {
|
||||||
t.Fatalf("Info leaked to console: %v", consoleBuf.lines)
|
t.Fatalf("Debug leaked to console: %v", consoleBuf.lines)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foundWarn := false
|
foundInfo, foundWarn := false, false
|
||||||
for _, line := range consoleBuf.lines {
|
for _, line := range consoleBuf.lines {
|
||||||
|
if strings.Contains(line, "info-both") {
|
||||||
|
foundInfo = true
|
||||||
|
}
|
||||||
if strings.Contains(line, "warn-both") {
|
if strings.Contains(line, "warn-both") {
|
||||||
foundWarn = true
|
foundWarn = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !foundInfo {
|
||||||
|
t.Fatalf("Info missing on console: %v", consoleBuf.lines)
|
||||||
|
}
|
||||||
if !foundWarn {
|
if !foundWarn {
|
||||||
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
|
t.Fatalf("Warn missing on console: %v", consoleBuf.lines)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-2
@@ -826,8 +826,18 @@ func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
|
|||||||
l.seqsMu.Lock()
|
l.seqsMu.Lock()
|
||||||
l.seqs = append(l.seqs, sw)
|
l.seqs = append(l.seqs, sw)
|
||||||
l.seqsMu.Unlock()
|
l.seqsMu.Unlock()
|
||||||
// 挂上 Seq 后控制台只打 Warn+,Info/Debug/SQL/访问日志仍进 Seq 与文件
|
// 挂上 Seq 后控制台放宽到 Info+(Debug/SQL 不上控制台),仍全量进 Seq 与文件;
|
||||||
l.console.setMinLevel(zerolog.WarnLevel)
|
// 访问日志等高噪声 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 安全)
|
// CloseSeq 冲刷并关闭本 Logger 挂载的全部 SeqWriter(幂等、nil 安全)
|
||||||
@@ -880,6 +890,11 @@ func isInfrastructureFile(file string) bool {
|
|||||||
if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") {
|
if strings.HasSuffix(file, "/context.go") || strings.HasSuffix(file, "\\context.go") {
|
||||||
return true
|
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
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package hotime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "code.hoteas.com/golang/hotime/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeRequestId_Valid(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c", "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c"},
|
||||||
|
// 大写归一为小写
|
||||||
|
{"6F0A1B2C-3D4E-4F5A-8B6C-7D8E9F0A1B2C", "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := normalizeRequestId(c.in); got != c.want {
|
||||||
|
t.Fatalf("normalizeRequestId(%q)=%q want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeRequestId_Invalid(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
"abc",
|
||||||
|
// 12 位短 hex(服务端自生成格式,不接受为前端传入 id)
|
||||||
|
"a1b2c3d4e5f6",
|
||||||
|
// 长度对但连字符位置错
|
||||||
|
"6f0a1b2c3-d4e-4f5a-8b6c-7d8e9f0a1b2c",
|
||||||
|
// 非 hex 字符
|
||||||
|
"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1bzz",
|
||||||
|
// 注入尝试:换行/引号
|
||||||
|
"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1\n2c",
|
||||||
|
"\"};alert(1);//-4f5a-8b6c-7d8e9f0a1b2c",
|
||||||
|
// 超长
|
||||||
|
"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c0",
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := normalizeRequestId(c); got != "" {
|
||||||
|
t.Fatalf("normalizeRequestId(%q)=%q want empty", c, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 共用 request_id 行为:前端带合法 UUID 沿用,非法/缺失服务端自生成 12 位 hex
|
||||||
|
func TestRequestIdSharing(t *testing.T) {
|
||||||
|
// 合法 UUID → 沿用(归一小写)
|
||||||
|
if got := normalizeRequestId("6F0A1B2C-3D4E-4F5A-8B6C-7D8E9F0A1B2C"); got != "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c" {
|
||||||
|
t.Fatalf("合法 UUID 应沿用为 request_id,got %q", got)
|
||||||
|
}
|
||||||
|
// 非法 → 走服务端自生成分支
|
||||||
|
if normalizeRequestId("evil\ninjection") != "" {
|
||||||
|
t.Fatal("非法值应被丢弃并触发服务端自生成")
|
||||||
|
}
|
||||||
|
id := shortID(6)
|
||||||
|
if len(id) != 12 {
|
||||||
|
t.Fatalf("服务端自生成 request_id 应为 12 位 hex,got %q", id)
|
||||||
|
}
|
||||||
|
for i := 0; i < len(id); i++ {
|
||||||
|
c := id[i]
|
||||||
|
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||||
|
t.Fatalf("服务端自生成 request_id 含非 hex 字符: %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewSetsServerSentMsHeader(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx := &Context{Resp: rec, RespData: Map{"status": 0}}
|
||||||
|
|
||||||
|
before := time.Now().UnixMilli()
|
||||||
|
ctx.View()
|
||||||
|
after := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
h := rec.Header().Get("X-Server-Sent-Ms")
|
||||||
|
if h == "" {
|
||||||
|
t.Fatal("X-Server-Sent-Ms 未设置")
|
||||||
|
}
|
||||||
|
ms, err := strconv.ParseInt(h, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("X-Server-Sent-Ms=%q 非毫秒时间戳: %v", h, err)
|
||||||
|
}
|
||||||
|
if ms < before || ms > after {
|
||||||
|
t.Fatalf("X-Server-Sent-Ms=%d 不在 [%d,%d] 区间", ms, before, after)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "\"status\":0") {
|
||||||
|
t.Fatalf("响应体异常: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+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]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ var ConfigNote = Map{
|
|||||||
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
|
"logHistory": "默认100,非必须,内存中保留最近N条错误日志,用于调试调阅,通过 Log.GetRecentErrors() 获取",
|
||||||
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
|
"webConnectLogShow": "默认true,非必须,访问日志如果需要web访问链接、访问ip、访问时间打印,false为关闭true开启此功能",
|
||||||
"webConnectLogFile": "无默认,非必须,webConnectLogShow开启之后才能使用,如果需要存储日志文件时使用,保存格式为:a/b/c/20060102150405.txt,将生成:a/b/c/年月日时分秒.txt,按需设置",
|
"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;请求日志自动带 sid(sessionId 前12位脱敏)与 request_id(并回写 X-Request-Id),可用 that.LogBind 追加 user_id 等;访问日志优先取 X-Real-IP,有 EO-Client-IPCountry 时记 ip_country;配置后控制台自动仅打 Warn/Error,Seq 与文件仍按 logLevel 全量",
|
"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,单机免费版留空即可,多用户或有认证要求时填写",
|
"seqApiKey": "无默认,非必须,Seq API Key,单机免费版留空即可,多用户或有认证要求时填写",
|
||||||
//"codeConfig": Map{
|
//"codeConfig": Map{
|
||||||
// "注释": "配置即启用,非必须,默认无",
|
// "注释": "配置即启用,非必须,默认无",
|
||||||
|
|||||||
Reference in New Issue
Block a user