Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e08b20698 | |||
| c8382200a0 | |||
| b951ba027e | |||
| a0719dc72d | |||
| 332c2414bc | |||
| 853940c795 | |||
| b5b764e3f9 | |||
| 25faa93724 | |||
| 831a7f017a | |||
| b254606003 | |||
| 412f08a048 | |||
| ec9d25fef6 | |||
| a600fa8a15 |
+151
-23
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -781,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 主库连接失败")
|
||||
@@ -790,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -487,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 回调时,不会执行响应值断言和数据库状态校验。适合纯查询、无副作用的接口。
|
||||
|
||||
@@ -32,6 +32,11 @@ func TestMain(m *testing.M) {
|
||||
Proj: Project,
|
||||
Tests: ProjectTest,
|
||||
},
|
||||
// 通用 CRUD(codeConfig=admin)路由由 Init 时按库表生成,这里仅挂测试
|
||||
"admin": {
|
||||
Proj: Proj{},
|
||||
Tests: AdminDepartmentTest,
|
||||
},
|
||||
}).
|
||||
Flows(DemoFlows)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-1
@@ -189,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
|
||||
@@ -221,6 +228,7 @@ type ApiCase struct {
|
||||
note string
|
||||
verifyFn func(a *Api) error
|
||||
bindCase string // 绑定的单接口验收用例名(FromCase)
|
||||
skipMsg bool // AnyMsg:跳过错误 msg 断言(用于网络错误等动态消息)
|
||||
}
|
||||
|
||||
// FromCase 在已有 ApiCase 上绑定验收用例名(若尚未加载模板则加载)
|
||||
@@ -300,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 {
|
||||
@@ -343,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
|
||||
}
|
||||
|
||||
|
||||
+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() 获取",
|
||||
"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