feat(service): Client Key 应答头条件化,静态文件/WebSocket 仅 Cookie 维护,配置字段命名统一(by AI)
- 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>
This commit is contained in:
parent
8f85c503da
commit
e8369d4680
21
CHANGELOG.md
21
CHANGELOG.md
@ -1,5 +1,26 @@
|
||||
# CHANGELOG - go/service
|
||||
|
||||
## v1.5.20 (2026-06-22)
|
||||
- **Client Key 应答头条件化**:
|
||||
- `Device-Id` / `Session-Id` 仅当请求头未携带时才写入应答头,避免客户端已持有 ID 时重复返回。
|
||||
- 静态文件和 WebSocket 升级应答中不再写入 `Device-Id` / `Session-Id` 头,仅通过 Cookie 维护身份(浏览器 WebSocket API 不支持自定义请求头)。
|
||||
- **Client App 头命名规范化**:
|
||||
- 客户端上报键名从 `AppName`/`AppVersion` 改为 `App-Name`/`App-Version`(与 `Device-Id`/`Session-Id` 一致使用破折号)。
|
||||
- **Breaking**: 旧版客户端需同步修改请求头名称。
|
||||
- **配置字段重命名**:
|
||||
- `NoLogHeaders` → `NoLogRequestHeaders`(请求头排除列表)。
|
||||
- `NoLogOutputFields` → `NoLogResponseFields`(响应体字段排除)。
|
||||
- 新增 `NoLogResponseHeaders`(响应头排除列表,用户可追加自定义字段)。
|
||||
- **Breaking**: 使用了旧字段名的配置需同步修改。
|
||||
- **动态排除列表**:
|
||||
- `NoLogRequestHeaders` 默认值动态构建(包含内部标准头 + 当前配置的 client key 名),用户只需追加自己关心的额外字段。
|
||||
- 移除 `init()` 中的硬编码默认值。
|
||||
- **Cookie 头智能过滤**:
|
||||
- `sanitizeLogHeaders` 对 `Cookie` 头不再整体排除,而是解析 Cookie 内容,仅剔除 key 命中排除列表的键值对(如 `Device-Id`/`Session-Id`),保留业务 Cookie。
|
||||
- 排除列表同时适用于 Header 名匹配和 Cookie key 匹配。
|
||||
- **日志应答头排除**:
|
||||
- 响应头日志捕获改为使用 `effectiveNoLogResponseHeaders()`,替代之前的硬编码空字符串。
|
||||
|
||||
## v1.5.19 (2026-06-22)
|
||||
- **依赖更新**:
|
||||
- 升级依赖 `id` 至 `v1.5.6`,`redis` 至 `v1.5.10`。
|
||||
|
||||
6
TEST.md
6
TEST.md
@ -2,7 +2,7 @@
|
||||
|
||||
## 性能测试 (Benchmark)
|
||||
- 测试日期: 2026-06-22
|
||||
- 版本: v1.5.19
|
||||
- 版本: v1.5.20
|
||||
- 指标: `BenchmarkRouting`: **5394 ns/op**
|
||||
- 环境: Darwin / Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
|
||||
|
||||
@ -27,7 +27,9 @@
|
||||
- [x] `TestSanitizeScalars` ~ `TestSanitizeMixedSlice`: 日志脱敏 10 个测试(标量/对象/数组/嵌套/预算/Unicode)
|
||||
- [x] `TestSessionLogic`: Session Save/Load/Remove 及 AuthFuncs
|
||||
- [x] `TestSessionInjection`: Session HTTP 注入流程
|
||||
- [x] Logging Filters: NoLogInput/NoLogOutput/NoLogAllHeaders/NoLogGets/NoLogHeaders
|
||||
- [x] Logging Filters: NoLogInput/NoLogOutput/NoLogAllHeaders/NoLogGets/NoLogRequestHeaders/NoLogResponseHeaders/NoLogResponseFields
|
||||
- [x] Client Keys: Device-Id/Session-Id 应答头条件化(请求有则不应答)、静态文件/WebSocket 仅 Cookie
|
||||
- [x] Cookie 头智能过滤: 排除列表中 key 从 Cookie 内容中剔除,保留业务 Cookie
|
||||
- [x] Response Body: 200 响应和 dev 模式下 keepBody 捕获
|
||||
|
||||
## 基础设施对齐验证
|
||||
|
||||
@ -26,11 +26,12 @@ type ServiceConfig struct {
|
||||
NoLogInput bool // 不记录请求输入
|
||||
NoLogOutput bool // 不记录响应输出
|
||||
NoLogAllHeaders bool // 不记录所有请求/响应头
|
||||
NoLogHeaders string // 不记录请求头中包含的这些字段,多个字段用逗号分隔
|
||||
NoLogRequestHeaders string // 不记录请求头中包含的这些字段(追加到动态默认列表),多个字段用逗号分隔
|
||||
NoLogResponseHeaders string // 不记录响应头中包含的这些字段(追加到动态默认列表),多个字段用逗号分隔
|
||||
LogInputObjectNum int // 请求对象中最多记录的 key 数
|
||||
LogInputArrayNum int // 请求数组中最多记录的元素数
|
||||
LogInputFieldSize int // 请求单个字段的字符串截断长度
|
||||
NoLogOutputFields string // 不记录响应字段中包含的这些字段
|
||||
NoLogResponseFields string // 不记录响应字段中包含的这些字段
|
||||
LogOutputObjectNum int // 响应对象中最多记录的 key 数
|
||||
LogOutputArrayNum int // 响应数组中最多记录的元素数
|
||||
LogOutputFieldSize int // 响应单个字段的字符串截断长度
|
||||
|
||||
51
handler.go
51
handler.go
@ -75,13 +75,13 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// 请求头
|
||||
var reqHeaders map[string]string
|
||||
if !ws.Config.NoLogAllHeaders {
|
||||
reqHeaders = sanitizeLogHeaders(r.Header, ws.Config.NoLogHeaders)
|
||||
reqHeaders = sanitizeLogHeaders(r.Header, ws.effectiveNoLogRequestHeaders())
|
||||
}
|
||||
|
||||
// 响应头
|
||||
var respHeaders map[string]string
|
||||
if !ws.Config.NoLogAllHeaders {
|
||||
respHeaders = sanitizeLogHeaders(response.Header().H, "")
|
||||
respHeaders = sanitizeLogHeaders(response.Header().H, ws.effectiveNoLogResponseHeaders())
|
||||
}
|
||||
|
||||
// 请求输入脱敏
|
||||
@ -434,8 +434,11 @@ func outputResult(response *Response, result any) {
|
||||
func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
|
||||
// SessionId
|
||||
if ws.usedSessionIdKey != "" {
|
||||
hasRequestSessionId := false
|
||||
sessionId := request.Header().Get(ws.usedSessionIdKey)
|
||||
if sessionId == "" && !ws.Config.SessionWithoutCookie {
|
||||
if sessionId != "" {
|
||||
hasRequestSessionId = true
|
||||
} else if !ws.Config.SessionWithoutCookie {
|
||||
if ck := request.GetCookie(ws.usedSessionIdKey); ck != nil {
|
||||
sessionId = ck.Value
|
||||
}
|
||||
@ -456,13 +459,18 @@ func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
|
||||
}
|
||||
}
|
||||
request.Request.Header.Set(discover.HeaderSessionID, sessionId)
|
||||
response.Header().Set(ws.usedSessionIdKey, sessionId)
|
||||
if !hasRequestSessionId {
|
||||
response.Header().Set(ws.usedSessionIdKey, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceId
|
||||
if ws.usedDeviceIdKey != "" {
|
||||
hasRequestDeviceId := false
|
||||
deviceId := request.Header().Get(ws.usedDeviceIdKey)
|
||||
if deviceId == "" && !ws.Config.DeviceWithoutCookie {
|
||||
if deviceId != "" {
|
||||
hasRequestDeviceId = true
|
||||
} else if !ws.Config.DeviceWithoutCookie {
|
||||
if ck := request.GetCookie(ws.usedDeviceIdKey); ck != nil {
|
||||
deviceId = ck.Value
|
||||
}
|
||||
@ -480,16 +488,41 @@ func (ws *WebServer) handleClientKeys(request *Request, response *Response) {
|
||||
}
|
||||
}
|
||||
request.Request.Header.Set(discover.HeaderDeviceID, deviceId)
|
||||
response.Header().Set(ws.usedDeviceIdKey, deviceId)
|
||||
if !hasRequestDeviceId {
|
||||
response.Header().Set(ws.usedDeviceIdKey, deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
// AppName / AppVersion(客户端上报,注入内部标准头供下游微服务使用)
|
||||
// App-Name / App-Version(客户端上报,注入内部标准头供下游微服务使用)
|
||||
if ws.usedClientAppKey != "" {
|
||||
if appName := request.Header().Get(ws.usedClientAppKey + "Name"); appName != "" {
|
||||
if appName := request.Header().Get(ws.usedClientAppKey + "-Name"); appName != "" {
|
||||
request.Request.Header.Set(discover.HeaderClientAppName, appName)
|
||||
}
|
||||
if appVersion := request.Header().Get(ws.usedClientAppKey + "Version"); appVersion != "" {
|
||||
if appVersion := request.Header().Get(ws.usedClientAppKey + "-Version"); appVersion != "" {
|
||||
request.Request.Header.Set(discover.HeaderClientAppVersion, appVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveNoLogRequestHeaders 返回请求头排除列表(动态默认值 + 用户配置追加)
|
||||
func (ws *WebServer) effectiveNoLogRequestHeaders() string {
|
||||
parts := []string{"X-Request-Id", "X-Device-Id", "X-Session-Id"}
|
||||
if ws.usedDeviceIdKey != "" {
|
||||
parts = append(parts, ws.usedDeviceIdKey)
|
||||
}
|
||||
if ws.usedSessionIdKey != "" {
|
||||
parts = append(parts, ws.usedSessionIdKey)
|
||||
}
|
||||
if ws.usedClientAppKey != "" {
|
||||
parts = append(parts, ws.usedClientAppKey+"-Name", ws.usedClientAppKey+"-Version")
|
||||
}
|
||||
if ws.Config.NoLogRequestHeaders != "" {
|
||||
parts = append(parts, ws.Config.NoLogRequestHeaders)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// effectiveNoLogResponseHeaders 返回响应头排除列表(用户配置追加)
|
||||
func (ws *WebServer) effectiveNoLogResponseHeaders() string {
|
||||
return ws.Config.NoLogResponseHeaders
|
||||
}
|
||||
|
||||
@ -137,14 +137,21 @@ func sanitizeSliceContent(s []any, opts sanitizeOpts, budget *int) []any {
|
||||
return result
|
||||
}
|
||||
|
||||
// sanitizeLogHeaders 过滤请求/响应头,排除 NoLogHeaders 中指定的字段
|
||||
// sanitizeLogHeaders 过滤请求/响应头,排除 noLogHeaders 中指定的字段。
|
||||
// 对于 Cookie 头,不整体排除,而是从 Cookie 内容中剔除 key 命中排除列表的键值对。
|
||||
func sanitizeLogHeaders(h http.Header, noLogHeaders string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
excludes := strings.Split(noLogHeaders, ",")
|
||||
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 ex != "" && strings.EqualFold(k, strings.TrimSpace(ex)) {
|
||||
if strings.EqualFold(k, ex) {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
@ -156,14 +163,52 @@ func sanitizeLogHeaders(h http.Header, noLogHeaders string) map[string]string {
|
||||
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.NoLogOutputFields != "" {
|
||||
parsed = stripFields(parsed, cfg.NoLogOutputFields)
|
||||
if cfg.NoLogResponseFields != "" {
|
||||
parsed = stripFields(parsed, cfg.NoLogResponseFields)
|
||||
}
|
||||
return sanitizeLogData(parsed, sanitizeOpts{
|
||||
maxSize: 200,
|
||||
|
||||
@ -111,7 +111,7 @@ var DefaultServer = NewWebServer()
|
||||
var Config = &DefaultServer.Config
|
||||
|
||||
func init() {
|
||||
Config.NoLogHeaders = "X-Request-Id,X-Device-Id,X-Session-Id,Cookie,Device-Id,Session-Id"
|
||||
// NoLogRequestHeaders / NoLogResponseHeaders 的默认值在日志捕获时动态构建
|
||||
Config.LogInputObjectNum = 10
|
||||
Config.LogInputArrayNum = 5
|
||||
Config.LogInputFieldSize = 20
|
||||
|
||||
@ -131,6 +131,10 @@ func (ws *WebServer) processStatic(requestPath string, request *Request, respons
|
||||
return false
|
||||
}
|
||||
|
||||
// 静态文件通过 Cookie 维护 ID,不应答 Device-Id / Session-Id 头
|
||||
response.Header().Del(ws.usedDeviceIdKey)
|
||||
response.Header().Del(ws.usedSessionIdKey)
|
||||
|
||||
// 检查 304
|
||||
if ifModifiedSince := request.Header().Get("If-Modified-Since"); ifModifiedSince != "" {
|
||||
if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil {
|
||||
|
||||
@ -78,6 +78,11 @@ func Upgrade(response *Response, request *Request) (*WebSocketConn, error) {
|
||||
}
|
||||
|
||||
func (ws *WebServer) doWebsocketService(wsc *websocketServiceType, request *Request, response *Response, logger *log.Logger, object any) {
|
||||
// WebSocket 浏览器 API 不支持自定义请求头,只能通过 Cookie 传递身份标识,
|
||||
// 因此不应在升级应答中返回 Device-Id / Session-Id 头。
|
||||
response.Header().Del(ws.usedDeviceIdKey)
|
||||
response.Header().Del(ws.usedSessionIdKey)
|
||||
|
||||
wsConn, err := Upgrade(response, request)
|
||||
if err != nil {
|
||||
logger.Error("websocket upgrade failed", "error", err.Error())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user