- Device-Id/Session-Id 仅当请求头未携带时才写入应答头 - 静态文件和 WebSocket 升级应答仅通过 Cookie 维护身份 - Client App 头改为 App-Name/App-Version(破折号命名) - NoLogHeaders → NoLogRequestHeaders,NoLogOutputFields → NoLogResponseFields,新增 NoLogResponseHeaders - 默认排除列表动态构建,用户只需追加自定义字段 - Cookie 头智能过滤:不再整体排除,仅剔除匹配排除列表的 key Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
243 lines
5.5 KiB
Go
243 lines
5.5 KiB
Go
package service
|
||
|
||
import (
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
|
||
"apigo.cc/go/cast"
|
||
)
|
||
|
||
// sanitizeOpts 日志脱敏配置
|
||
type sanitizeOpts struct {
|
||
maxSize int // 整体尺寸上限(内容字符估算)
|
||
fieldSize int // 单字符串截断长度
|
||
arrayNum int // 数组最多保留元素数
|
||
objectNum int // 对象最多保留 key 数
|
||
}
|
||
|
||
// sanitizeLogData 递归脱敏,返回新建的对象,不影响原始数据
|
||
func sanitizeLogData(v any, opts sanitizeOpts) any {
|
||
budget := opts.maxSize
|
||
return sanitizeRecursive(v, opts, &budget)
|
||
}
|
||
|
||
func sanitizeRecursive(v any, opts sanitizeOpts, budget *int) any {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
|
||
switch val := v.(type) {
|
||
case string:
|
||
return sanitizeString(val, opts, budget)
|
||
case bool:
|
||
return sanitizeScalar(val, 5, budget)
|
||
case float64:
|
||
return sanitizeScalar(val, 8, budget)
|
||
case map[string]any:
|
||
return sanitizeMapContent(val, opts, budget)
|
||
case []any:
|
||
return sanitizeSliceContent(val, opts, budget)
|
||
default:
|
||
// int 等各种数值类型
|
||
return sanitizeScalar(val, 8, budget)
|
||
}
|
||
}
|
||
|
||
func sanitizeString(s string, opts sanitizeOpts, budget *int) string {
|
||
if len([]rune(s)) > opts.fieldSize {
|
||
s = string([]rune(s)[:opts.fieldSize])
|
||
}
|
||
if *budget < len(s) {
|
||
*budget = 0
|
||
return s // 即使超出预算也返回截断后的内容
|
||
}
|
||
*budget -= len(s)
|
||
return s
|
||
}
|
||
|
||
func sanitizeScalar(v any, cost int, budget *int) any {
|
||
if *budget < cost {
|
||
*budget = 0
|
||
return nil
|
||
}
|
||
*budget -= cost
|
||
return v
|
||
}
|
||
|
||
func sanitizeMapContent(m map[string]any, opts sanitizeOpts, budget *int) map[string]any {
|
||
// 空 map 占 2 预算
|
||
if *budget < 2 {
|
||
*budget = 0
|
||
return map[string]any{}
|
||
}
|
||
*budget -= 2
|
||
|
||
result := make(map[string]any)
|
||
// 排序 key 保证遍历顺序确定性
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
|
||
count := 0
|
||
for _, k := range keys {
|
||
if count >= opts.objectNum || *budget <= 0 {
|
||
break
|
||
}
|
||
|
||
// 扣 key 长度
|
||
keyCost := len(k)
|
||
if *budget < keyCost {
|
||
break
|
||
}
|
||
budgetBefore := *budget
|
||
*budget -= keyCost
|
||
|
||
// 递归处理 value
|
||
processed := sanitizeRecursive(m[k], opts, budget)
|
||
|
||
// 检查是否因预算不足返回了 nil(仅当原值非 nil 时)
|
||
if processed == nil && m[k] != nil {
|
||
*budget = budgetBefore
|
||
break
|
||
}
|
||
|
||
result[k] = processed
|
||
count++
|
||
}
|
||
return result
|
||
}
|
||
|
||
func sanitizeSliceContent(s []any, opts sanitizeOpts, budget *int) []any {
|
||
// 空 slice 占 2 预算
|
||
if *budget < 2 {
|
||
*budget = 0
|
||
return []any{}
|
||
}
|
||
*budget -= 2
|
||
|
||
result := make([]any, 0, min(len(s), opts.arrayNum))
|
||
count := 0
|
||
for _, v := range s {
|
||
if count >= opts.arrayNum || *budget <= 0 {
|
||
break
|
||
}
|
||
|
||
// 值预算由递归处理
|
||
processed := sanitizeRecursive(v, opts, budget)
|
||
if processed == nil && v != nil {
|
||
break
|
||
}
|
||
|
||
result = append(result, processed)
|
||
count++
|
||
}
|
||
return result
|
||
}
|
||
|
||
// sanitizeLogHeaders 过滤请求/响应头,排除 noLogHeaders 中指定的字段。
|
||
// 对于 Cookie 头,不整体排除,而是从 Cookie 内容中剔除 key 命中排除列表的键值对。
|
||
func sanitizeLogHeaders(h http.Header, noLogHeaders string) map[string]string {
|
||
result := make(map[string]string)
|
||
excludes := splitAndTrim(noLogHeaders)
|
||
for k, v := range h {
|
||
if k == "Cookie" && len(excludes) > 0 {
|
||
if filtered := sanitizeCookieValue(v, excludes); filtered != "" {
|
||
result[k] = filtered
|
||
}
|
||
continue
|
||
}
|
||
skip := false
|
||
for _, ex := range excludes {
|
||
if strings.EqualFold(k, ex) {
|
||
skip = true
|
||
break
|
||
}
|
||
}
|
||
if !skip {
|
||
result[k] = strings.Join(v, ", ")
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// sanitizeCookieValue 从 Cookie 原始值中剔除 key 命中排除列表的键值对。
|
||
// 输入为原始 Cookie 头值(可能多个 key=value 以 "; " 或 ";" 分隔)。
|
||
func sanitizeCookieValue(values []string, excludes []string) string {
|
||
raw := strings.Join(values, "; ")
|
||
pairs := strings.Split(raw, ";")
|
||
var kept []string
|
||
for _, pair := range pairs {
|
||
pair = strings.TrimSpace(pair)
|
||
if pair == "" {
|
||
continue
|
||
}
|
||
key, _, _ := strings.Cut(pair, "=")
|
||
key = strings.TrimSpace(key)
|
||
excluded := false
|
||
for _, ex := range excludes {
|
||
if strings.EqualFold(key, ex) {
|
||
excluded = true
|
||
break
|
||
}
|
||
}
|
||
if !excluded {
|
||
kept = append(kept, pair)
|
||
}
|
||
}
|
||
return strings.Join(kept, "; ")
|
||
}
|
||
|
||
func splitAndTrim(s string) []string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(s, ",")
|
||
for i, p := range parts {
|
||
parts[i] = strings.TrimSpace(p)
|
||
}
|
||
return parts
|
||
}
|
||
|
||
// sanitizeRespBody 对响应体进行脱敏:尝试 JSON 解析后走对象脱敏,失败则按字符串截断
|
||
func sanitizeRespBody(body []byte, cfg *ServiceConfig) any {
|
||
// 尝试解析为 JSON 对象
|
||
var parsed any
|
||
if err := cast.UnmarshalJSON(body, &parsed); err == nil && parsed != nil {
|
||
// 排除敏感字段
|
||
if cfg.NoLogResponseFields != "" {
|
||
parsed = stripFields(parsed, cfg.NoLogResponseFields)
|
||
}
|
||
return sanitizeLogData(parsed, sanitizeOpts{
|
||
maxSize: 200,
|
||
fieldSize: cfg.LogOutputFieldSize,
|
||
arrayNum: cfg.LogOutputArrayNum,
|
||
objectNum: cfg.LogOutputObjectNum,
|
||
})
|
||
}
|
||
|
||
// 非 JSON 内容,按字符串截断
|
||
if len(body) > cfg.LogOutputMaxSize {
|
||
return string(body[:cfg.LogOutputMaxSize]) + "..."
|
||
}
|
||
return string(body)
|
||
}
|
||
|
||
// stripFields 从对象中删除指定字段(仅处理顶层 map)
|
||
func stripFields(v any, fields string) any {
|
||
m, ok := v.(map[string]any)
|
||
if !ok {
|
||
return v
|
||
}
|
||
excludes := strings.Split(fields, ",")
|
||
for _, f := range excludes {
|
||
f = strings.TrimSpace(f)
|
||
if f != "" {
|
||
delete(m, f)
|
||
}
|
||
}
|
||
return m
|
||
}
|