Compare commits

...

3 Commits

Author SHA1 Message Date
hoteas 2cd5d64818 feat(log): 设备级 client_id 追踪,X-Client-Id 头校验后绑定请求级与访问日志
- 与 request_id 同一 UUID 白名单校验,非法/缺失丢弃不自生成
- CORS Allow-Headers 增加 X-Client-Id

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 08:13:01 +08:00
hoteas d3e71decf4 feat(log): 前后端共用 request_id 与四时间点时钟校准
- X-Request-Id 请求头带合法 UUID 则沿用为 request_id(严格格式白名单防注入),否则自生成 12 位 hex
- 响应头新增 X-Server-Received-Ms / X-Server-Sent-Ms 供前端时钟校准
- CORS Allow-Headers 增加 X-Request-Id,Expose-Headers 显式列出(credentials 下不认 *)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:46:38 +08:00
hoteas e22df36d3f chore(rules): 工作流门禁措辞对齐,改为按需加载
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 07:46:38 +08:00
5 changed files with 205 additions and 20 deletions
+24 -11
View File
@@ -1,32 +1,45 @@
--- ---
description: HoTime Doc-Driven + TDD 门禁 description: HoTime Doc-Driven + TDD 门禁
alwaysApply: true alwaysApply: false
--- ---
# HoTime 工作流 # HoTime 工作流
**不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。 **不可跳过。** Doc-Driven 与 TDD 缺一不可。日常五步:①定位 → ②修改 → ③补/改行为测试并针对性跑至绿 → ④跨模块边界 → ⑤回写文档。未测绿或未回写 = **未完成**。中文回复。
## 定位 ## 定位
- 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增/修改的**测试文件计入锁定**。先读相关 docs 再改。口径不清 **先问**。 - 先锁约 **3~4 个文件**(文档 1~2 + 源码 1~2);跨模块扩到合计约 **4~6**,禁止散改全仓。本次新增修改的**测试文件计入锁定范围**。先读流程与规则边界再改。口径不清 **先问**。
- 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。 - 入口:`docs/README.md`、仓根 `README.md`;测试细则 → `docs/Testing_API测试框架.md` / skill `hotime-tdd-testing`。
- 配置 / 部署 / 发版 / 密钥 / 脚本等运维操作:**先查仓内相关 docs**(计入锁定)定可改边界与回滚点,未读禁动手;偏离文档先说明并回写。
## 改与测 ## 改与测
- 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。 - 改接口、ctr、业务逻辑或测试框架:对应 `*_test` **无则补、有则改**用例,并跑通至绿;**禁**只改实现不补测;**禁全量**;编译不算测完。
- 样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到子用例;**改哪测哪**。用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条 - 单接口样板(`example` 根):`go test ./app/ -count=1 -run 'TestApi/app/<ctr>/<action>'`。可收窄到 1~N 个相关子用例(更长 `-run`;名以 `*_test.go` / `go test -list` 为准);**改哪测哪**
- 用户要求全量或以编译代替行为测时,**明确拒绝**并仍按本条执行。
## 文档 ## 文档
- **有旧改旧**;无篇目且确有缺口才新建禁不查就建、禁同一入口重复建档旧文只补受影响节;只写最终态;对照源码,禁臆测 - **有旧改旧**;无篇目且确有缺口才新建禁不查就建、禁同一入口重复建档旧文日常只补受影响节。
- 路径用相对 Markdown 链接;**禁** `D:\`、`file:///`、`~/`。新建须回写 `docs/README.md` - 业务/工程文写**流程与口径、规则边界、职责与影响、怎么测**,字段按需、必要处配 mermaid;**禁** UI 皮相与实现转法(for/if、组包细节堆砌);对照源码是核验不是抄写。只写最终态、禁臆测
- **触改即升级**:回写正文时该篇明显不合规 → 先通读全文并核验源码/测试/必要上下游,**整篇重写**为该入口完整最终态;**禁**删成只剩本次内容、禁借机扫仓瘦身。
- 路径用相对 Markdown 链接;**禁**绝对路径、`file:///`、`~/`。archive / 审计目录 **不作**依据。新建须回写 `docs/README.md`。
## Git 合并冲突
- 解冲突时:**禁止**用 `ours` / `theirs` 或「以一侧为准」整文件覆盖,从而丢掉另一侧有效改动。
- **尽量保存两边代码**:对照两侧 diff,把两边意图合进同一最终文件。
- **业务冲突由 AI 合成**:读懂两边改动后产出可运行结果;仅当同一字段互斥才给出合成结论,并在回报里说明取舍理由。
- modify/delete:先判断删除侧是否为有意清理、修改侧是否仍有业务价值;能保留则保留或迁到合理路径,禁不读 diff 就整侧丢弃。
- **Swagger 例外(硬规则)**`tpt/swagger/`、`example/tpt/swagger/` 等为测试框架生成物,**永不**纳入版本管理、**永不**为 swagger 解冲突或花精力合并;遇相关冲突直接按「移出跟踪 + 忽略」处理。
## 回复 ## 回复
- 先结论;摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。 - 先结论;对话摘要 + 文档路径 + **改动定位(文件·位置·原因)**;禁大段复述 docs。
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求 - 排障四层:正常行为 → 断点 → 故障点 → 修正与复验
- 凡改代码或报完成:末尾必须一行 `【门禁】锁定:… | 补测并跑绿:是/否 | 文档回写:是/否/无 | 新建or旧文:…`;缺一不可。闲聊不要求。勾选空洞/矛盾/丢格式 → 宜新开窗口。
## 批量补文档(仅整包) ## 批量补文档(仅整包)
一文一题;最少 2 轮自检。日常小改不适用。 一文一题;按分型写完整,密度自检以「无 UI 皮相 / 无实现复述」为准;最少 2 轮自检。日常小改不适用。
+47 -6
View File
@@ -208,6 +208,30 @@ func truncateUA(s string, max int) string {
return string(r[:max]) return string(r[:max])
} }
// normalizeRequestId 校验前端传入的 X-Request-Id。
// 仅接受标准 UUID 形态(36 位,8-4-4-4-12,hex+连字符),统一转小写;
// 其余(超长、控制符、任意注入串)一律丢弃返回空,保证日志字段安全且低基数可控。
func normalizeRequestId(v string) string {
if len(v) != 36 {
return ""
}
for i := 0; i < 36; i++ {
c := v[i]
switch i {
case 8, 13, 18, 23:
if c != '-' {
return ""
}
default:
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
if !isHex {
return ""
}
}
}
return strings.ToLower(v)
}
// shortID 生成 n 字节随机 hexn=6 → 12 位) // shortID 生成 n 字节随机 hexn=6 → 12 位)
func shortID(n int) string { func shortID(n int) string {
b := make([]byte, n) b := make([]byte, n)
@@ -557,14 +581,26 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
unescapeUrl = req.RequestURI unescapeUrl = req.RequestURI
} }
// 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头 // 请求级追踪:sid=sessionId 前 12 位(脱敏),request_id 回写响应头
requestId := shortID(6) // 前后端共用一个 request_id:前端带合法 X-Request-Id(UUID)则沿用,非法/缺失才服务端自生成,
// 严格格式校验杜绝日志注入。
requestId := normalizeRequestId(req.Header.Get("X-Request-Id"))
if requestId == "" {
requestId = shortID(6)
}
// 设备级追踪:前端持久 UUID 经 X-Client-Id 传入(同一校验口径),合法才绑 client_id
clientId := normalizeRequestId(req.Header.Get("X-Client-Id"))
sid := sessionId sid := sessionId
if len(sid) > 12 { if len(sid) > 12 {
sid = sid[:12] sid = sid[:12]
} }
w.Header().Set("X-Request-Id", requestId) w.Header().Set("X-Request-Id", requestId)
// 服务端接收时间(ms),与 View() 写出的 X-Server-Sent-Ms 组成四时间点时钟校准
w.Header().Set("X-Server-Received-Ms", strconv.FormatInt(nowUnixTime.UnixMilli(), 10))
reqLog := that.Log.WithFields("sid", sid, "request_id", requestId) reqLog := that.Log.WithFields("sid", sid, "request_id", requestId)
if clientId != "" {
reqLog = that.Log.WithFields("sid", sid, "request_id", requestId, "client_id", clientId)
}
dbCopy := that.Db dbCopy := that.Db
dbCopy.Log = reqLog dbCopy.Log = reqLog
@@ -593,6 +629,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) {
Str("request_id", requestId). Str("request_id", requestId).
Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00). Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00).
Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00) Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00)
if clientId != "" {
evt = evt.Str("client_id", clientId)
}
if ipChain != "" && ipChain != ip { if ipChain != "" && ipChain != ip {
evt = evt.Str("ip_chain", ipChain) evt = evt.Str("ip_chain", ipChain)
} }
@@ -716,8 +755,9 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
//header.Set("Access-Control-Allow-Origin", "*") //header.Set("Access-Control-Allow-Origin", "*")
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE") header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
header.Set("Access-Control-Allow-Credentials", "true") header.Set("Access-Control-Allow-Credentials", "true")
header.Set("Access-Control-Expose-Headers", "*") // credentials 模式下浏览器不认 "*",必须显式列出可读响应头
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie") header.Set("Access-Control-Expose-Headers", "X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie,X-Request-Id,X-Client-Id")
if sessionId != "" { if sessionId != "" {
//跨域允许需要设置cookie的允许跨域https才有效果 //跨域允许需要设置cookie的允许跨域https才有效果
@@ -763,8 +803,9 @@ func (that *Application) crossDomain(context *Context, sessionId string) {
header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE") header.Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE")
header.Set("Access-Control-Allow-Credentials", "true") header.Set("Access-Control-Allow-Credentials", "true")
header.Set("Access-Control-Expose-Headers", "*") // credentials 模式下浏览器不认 "*",必须显式列出可读响应头
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie") header.Set("Access-Control-Expose-Headers", "X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms")
header.Set("Access-Control-Allow-Headers", "X-Requested-With,Content-Type,Access-Token,Authorization,Cookie,Set-Cookie,X-Request-Id,X-Client-Id")
if sessionId != "" { if sessionId != "" {
//跨域允许需要设置cookie的允许跨域https才有效果 //跨域允许需要设置cookie的允许跨域https才有效果
+4
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
@@ -127,6 +128,9 @@ func (that *Context) View() {
} }
that.DataSize = len(d) that.DataSize = len(d)
that.RespData = nil that.RespData = nil
// 服务端发送时间(ms):与 X-Server-Received-Ms 组成四时间点,供前端时钟校准;
// 必须在首次 Write 之前设置,否则 header 已锁定
that.Resp.Header().Set("X-Server-Sent-Ms", strconv.FormatInt(time.Now().UnixMilli(), 10))
that.Resp.Write(d) that.Resp.Write(d)
} }
+25 -3
View File
@@ -23,13 +23,33 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq
激活后每个 HTTP 请求会自动: 激活后每个 HTTP 请求会自动:
- 生成 `request_id`12 位 hex),回写响应头 `X-Request-Id` - 确定 `request_id`前端带合法 UUID 则沿用,否则自生成 12 位 hex),回写响应头 `X-Request-Id`
-`sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq) -`sessionId` 前 12 位作为 `sid` 写入请求级日志(脱敏,避免把登录凭据写进 Seq)
- 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id` - 业务日志、SQL 日志、访问日志均携带 `sid` / `request_id`
- **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件 - **控制台自动降噪**(零额外配置):业务日志(`that.Log`)控制台放宽到 **Info+**(Debug/SQL 不上控制台),访问日志(`that.WebConnectLog`)控制台保持 **Warn+**(量大不上控制台);Debug/SQL/访问日志(Info 级)仍全量进 Seq 与文件
--- ---
## 请求追踪与前端串联(共用 request_id + 设备级 client_id
前后端**共用一个 `request_id`**:前端为每次请求生成 UUID 并经 `X-Request-Id` 请求头带上,服务端严格校验(36 位 8-4-4-4-12 hex+连字符,统一小写)通过则直接沿用为本请求 `request_id`;非法或缺失(含浏览器直接访问、老前端)则服务端自生成 12 位 hex。最终值回写响应头 `X-Request-Id`,业务/SQL/访问日志统一携带——Seq 里一条 `request_id = '<uuid>'` 即可同时串出前端事件与后端全链路日志。
设备级串联走 `client_id`:前端持久化 UUIDlocalStorage 存一次用终身)经 `X-Client-Id` 请求头传入,**同一 UUID 白名单校验**,合法才追加绑定到请求级日志与访问日志(非法/缺失直接丢弃,服务端不自生成)。`client_id = '<uuid>'` 一次查出该设备的前端行为事件与后端全部请求日志。
- 严格格式白名单杜绝日志注入(换行/引号/超长一律丢弃)
- 前端伪造/重复 UUID 只污染其自身请求的串联,不影响其他请求;接受该风险换取字段简洁
同时每个请求回写**服务端收发时间**响应头,供前端做四时间点时钟校准(前端日志的 `@t` 对齐服务器时间轴):
- `X-Server-Received-Ms`handler 入口时间(ms
- `X-Server-Sent-Ms`:响应体写出前时间(ms`context.View()` 设置;静态文件不带)
CORS 已相应放行/暴露:`Access-Control-Allow-Headers` 增加 `X-Request-Id,X-Client-Id``Access-Control-Expose-Headers``*` 改为显式 `X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms`credentials 模式下浏览器不认 `*`)。
**怎么测**`request_trace_test.go``go test . -count=1 -run 'TestNormalizeRequestId|TestRequestIdSharing|TestClientIdHeaderNormalize|TestViewSetsServerSentMsHeader'`
---
## 会话追踪与 LogBind ## 会话追踪与 LogBind
框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。 框架在 `handler` 入口派生请求级 Logger,并浅拷贝 `Db` 将其 `Log` 指向同一 Logger,因此 **SQL 日志自动带会话字段**。字段会出现在同条日志的控制台/文件/Seq 出口上。
@@ -122,7 +142,8 @@ multiWriterhotimev1.5/log/logger.go
| `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) | | `message` / `msg` | `@m` | 消息正文(不用 `@mt`,避免 `{xxx}` 被当模板) |
| `caller` | `caller` | 调用位置 | | `caller` | `caller` | 调用位置 |
| `sid` | `sid` | sessionId 前 12 位 | | `sid` | `sid` | sessionId 前 12 位 |
| `request_id` | `request_id` | 单次请求 id | | `request_id` | `request_id` | 单次请求 id(前端合法 UUID 沿用,否则服务端 12 位 hex) |
| `client_id` | `client_id` | 设备级持久 UUID`X-Client-Id` 传入且校验通过时才有) |
| `ip` | `ip` | 客户端最佳 IP | | `ip` | `ip` | 客户端最佳 IP |
| `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) | | `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) |
| `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 | | `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 |
@@ -138,7 +159,8 @@ multiWriterhotimev1.5/log/logger.go
| 目标 | 查询语句 | | 目标 | 查询语句 |
|---|---| |---|---|
| 按会话追踪 | `sid = 'abc123def456'` | | 按会话追踪 | `sid = 'abc123def456'` |
| 按单次请求 | `request_id = 'fedcba987654'` | | 按单次请求(前后端同 id 串联) | `request_id = '6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c'`(服务端自生成时为 12 位 hex |
| 按设备追踪(前后端同 id 串联) | `client_id = '6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c'` |
| 关键词 | 直接输入,如 `支付失败` | | 关键词 | 直接输入,如 `支付失败` |
| 日志级别 | `@l = 'Error'` | | 日志级别 | `@l = 'Error'` |
| 特定实例 | `instance = '192.168.1.10:8085'` | | 特定实例 | `instance = '192.168.1.10:8085'` |
+105
View File
@@ -0,0 +1,105 @@
package hotime
import (
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
. "code.hoteas.com/golang/hotime/common"
)
func TestNormalizeRequestId_Valid(t *testing.T) {
cases := []struct{ in, want string }{
{"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c", "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c"},
// 大写归一为小写
{"6F0A1B2C-3D4E-4F5A-8B6C-7D8E9F0A1B2C", "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c"},
}
for _, c := range cases {
if got := normalizeRequestId(c.in); got != c.want {
t.Fatalf("normalizeRequestId(%q)=%q want %q", c.in, got, c.want)
}
}
}
func TestNormalizeRequestId_Invalid(t *testing.T) {
cases := []string{
"",
"abc",
// 12 位短 hex(服务端自生成格式,不接受为前端传入 id)
"a1b2c3d4e5f6",
// 长度对但连字符位置错
"6f0a1b2c3-d4e-4f5a-8b6c-7d8e9f0a1b2c",
// 非 hex 字符
"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1bzz",
// 注入尝试:换行/引号
"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1\n2c",
"\"};alert(1);//-4f5a-8b6c-7d8e9f0a1b2c",
// 超长
"6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c0",
}
for _, c := range cases {
if got := normalizeRequestId(c); got != "" {
t.Fatalf("normalizeRequestId(%q)=%q want empty", c, got)
}
}
}
// 共用 request_id 行为:前端带合法 UUID 沿用,非法/缺失服务端自生成 12 位 hex
func TestRequestIdSharing(t *testing.T) {
// 合法 UUID → 沿用(归一小写)
if got := normalizeRequestId("6F0A1B2C-3D4E-4F5A-8B6C-7D8E9F0A1B2C"); got != "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c" {
t.Fatalf("合法 UUID 应沿用为 request_idgot %q", got)
}
// 非法 → 走服务端自生成分支
if normalizeRequestId("evil\ninjection") != "" {
t.Fatal("非法值应被丢弃并触发服务端自生成")
}
id := shortID(6)
if len(id) != 12 {
t.Fatalf("服务端自生成 request_id 应为 12 位 hexgot %q", id)
}
for i := 0; i < len(id); i++ {
c := id[i]
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Fatalf("服务端自生成 request_id 含非 hex 字符: %q", id)
}
}
}
// 设备级 client_id:与 request_id 同一 UUID 白名单口径——合法归一小写后绑定,非法/缺失丢弃(不自生成)
func TestClientIdHeaderNormalize(t *testing.T) {
if got := normalizeRequestId("6F0A1B2C-3D4E-4F5A-8B6C-7D8E9F0A1B2C"); got != "6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c" {
t.Fatalf("合法 X-Client-Id 应归一沿用,got %q", got)
}
for _, bad := range []string{"", "device-001", "a1b2c3d4e5f6", "evil\ninjection"} {
if normalizeRequestId(bad) != "" {
t.Fatalf("非法 X-Client-Id %q 应被丢弃", bad)
}
}
}
func TestViewSetsServerSentMsHeader(t *testing.T) {
rec := httptest.NewRecorder()
ctx := &Context{Resp: rec, RespData: Map{"status": 0}}
before := time.Now().UnixMilli()
ctx.View()
after := time.Now().UnixMilli()
h := rec.Header().Get("X-Server-Sent-Ms")
if h == "" {
t.Fatal("X-Server-Sent-Ms 未设置")
}
ms, err := strconv.ParseInt(h, 10, 64)
if err != nil {
t.Fatalf("X-Server-Sent-Ms=%q 非毫秒时间戳: %v", h, err)
}
if ms < before || ms > after {
t.Fatalf("X-Server-Sent-Ms=%d 不在 [%d,%d] 区间", ms, before, after)
}
if !strings.Contains(rec.Body.String(), "\"status\":0") {
t.Fatalf("响应体异常: %s", rec.Body.String())
}
}