From 2cd5d64818de45dc15cba2c33d5e4653375e7bd5 Mon Sep 17 00:00:00 2001 From: hoteas <925970985@qq.com> Date: Mon, 27 Jul 2026 08:13:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(log):=20=E8=AE=BE=E5=A4=87=E7=BA=A7=20clie?= =?UTF-8?q?nt=5Fid=20=E8=BF=BD=E8=B8=AA=EF=BC=8CX-Client-Id=20=E5=A4=B4?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=90=8E=E7=BB=91=E5=AE=9A=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E7=BA=A7=E4=B8=8E=E8=AE=BF=E9=97=AE=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 与 request_id 同一 UUID 白名单校验,非法/缺失丢弃不自生成 - CORS Allow-Headers 增加 X-Client-Id Co-authored-by: Cursor --- application.go | 12 ++++++++++-- docs/Seq_日志集成.md | 14 +++++++++----- request_trace_test.go | 12 ++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/application.go b/application.go index 9afbc96..680878a 100644 --- a/application.go +++ b/application.go @@ -588,6 +588,8 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) { if requestId == "" { requestId = shortID(6) } + // 设备级追踪:前端持久 UUID 经 X-Client-Id 传入(同一校验口径),合法才绑 client_id + clientId := normalizeRequestId(req.Header.Get("X-Client-Id")) sid := sessionId if len(sid) > 12 { sid = sid[:12] @@ -596,6 +598,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) { // 服务端接收时间(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) + if clientId != "" { + reqLog = that.Log.WithFields("sid", sid, "request_id", requestId, "client_id", clientId) + } dbCopy := that.Db dbCopy.Log = reqLog @@ -624,6 +629,9 @@ func (that *Application) handler(w http.ResponseWriter, req *http.Request) { Str("request_id", requestId). Float64("cost_ms", ObjToFloat64(time.Now().UnixNano()-nowUnixTime.UnixNano())/1000000.00). Float64("size_kb", ObjToFloat64(context.DataSize)/1000.00) + if clientId != "" { + evt = evt.Str("client_id", clientId) + } if ipChain != "" && ipChain != ip { evt = evt.Str("ip_chain", ipChain) } @@ -749,7 +757,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) { header.Set("Access-Control-Allow-Credentials", "true") // credentials 模式下浏览器不认 "*",必须显式列出可读响应头 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") + 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 != "" { //跨域允许需要设置cookie的允许跨域https才有效果 @@ -797,7 +805,7 @@ func (that *Application) crossDomain(context *Context, sessionId string) { header.Set("Access-Control-Allow-Credentials", "true") // credentials 模式下浏览器不认 "*",必须显式列出可读响应头 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") + 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 != "" { //跨域允许需要设置cookie的允许跨域https才有效果 diff --git a/docs/Seq_日志集成.md b/docs/Seq_日志集成.md index cd0e59a..8597863 100644 --- a/docs/Seq_日志集成.md +++ b/docs/Seq_日志集成.md @@ -30,21 +30,23 @@ HoTime 框架内置 Seq 日志推送支持。通过在 `config.json` 填写 `seq --- -## 请求追踪与前端串联(共用 request_id) +## 请求追踪与前端串联(共用 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`:前端持久化 UUID(localStorage 存一次用终身)经 `X-Client-Id` 请求头传入,**同一 UUID 白名单校验**,合法才追加绑定到请求级日志与访问日志(非法/缺失直接丢弃,服务端不自生成)。`client_id = ''` 一次查出该设备的前端行为事件与后端全部请求日志。 + +- 严格格式白名单杜绝日志注入(换行/引号/超长一律丢弃) +- 前端伪造/重复 UUID 只污染其自身请求的串联,不影响其他请求;接受该风险换取字段简洁 同时每个请求回写**服务端收发时间**响应头,供前端做四时间点时钟校准(前端日志的 `@t` 对齐服务器时间轴): - `X-Server-Received-Ms`:handler 入口时间(ms) - `X-Server-Sent-Ms`:响应体写出前时间(ms,`context.View()` 设置;静态文件不带) -CORS 已相应放行/暴露:`Access-Control-Allow-Headers` 增加 `X-Request-Id`;`Access-Control-Expose-Headers` 由 `*` 改为显式 `X-Request-Id,X-Server-Received-Ms,X-Server-Sent-Ms`(credentials 模式下浏览器不认 `*`)。 +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|TestViewSetsServerSentMsHeader'`。 +**怎么测**:`request_trace_test.go` — `go test . -count=1 -run 'TestNormalizeRequestId|TestRequestIdSharing|TestClientIdHeaderNormalize|TestViewSetsServerSentMsHeader'`。 --- @@ -141,6 +143,7 @@ multiWriter(hotimev1.5/log/logger.go) | `caller` | `caller` | 调用位置 | | `sid` | `sid` | sessionId 前 12 位 | | `request_id` | `request_id` | 单次请求 id(前端合法 UUID 沿用,否则服务端 12 位 hex) | +| `client_id` | `client_id` | 设备级持久 UUID(`X-Client-Id` 传入且校验通过时才有) | | `ip` | `ip` | 客户端最佳 IP | | `ip_chain` | `ip_chain` | 多源去重链路(与 `ip` 不同时才有) | | `ip_country` | `ip_country` | 有 `EO-Client-IPCountry` 时 | @@ -157,6 +160,7 @@ multiWriter(hotimev1.5/log/logger.go) |---|---| | 按会话追踪 | `sid = 'abc123def456'` | | 按单次请求(前后端同 id 串联) | `request_id = '6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c'`(服务端自生成时为 12 位 hex) | +| 按设备追踪(前后端同 id 串联) | `client_id = '6f0a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c'` | | 关键词 | 直接输入,如 `支付失败` | | 日志级别 | `@l = 'Error'` | | 特定实例 | `instance = '192.168.1.10:8085'` | diff --git a/request_trace_test.go b/request_trace_test.go index f9a0009..5ff0391 100644 --- a/request_trace_test.go +++ b/request_trace_test.go @@ -68,6 +68,18 @@ func TestRequestIdSharing(t *testing.T) { } } +// 设备级 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}}