P4-A: 三服务共享auth/logging能力 - 共享包边界定义/golden测试/契约测试

- gateway/internal/shared/: 新建 shared/auth 和 shared/logging 包
- shared/logging: LogEntry/Logger/NewLogger/sanitizeFields, 7个golden output测试
- shared/auth: ExtractBearerToken/HasExternalQueryKey/WriteAuthError/AuditEvent, 8个契约测试
- docs/plans/2026-04-21-shared-auth-logging-analysis.md: P4-A完整分析文档

迁移顺序: logging(第一步) -> auth基础(第二步) -> audit(第三步) -> 契约测试(第四步)
共享边界: JWT验证/token状态查询/授权策略/BruteForce保持服务特有
This commit is contained in:
Your Name
2026-04-21 19:00:25 +08:00
parent 8c5ab32e2e
commit 3b70fe1865
5 changed files with 1034 additions and 0 deletions

View File

@@ -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 keySupply-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 有 RequestIDSupply-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 structtimestamp/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. BruteForceProtectionSupply-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 ./...` 验证
**关键约束:不允许跨三个服务同时大爆炸改动。**

View File

@@ -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"],
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}