Compare commits

...

5 Commits

Author SHA1 Message Date
hoteas 3e08b20698 fix(makecode): 达梦列注释按 OWNER 对齐,并兼容选项解析边界
同时修复新增默认 state、导出 sheet 名长度与目录权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 05:36:37 +08:00
hoteas c8382200a0 docs: Seq LogBind 示例去掉业务项目细节,保持框架通用口径
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:52:35 +08:00
hoteas b951ba027e fix(log): caller 过滤跳过框架 session.go,SQL 日志归因直达业务帧
session 存取中间层不是业务发起点,此前 session 读写触发的 SQL 日志
caller 落在 session.go:29/136,业务帧被挤到 "<-" 之后,Seq 归因噪音大。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:44:59 +08:00
hoteas a0719dc72d docs: LogBind 示例更新为入口统一绑定口径
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:40:34 +08:00
hoteas 332c2414bc feat(log): 访问日志携带 ua,挂 Seq 后控制台放宽到 Info+
- WebConnectLog 访问日志新增 ua 字段(User-Agent 按 rune 截断 200 字符),便于按设备归因
- SetSeqWriter 挂 Seq 后业务控制台由 Warn+ 放宽为 Info+,Debug/SQL 仍只进 Seq 与文件
- 新增 Logger.SetConsoleMinLevel,访问日志控制台单独保持 Warn+

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 01:35:45 +08:00
9 changed files with 279 additions and 33 deletions
+14
View File
@@ -199,6 +199,15 @@ func clientIP(req *http.Request) string {
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 字节随机 hexn=6 → 12 位)
func shortID(n int) string {
b := make([]byte, n)
@@ -466,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)
}
@@ -588,6 +599,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
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)
}
}()
+25 -10
View File
@@ -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
+29 -5
View File
@@ -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])})
}
}
}
+20 -10
View File
@@ -26,7 +26,7 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
- 生成 `request_id`12 位 hex),回写响应头 `X-Request-Id`
-`sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
- **控制台自动降噪为 Warn+**Info/Debug/SQL/访问日志进 Seq 与文件,零额外配置)
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志Info 级)仍全量进 Seq 与文件
---
@@ -34,17 +34,19 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
业务在 `SetConnectListener` 鉴权通过后绑定(xbc `main.go`):
业务在 `SetConnectListener` 中按需绑定自定义字段(建议放在鉴权/守卫之前,这样未登录被拒的 Warning 也能带上维度字段):
```go
// app 鉴权通过后
context.LogBind("user_id", context.Session("user_id").ToCeilInt64())
// admin 鉴权通过后(或 session 已有 admin_id
context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
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 日志都会带上该字段。
`LogBind` 后,本请求后续业务日志与 SQL 日志都会带上该字段。session 在 context 内有缓存,多次 `Session()` 只查一次库。字段名与取值由业务自行约定。
**不带会话字段的边界:**
@@ -55,14 +57,20 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
## 控制台降噪(配了 seqUrl)
| Logger | 控制台行为 |
|---|---|
| 业务日志(`that.Log` | Info+Info / Warn / Error,含 `Display` 非 0 的 WarnDebug/SQL 不上控制台) |
| 访问日志(`that.WebConnectLog`) | Warn+(访问日志量大,Info 级不上控制台) |
| 出口 | 行为 |
|---|---|
| 控制台 | 仅 Warn / Error(含 `Display` 非 0 的 Warn |
| Seq | 按 `logLevel` 全量(Info/Debug/SQL/访问日志等) |
| 本地文件 | 与原先一致,不受控制台过滤影响 |
未配置 `seqUrl` 时控制台仍按 `logLevel` 全打。
框架不新增配置项;如需自定义某个 Logger 的控制台门槛,可在挂 Seq 后调用 `Logger.SetConsoleMinLevel(level zerolog.Level)`(仅影响该 Logger 的控制台出口,不影响 Seq/文件)。
---
## 客户端 IP 与地域
@@ -89,7 +97,7 @@ context.LogBind("admin_id", context.Session("admin_id").ToCeilInt64())
│ fmt.Println("...") ← 捕获后无 sid
multiWriterhotimev1.5/log/logger.go
├─ Console(挂 Seq 后 Warn+)→ 终端
├─ Console(挂 Seq 后:业务 Info+ / 访问日志 Warn+)→ 终端
├─ FileWriter → 本地文件(按需,全量)
└─ SeqWriter → Seq(全量)
│ Write() 只做 channel <- bytesO(1) 非阻塞
@@ -118,6 +126,7 @@ multiWriterhotimev1.5/log/logger.go
| `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,6 +146,7 @@ multiWriterhotimev1.5/log/logger.go
| stdout | `source = 'stdout'` |
| panic | `source = 'panic'` |
| 组合 | `@l = 'Error' and sid = 'abc123def456'` |
| 关键字组合其他条件 | `@Message like '%支付失败%' and @l = 'Warning' and instance = '192.168.1.10:8085'` |
---
+31
View File
@@ -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
View File
@@ -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) {
var mu sync.Mutex
var gotBody string
@@ -223,13 +225,14 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
consoleBuf := &captureBuf{}
l.console.inner = consoleBuf
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")
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
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()
if ok {
break
@@ -239,22 +242,28 @@ func TestSetSeqWriter_QuietsConsoleKeepsSeqFull(t *testing.T) {
mu.Lock()
body := gotBody
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)
}
consoleBuf.mu.Lock()
defer consoleBuf.mu.Unlock()
for _, line := range consoleBuf.lines {
if strings.Contains(line, "info-to-seq-only") {
t.Fatalf("Info leaked to console: %v", consoleBuf.lines)
if strings.Contains(line, "debug-to-seq-only") {
t.Fatalf("Debug leaked to console: %v", consoleBuf.lines)
}
}
foundWarn := false
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)
}
+87
View File
@@ -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
View File
@@ -826,8 +826,18 @@ func (l *Logger) SetSeqWriter(seqUrl, apiKey, instance string) {
l.seqsMu.Lock()
l.seqs = append(l.seqs, sw)
l.seqsMu.Unlock()
// 挂上 Seq 后控制台只打 Warn+Info/Debug/SQL/访问日志仍进 Seq 与文件
l.console.setMinLevel(zerolog.WarnLevel)
// 挂上 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 安全)
@@ -880,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
+41
View File
@@ -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]))
}
}