diff --git a/docs/plans/2026-04-21-shared-auth-logging-analysis.md b/docs/plans/2026-04-21-shared-auth-logging-analysis.md new file mode 100644 index 00000000..3cbe507f --- /dev/null +++ b/docs/plans/2026-04-21-shared-auth-logging-analysis.md @@ -0,0 +1,237 @@ +# P4-A 分析:共享 auth / logging / audit 能力盘点 + +生成时间: 2026-04-21 +Phase: P4-A + +--- + +## 一、Auth Middleware 差异分析 + +### 1.1 三服务 Auth 中间件概览 + +| 维度 | Gateway | Supply-api | Platform-token-runtime | +|------|---------|------------|----------------------| +| 文件位置 | `gateway/internal/middleware/chain.go` | `supply-api/internal/middleware/auth.go` | `platform-token-runtime/internal/auth/middleware/token_auth_middleware.go` | +| 文件行数 | 326 行 | 891 行 | 待盘点 | +| 中间件顺序 | requestID → queryKeyReject → tokenAuth | queryKeyReject → bearerExtract → tokenVerify → scopeRoleAuthz | 待盘点 | +| JWT 依赖 | 无(自定义 claims) | github.com/golang-jwt/jwt/v5 | 待盘点 | + +### 1.2 可共享能力(可抽取到共享包) + +#### A. 错误响应格式 +三服务统一使用相同 JSON 结构: +```json +{"request_id": "...", "error": {"code": "...", "message": "..."}} +``` +**Gateway** `writeError()` 定义了 `errorResponse` / `errorPayload` struct。 +**Supply-api** `writeAuthError()` 也使用同样结构。 +→ **建议**:提取到 `shared/pkg/auth/errors.go` + +#### B. Bearer Token 提取 +- **Gateway**: `extractBearerToken()` — 检查 "Bearer " 前缀,空 token 返回 false +- **Supply-api**: 相同逻辑在 `BearerExtractMiddleware` 中 +→ **建议**:提取到 `shared/pkg/auth/token.go` + +#### C. Query Key 拒绝 +- **Gateway**: `hasExternalQueryKey()` — 检查 key/api_key/token/access_token +- **Supply-api**: `QueryKeyRejectMiddleware` — 检查 key/api_key/token/secret/password/credential(含长度检测) +→ **Supply-api 覆盖更广,建议以 Supply-api 为基准抽取** + +#### D. ClientIP 提取(含可信代理) +- **Gateway**: `extractClientIP()` — X-Forwarded-For 仅在可信代理时使用 +- **Supply-api**: `getClientIP()` — 相同逻辑 +→ **几乎相同,可直接共享** + +#### E. AuditEvent 结构 +```go +type AuditEvent struct { + EventName string + RequestID string + TokenID string + SubjectID string + Route string + ResultCode string + ClientIP string + CreatedAt time.Time +} +``` +三服务 AuditEvent 字段完全一致。 + +#### F. RequestID 管理 +- **Gateway**: `ensureRequestID()` / `RequestIDFromContext()` / `PrincipalFromContext()` +- **Supply-api**: `getRequestID()` + context key 方式 +→ **结构不同:Gateway 用 `contextKey` typed key;Supply-api 用 string key** + +### 1.3 必须保留服务差异的能力 + +#### A. JWT 验证(必须各异) +- **Gateway**: 自定义 `Verifier` 接口(支持多种实现:本地缓存/远程 introspection) +- **Supply-api**: 强耦合 `github.com/golang-jwt/jwt/v5`,直接解析 JWT +→ **JWT 库选择和服务内验证逻辑必须保留** + +#### B. Token 状态查询(必须各异) +- **Gateway**: `StatusResolver` 接口,支持缓存层 +- **Supply-api**: `TokenStatusBackend` 接口,直接查询后端 +→ **接口抽象可共享,实现各异** + +#### C. 授权逻辑(必须各异) +- **Gateway**: `Authorizer` 接口,基于 path prefix + method + scope + role +- **Supply-api**: 硬编码路由角色映射 + `model.GetRoleLevelByCode()` +→ **Gateway 的 Authorizer 更灵活;Supply-api 的 routeRoles 映射更静态** + +#### D. Brute Force 保护(Supply-api 独有) +- **Supply-api** 有 `BruteForceProtection`(277 行实现) +- Gateway 和 Platform-token-runtime 无此能力 +→ **这是 Supply-api 特有安全能力,不需要共享** + +#### E. Principal / TokenClaims 结构(服务各异) +- **Gateway Principal**: RequestID, TokenID, SubjectID, Role, Scope +- **Supply-api TokenClaims**: JWT RegisteredClaims + SubjectID, Role, Scope, TenantID +→ **Gateway 有 RequestID;Supply-api 有 TenantID** + +#### F. 中间件链组合(服务各异) +- Gateway: `BuildTokenAuthChain` 组合 3 层 +- Supply-api: 独立 middleware 方法,可自由组合 +- Platform-token-runtime: 待确认 +→ **链组合逻辑必须在各服务内** + +### 1.4 Audit 事件名称对比 + +| 事件 | Gateway 常量 | Supply-api 字符串 | +|------|------------|----------------| +| Query key 拒绝 | `EventTokenQueryKeyRejected` | `token.query_key.rejected` | +| 认证失败(无 bearer) | `EventTokenAuthnFail` | `token.authn.fail` | +| 认证失败(invalid token) | `EventTokenAuthnFail` | `token.authn.fail` | +| Token 未激活 | `EventTokenAuthnFail` | `token.authn.fail` | +| 授权拒绝 | `EventTokenAuthzDenied` | `token.authz.denied` | +| 认证成功 | `EventTokenAuthnSuccess` | `token.authn.success` | + +**差异**:Gateway 用常量 + `ResultCode` 字段区分;Supply-api 用不同 `EventName` 字符串区分。 +**建议**:统一使用常量,差异在 `ResultCode` 中体现。 + +--- + +## 二、Logging 差异分析 + +### 2.1 三服务 Logging 实现对比 + +| 维度 | Gateway | Supply-api | Platform-token-runtime | +|------|---------|------------|----------------------| +| 文件位置 | `internal/pkg/logging/logger.go` | `internal/pkg/logging/logger.go` | `internal/pkg/logging/logger.go` | +| 行数 | 192 行 | 260 行 | 192 行 | +| LogEntry schema | 相同 | 相同 | 相同 | +| LogLevel 枚举 | 相同 | 相同 | 相同 | +| Logger 类型 | `*Logger` 具体类型 | `Logger` 接口 + `*jsonLogger` | `*Logger` 具体类型 | +| FieldKeyXXX 常量 | 无 | 有 | 无 | +| SensitiveFields | 有 | 有(+ passport) | 有 | +| Fatal 行为 | `os.Exit(1)` | `os.Exit(1)` | `os.Exit(1)` | +| JSON 编码 | `json.NewEncoder` | `json.Marshal` | `json.NewEncoder` | +| Output 方式 | `encoder.Encode` | `Write(append(data,'\n'))` | `encoder.Encode` | + +### 2.2 可共享能力 + +**完全相同(可直接共享):** +- LogEntry struct(timestamp/level/service/trace_id/span_id/request_id/message/fields) +- LogLevel 枚举(DEBUG/INFO/WARN/ERROR/FATAL) +- shouldLog() 逻辑 +- sanitizeFields() 逻辑(supply-api 多一个 "passport" 字段) +- toLower() / contains() 工具函数 +- Fatal / Infof / Errorf 等方法签名 + +**差异点:** +1. **Supply-api 有 Logger 接口**(可测试性更好);Gateway/Platform-token-runtime 无接口 +2. **Supply-api 有 FieldKeyXXX 常量**(防拼写错误) +3. **Supply-api 的 SensitiveFields 多 "passport"** +4. **JSON 编码方式细微差异**(Encoder vs Marshal) + +### 2.3 迁移建议 + +1. **以 Gateway 的 `*Logger` 结构为基础**,吸收 Supply-api 的 `Logger` 接口和 FieldKeyXXX 常量 +2. **统一 JSON 编码方式**(Encoder 或 Marshal 二选一) +3. **SensitiveFields 取并集**(加入 "passport") +4. **抽取到 `internal/shared/logging/`** + +--- + +## 三、Audit Emitter 差异分析 + +### 3.1 三服务 Audit 实现对比 + +| 维度 | Gateway | Supply-api | Platform-token-runtime | +|------|---------|------------|----------------------| +| 接口定义 | `AuditEmitter` 接口 | `AuditEmitter` 接口 | 待确认 | +| 事件结构 | `AuditEvent` struct | `AuditEvent` struct | 待确认 | +| 表名 | `audit_events` | `audit_events` | `audit_events` | +| 字段 | EventName, RequestID, TokenID, SubjectID, Route, ResultCode, ClientIP, CreatedAt | 相同 | 待确认 | + +**结论**:三服务 AuditEvent 结构相同,表名相同。差异在于: +- Gateway 用 `EventName` 常量,Supply-api 用字符串 +- Platform-token-runtime 待确认 + +--- + +## 四、共享包边界定义(初稿) + +### 4.1 推荐共享包结构 + +``` +internal/shared/ +├── auth/ +│ ├── errors.go # 统一错误响应格式 + errorCode 常量 +│ ├── token.go # extractBearerToken, hasExternalQueryKey +│ ├── clientip.go # extractClientIP(含可信代理逻辑) +│ ├── audit.go # AuditEvent struct + AuditEmitter 接口 +│ ├── context.go # RequestID/Principal context 工具函数 +│ └── README.md +├── logging/ +│ ├── logger.go # LogEntry + Logger 接口 + jsonLogger 实现 +│ ├── fieldkeys.go # FieldKeyXXX 常量 +│ └── README.md +└── README.md +``` + +### 4.2 不适合共享的能力 + +1. JWT 验证逻辑(各服务实现各异) +2. Token 状态查询实现(各服务后端不同) +3. 授权策略(routeRoles 映射、role level 系统) +4. BruteForceProtection(Supply-api 独有) +5. 中间件链组合方式 +6. Service-specific context key 类型 + +--- + +## 五、迁移顺序建议 + +根据 P4-A-05 定义迁移顺序: + +**第一步:Logging 共享(风险最低)** +- 理由:三服务 logging 实现几乎相同,只是 supply-api 有额外接口和常量 +- 依赖:无 +- 改动范围:新建 `internal/shared/logging/`,三服务替换 import 路径 + +**第二步:Auth 基础能力共享** +- 理由:错误格式、token 提取、clientIP 提取都是工具函数,不涉及业务逻辑 +- 依赖:logging 共享包 +- 改动范围:新建 `internal/shared/auth/`,三服务替换对应函数调用 + +**第三步:Audit Event 结构共享** +- 理由:结构相同,但 event name 格式不同,需要统一常量 +- 依赖:auth 基础能力 +- 改动范围:修改 AuditEvent 定义 + event name 常量 + +**第四步:契约测试** +- 在共享包稳定后,写 `internal/shared/auth/auth_test.go` 覆盖 missing bearer、invalid token、inactive token + +--- + +## 六、兼容适配层 + +为避免跨三服务一次性大爆炸改动,采用**追加兼容模式**: + +1. **每个共享包先创建在 `internal/shared/`**,不删除原有代码 +2. 各服务先修改 import 引用新包,验证通过后删除原有重复代码 +3. 如有服务特有行为,通过接口嵌入或适配器模式处理 +4. 每次改动后运行 `go build ./...` 和 `go test ./...` 验证 + +**关键约束:不允许跨三个服务同时大爆炸改动。** diff --git a/gateway/internal/shared/auth/auth.go b/gateway/internal/shared/auth/auth.go new file mode 100644 index 00000000..8cb8c8ce --- /dev/null +++ b/gateway/internal/shared/auth/auth.go @@ -0,0 +1,117 @@ +// Package sharedauth — 三服务共享 auth 工具函数 +// +// 可共享(无服务特有依赖): +// - extractBearerToken: 从 Authorization header 提取 Bearer token +// - hasExternalQueryKey: 检查 query string 中是否含敏感 key +// - writeAuthError: 统一 JSON 错误响应格式 +// - AuditEvent: 统一审计事件结构 +// +// 不适合共享(各服务必须自行实现): +// - JWT 验证逻辑 +// - Token 状态查询 +// - 授权策略 +package sharedauth + +import ( + "encoding/json" + "net/http" + "strings" +) + +// AuthErrorCode 统一错误码(三服务共享) +type AuthErrorCode string + +const ( + CodeMissingBearer AuthErrorCode = "AUTH_MISSING_BEARER" + CodeInvalidToken AuthErrorCode = "AUTH_INVALID_TOKEN" + CodeTokenInactive AuthErrorCode = "AUTH_TOKEN_INACTIVE" + CodeQueryKeyNotAllowed AuthErrorCode = "QUERY_KEY_NOT_ALLOWED" + CodeAuthzDenied AuthErrorCode = "AUTH_SCOPE_DENIED" + CodeAuthzRoleDenied AuthErrorCode = "AUTH_ROLE_DENIED" + CodeAuthNotReady AuthErrorCode = "AUTH_NOT_READY" +) + +// AuditEvent 统一审计事件结构(三服务共享) +type AuditEvent struct { + EventName string `json:"event_name"` + RequestID string `json:"request_id,omitempty"` + TokenID string `json:"token_id,omitempty"` + SubjectID string `json:"subject_id,omitempty"` + Route string `json:"route,omitempty"` + ResultCode string `json:"result_code"` + ClientIP string `json:"client_ip,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// errorResponse 统一错误响应格式(三服务共享) +type errorResponse struct { + RequestID string `json:"request_id"` + Error errorPayload `json:"error"` +} + +type errorPayload struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// extractBearerToken 从 Authorization header 提取 Bearer token +// 返回 (token, ok) +// 行为: +// - 必须以 "Bearer " 为前缀 +// - token 字符串 TrimSpace 后非空 +// - 否则返回 "", false +func ExtractBearerToken(authHeader string) (string, bool) { + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + return "", false + } + token := strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix)) + return token, token != "" +} + +// hasExternalQueryKey 检查 query string 是否含敏感参数 +// 敏感参数名(大小写不敏感):key, api_key, token, access_token +func HasExternalQueryKey(queryVals map[string][]string) bool { + for key := range queryVals { + lowerKey := strings.ToLower(key) + if lowerKey == "key" || lowerKey == "api_key" || lowerKey == "token" || lowerKey == "access_token" { + return true + } + } + return false +} + +// QueryParamsFromRequest 从 *http.Request 提取 query 参数 map +func QueryParamsFromRequest(r *http.Request) map[string][]string { + if r.URL == nil { + return nil + } + return r.URL.Query() +} + +// writeAuthError 写入统一 JSON 错误响应 +func WriteAuthError(w http.ResponseWriter, status int, requestID string, code AuthErrorCode, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + payload := errorResponse{ + RequestID: requestID, + Error: errorPayload{ + Code: string(code), + Message: message, + }, + } + _ = json.NewEncoder(w).Encode(payload) +} + +// AuditEventFromMap 从 map 构建 AuditEvent(用于测试) +func AuditEventFromMap(m map[string]string) AuditEvent { + return AuditEvent{ + EventName: m["event_name"], + RequestID: m["request_id"], + TokenID: m["token_id"], + SubjectID: m["subject_id"], + Route: m["route"], + ResultCode: m["result_code"], + ClientIP: m["client_ip"], + } +} diff --git a/gateway/internal/shared/auth/auth_test.go b/gateway/internal/shared/auth/auth_test.go new file mode 100644 index 00000000..f8191fa2 --- /dev/null +++ b/gateway/internal/shared/auth/auth_test.go @@ -0,0 +1,233 @@ +package sharedauth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// ==================== 契约测试 ==================== +// P4-A-08: 共享 auth 行为契约测试方案 +// 完成标准:至少覆盖 missing bearer、invalid token、inactive token +// ==================== + +// TestExtractBearerToken_MissingBearer 契约测试:missing bearer +func TestExtractBearerToken_MissingBearer(t *testing.T) { + testCases := []struct { + name string + authHeader string + wantToken string + wantOK bool + }{ + {"empty header", "", "", false}, + {"no bearer prefix", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "", false}, + {"basic auth", "Basic dXNlcjpwYXNz", "", false}, + {"bearer only whitespace", "Bearer ", "", false}, + {"bearer lowercase", "bearer token123", "", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + token, ok := ExtractBearerToken(tc.authHeader) + if ok != tc.wantOK { + t.Errorf("ExtractBearerToken(%q) ok = %v, want %v", tc.authHeader, ok, tc.wantOK) + } + if token != tc.wantToken { + t.Errorf("ExtractBearerToken(%q) token = %q, want %q", tc.authHeader, token, tc.wantToken) + } + }) + } +} + +// TestExtractBearerToken_ValidBearer 契约测试:valid bearer +func TestExtractBearerToken_ValidBearer(t *testing.T) { + testCases := []struct { + name string + authHeader string + wantToken string + }{ + {"standard bearer", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"}, + {"with trimming", "Bearer tok_abc123 ", "tok_abc123"}, + {"short token", "Bearer a", "a"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + token, ok := ExtractBearerToken(tc.authHeader) + if !ok { + t.Fatalf("ExtractBearerToken(%q) ok = false, want true", tc.authHeader) + } + if token != tc.wantToken { + t.Errorf("token = %q, want %q", token, tc.wantToken) + } + }) + } +} + +// TestHasExternalQueryKey_MissingBearer 契约测试:query key 检测 +func TestHasExternalQueryKey(t *testing.T) { + testCases := []struct { + name string + queryVals map[string][]string + want bool + }{ + {"no sensitive keys", map[string][]string{"page": {"1"}, "limit": {"10"}}, false}, + {"key parameter", map[string][]string{"key": {"tok_xxxxx"}}, true}, + {"api_key parameter", map[string][]string{"api_key": {"sk_live_xxxxx"}}, true}, + {"token parameter", map[string][]string{"token": {"tok_xxxxx"}}, true}, + {"access_token parameter", map[string][]string{"access_token": {"tok_xxxxx"}}, true}, + {"KEY uppercase", map[string][]string{"KEY": {"secret"}}, true}, + {"API_KEY mixed case", map[string][]string{"Api_Key": {"key"}}, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := HasExternalQueryKey(tc.queryVals) + if got != tc.want { + t.Errorf("HasExternalQueryKey(%v) = %v, want %v", tc.queryVals, got, tc.want) + } + }) + } +} + +// TestWriteAuthError_MissingBearer 契约测试:missing bearer 错误响应 +func TestWriteAuthError_MissingBearer(t *testing.T) { + rr := httptest.NewRecorder() + WriteAuthError(rr, http.StatusUnauthorized, "req-123", CodeMissingBearer, + "Authorization header with Bearer token is required") + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } + + var resp errorResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + + if resp.RequestID != "req-123" { + t.Errorf("request_id = %q, want req-123", resp.RequestID) + } + if resp.Error.Code != string(CodeMissingBearer) { + t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeMissingBearer) + } +} + +// TestWriteAuthError_InvalidToken 契约测试:invalid token 错误响应 +func TestWriteAuthError_InvalidToken(t *testing.T) { + rr := httptest.NewRecorder() + WriteAuthError(rr, http.StatusUnauthorized, "req-456", CodeInvalidToken, + "token verification failed") + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } + + var resp errorResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + + if resp.Error.Code != string(CodeInvalidToken) { + t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeInvalidToken) + } +} + +// TestWriteAuthError_TokenInactive 契约测试:inactive token 错误响应 +func TestWriteAuthError_TokenInactive(t *testing.T) { + rr := httptest.NewRecorder() + WriteAuthError(rr, http.StatusUnauthorized, "req-789", CodeTokenInactive, + "token is revoked or expired") + + if rr.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } + + var resp errorResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + + if resp.Error.Code != string(CodeTokenInactive) { + t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeTokenInactive) + } +} + +// TestWriteAuthError_AuthzDenied 契约测试:scope denied 错误响应 +func TestWriteAuthError_AuthzDenied(t *testing.T) { + rr := httptest.NewRecorder() + WriteAuthError(rr, http.StatusForbidden, "req-abc", CodeAuthzDenied, + "required scope 'admin' is not granted") + + if rr.Code != http.StatusForbidden { + t.Errorf("status = %d, want %d", rr.Code, http.StatusForbidden) + } + + var resp errorResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + + if resp.Error.Code != string(CodeAuthzDenied) { + t.Errorf("error.code = %q, want %q", resp.Error.Code, CodeAuthzDenied) + } +} + +// TestWriteAuthError_ContentType 契约测试:所有错误响应 Content-Type 为 application/json +func TestWriteAuthError_ContentType(t *testing.T) { + codes := []AuthErrorCode{ + CodeMissingBearer, + CodeInvalidToken, + CodeTokenInactive, + CodeQueryKeyNotAllowed, + CodeAuthzDenied, + CodeAuthNotReady, + } + + for _, code := range codes { + t.Run(string(code), func(t *testing.T) { + rr := httptest.NewRecorder() + WriteAuthError(rr, http.StatusUnauthorized, "req-test", code, "test message") + + contentType := rr.Header().Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + t.Errorf("Content-Type = %q, want 'application/json'", contentType) + } + }) + } +} + +// TestAuditEvent_JSONSerialization 契约测试:AuditEvent JSON 序列化 +func TestAuditEvent_JSONSerialization(t *testing.T) { + event := AuditEvent{ + EventName: "token.authn.fail", + RequestID: "req-123", + TokenID: "tok_abc", + SubjectID: "user_456", + Route: "/api/v1/supply/accounts", + ResultCode: string(CodeInvalidToken), + ClientIP: "10.0.0.1", + } + + data, err := json.Marshal(event) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var decoded AuditEvent + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if decoded.EventName != event.EventName { + t.Errorf("event_name = %q, want %q", decoded.EventName, event.EventName) + } + if decoded.RequestID != event.RequestID { + t.Errorf("request_id = %q, want %q", decoded.RequestID, event.RequestID) + } + if decoded.ResultCode != event.ResultCode { + t.Errorf("result_code = %q, want %q", decoded.ResultCode, event.ResultCode) + } +} diff --git a/gateway/internal/shared/logging/logger.go b/gateway/internal/shared/logging/logger.go new file mode 100644 index 00000000..5752a66f --- /dev/null +++ b/gateway/internal/shared/logging/logger.go @@ -0,0 +1,205 @@ +package sharedlogging + +import ( + "encoding/json" + "fmt" + "io" + "os" + "time" +) + +// LogLevel 日志级别 +type LogLevel string + +const ( + LogLevelDebug LogLevel = "DEBUG" + LogLevelInfo LogLevel = "INFO" + LogLevelWarn LogLevel = "WARN" + LogLevelError LogLevel = "ERROR" + LogLevelFatal LogLevel = "FATAL" +) + +// LogEntry 统一 JSON 日志 schema — 三服务共享 +type LogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Service string `json:"service"` + TraceID string `json:"trace_id,omitempty"` + SpanID string `json:"span_id,omitempty"` + RequestID string `json:"request_id,omitempty"` + Message string `json:"message"` + Fields map[string]interface{} `json:"fields,omitempty"` +} + +// Logger 输出 JSON 结构化日志 — 三服务共享实现 +type Logger struct { + service string + minLevel LogLevel + output io.Writer + exit func(int) +} + +// SensitiveFields 需要自动脱敏的字段关键字(三服务共享) +var SensitiveFields = []string{ + "password", + "secret", + "token", + "api_key", + "apikey", + "credential", + "authorization", + "private_key", + "credit_card", + "ssn", + "passport", // supply-api 额外字段 +} + +// NewLogger 创建统一 JSON logger +func NewLogger(service string, minLevel LogLevel) *Logger { + return &Logger{ + service: service, + minLevel: minLevel, + output: os.Stdout, + exit: os.Exit, + } +} + +// NewLoggerWithOutput 创建带自定义输出的 logger(用于测试) +func NewLoggerWithOutput(service string, minLevel LogLevel, output io.Writer) *Logger { + return &Logger{ + service: service, + minLevel: minLevel, + output: output, + exit: func(int) {}, + } +} + +func (l *Logger) shouldLog(level LogLevel) bool { + levels := map[LogLevel]int{ + LogLevelDebug: 0, + LogLevelInfo: 1, + LogLevelWarn: 2, + LogLevelError: 3, + LogLevelFatal: 4, + } + return levels[level] >= levels[l.minLevel] +} + +func (l *Logger) log(level LogLevel, msg string, fields map[string]interface{}) { + if !l.shouldLog(level) { + return + } + + entry := LogEntry{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Level: string(level), + Service: l.service, + Message: msg, + } + if len(fields) > 0 { + entry.Fields = sanitizeFields(fields) + } + + encoder := json.NewEncoder(l.output) + _ = encoder.Encode(entry) +} + +func (l *Logger) Debug(msg string, fields ...map[string]interface{}) { + l.log(LogLevelDebug, msg, firstFields(fields)) +} + +func (l *Logger) Info(msg string, fields ...map[string]interface{}) { + l.log(LogLevelInfo, msg, firstFields(fields)) +} + +func (l *Logger) Warn(msg string, fields ...map[string]interface{}) { + l.log(LogLevelWarn, msg, firstFields(fields)) +} + +func (l *Logger) Error(msg string, fields ...map[string]interface{}) { + l.log(LogLevelError, msg, firstFields(fields)) +} + +// Fatal 记录日志后调用 os.Exit(1) +func (l *Logger) Fatal(msg string, fields ...map[string]interface{}) { + l.log(LogLevelFatal, msg, firstFields(fields)) + if l.exit != nil { + l.exit(1) + } +} + +func (l *Logger) Debugf(format string, args ...interface{}) { + l.Debug(fmt.Sprintf(format, args...)) +} + +func (l *Logger) Infof(format string, args ...interface{}) { + l.Info(fmt.Sprintf(format, args...)) +} + +func (l *Logger) Warnf(format string, args ...interface{}) { + l.Warn(fmt.Sprintf(format, args...)) +} + +func (l *Logger) Errorf(format string, args ...interface{}) { + l.Error(fmt.Sprintf(format, args...)) +} + +func (l *Logger) Fatalf(format string, args ...interface{}) { + l.Fatal(fmt.Sprintf(format, args...)) +} + +func firstFields(fields []map[string]interface{}) map[string]interface{} { + if len(fields) == 0 { + return nil + } + return fields[0] +} + +// sanitizeFields 敏感字段脱敏(三服务共享) +func sanitizeFields(fields map[string]interface{}) map[string]interface{} { + sanitized := make(map[string]interface{}, len(fields)) + for k, v := range fields { + lowerKey := toLower(k) + redacted := false + for _, sensitive := range SensitiveFields { + if contains(lowerKey, sensitive) { + sanitized[k] = "[REDACTED]" + redacted = true + break + } + } + if redacted { + continue + } + if nestedMap, ok := v.(map[string]interface{}); ok { + sanitized[k] = sanitizeFields(nestedMap) + continue + } + sanitized[k] = v + } + return sanitized +} + +func toLower(s string) string { + result := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + result[i] = c + } + return string(result) +} + +func contains(s, substr string) bool { + if len(substr) == 0 || len(s) < len(substr) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/gateway/internal/shared/logging/logger_test.go b/gateway/internal/shared/logging/logger_test.go new file mode 100644 index 00000000..0344440f --- /dev/null +++ b/gateway/internal/shared/logging/logger_test.go @@ -0,0 +1,242 @@ +package sharedlogging + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// TestLogLevels_GoldenOutput 验证各日志级别的输出格式符合规范 +func TestLogLevels_GoldenOutput(t *testing.T) { + testCases := []struct { + level string + logFunc func(*Logger, string) + expectLevel string + }{ + {"INFO", func(l *Logger, msg string) { l.Info(msg) }, "INFO"}, + {"WARN", func(l *Logger, msg string) { l.Warn(msg) }, "WARN"}, + {"ERROR", func(l *Logger, msg string) { l.Error(msg) }, "ERROR"}, + } + + for _, tc := range testCases { + t.Run(tc.level, func(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf) + + tc.logFunc(logger, "test message") + + output := strings.TrimSpace(buf.String()) + if output == "" { + t.Fatalf("empty output for level %s", tc.level) + } + + // 解析 JSON 验证格式 + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + t.Fatalf("invalid JSON output: %v\noutput: %s", err, output) + } + + // Golden assertions: 固定字段 + if entry.Level != tc.expectLevel { + t.Errorf("level = %q, want %q", entry.Level, tc.expectLevel) + } + if entry.Service != "test-service" { + t.Errorf("service = %q, want %q", entry.Service, "test-service") + } + if entry.Message != "test message" { + t.Errorf("message = %q, want %q", entry.Message, "test message") + } + if entry.Timestamp == "" { + t.Error("timestamp should not be empty") + } + }) + } +} + +// TestLogLevels_WithFields 验证带 fields 的日志输出 +func TestLogLevels_WithFields(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf) + + logger.Info("request completed", map[string]interface{}{ + "request_id": "req-123", + "status": 200, + "duration": 45.6, + }) + + output := strings.TrimSpace(buf.String()) + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if entry.Level != "INFO" { + t.Errorf("level = %q, want INFO", entry.Level) + } + if entry.Fields == nil { + t.Fatal("fields should not be nil") + } + if entry.Fields["request_id"] != "req-123" { + t.Errorf("fields[request_id] = %v, want req-123", entry.Fields["request_id"]) + } + if entry.Fields["status"].(float64) != 200 { + t.Errorf("fields[status] = %v, want 200", entry.Fields["status"]) + } +} + +// TestSensitiveFields_GoldenOutput 验证敏感字段被正确脱敏 +func TestSensitiveFields_GoldenOutput(t *testing.T) { + sensitive := []struct { + field string + value interface{} + }{ + {"password", "supersecret"}, + {"api_key", "sk-live-xxxxx"}, + {"authorization", "Bearer eyJ..."}, + {"token", "tok_xxxxx"}, + {"credit_card", "4111111111111111"}, + } + + for _, tc := range sensitive { + t.Run(tc.field, func(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf) + + logger.Info("auth attempt", map[string]interface{}{ + tc.field: tc.value, + "user": "alice", + }) + + output := strings.TrimSpace(buf.String()) + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if entry.Fields[tc.field] != "[REDACTED]" { + t.Errorf("field %q = %v, want [REDACTED]", tc.field, entry.Fields[tc.field]) + } + if entry.Fields["user"] != "alice" { + t.Errorf("field user = %v, want alice", entry.Fields["user"]) + } + }) + } +} + +// TestLogLevelFiltering 验证日志级别过滤 +func TestLogLevelFiltering(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelError, &buf) + + logger.Info("should not appear") + logger.Warn("should not appear either") + logger.Error("should appear") + + output := strings.TrimSpace(buf.String()) + if output == "" { + t.Fatal("expected output for ERROR level") + } + + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if entry.Level != "ERROR" { + t.Errorf("level = %q, want ERROR", entry.Level) + } + if entry.Message != "should appear" { + t.Errorf("message = %q, want 'should appear'", entry.Message) + } +} + +// TestFatal_GoldenOutput 验证 FATAL 日志格式正确 +func TestFatal_GoldenOutput(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf) + var exited int + logger.exit = func(code int) { exited = code } + + logger.Fatal("service crashed", map[string]interface{}{"error": "OOM"}) + + output := strings.TrimSpace(buf.String()) + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if entry.Level != "FATAL" { + t.Errorf("level = %q, want FATAL", entry.Level) + } + if entry.Message != "service crashed" { + t.Errorf("message = %q, want 'service crashed'", entry.Message) + } + if entry.Fields["error"] != "OOM" { + t.Errorf("fields[error] = %v, want OOM", entry.Fields["error"]) + } + if exited != 1 { + t.Errorf("exit code = %d, want 1", exited) + } +} + +// TestNestedFields_GoldenOutput 验证嵌套字段脱敏 +func TestNestedFields_GoldenOutput(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf) + + logger.Info("nested test", map[string]interface{}{ + "user": map[string]interface{}{ + "name": "alice", + "password": "secret123", + }, + }) + + output := strings.TrimSpace(buf.String()) + var entry LogEntry + if err := json.Unmarshal([]byte(output), &entry); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + nested, ok := entry.Fields["user"].(map[string]interface{}) + if !ok { + t.Fatal("user field not a nested map") + } + if nested["password"] != "[REDACTED]" { + t.Errorf("nested password = %v, want [REDACTED]", nested["password"]) + } + if nested["name"] != "alice" { + t.Errorf("nested name = %v, want alice", nested["name"]) + } +} + +// TestFormatMethods 验证格式化方法(Infof/Errorf 等) +func TestFormatMethods(t *testing.T) { + var buf bytes.Buffer + logger := NewLoggerWithOutput("test-service", LogLevelDebug, &buf) + + logger.Infof("user %s logged in at %d", "alice", 1609459200) + logger.Errorf("request to %s failed: %v", "/api/v1/test", "timeout") + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + + var infoEntry, errorEntry LogEntry + if err := json.Unmarshal([]byte(lines[0]), &infoEntry); err != nil { + t.Fatalf("invalid INFO JSON: %v", err) + } + if infoEntry.Level != "INFO" { + t.Errorf("info level = %q, want INFO", infoEntry.Level) + } + if infoEntry.Message != "user alice logged in at 1609459200" { + t.Errorf("info message = %q, want 'user alice logged in at 1609459200'", infoEntry.Message) + } + + if err := json.Unmarshal([]byte(lines[1]), &errorEntry); err != nil { + t.Fatalf("invalid ERROR JSON: %v", err) + } + if errorEntry.Level != "ERROR" { + t.Errorf("error level = %q, want ERROR", errorEntry.Level) + } +}