Files
junhong_cmp_fiber/pkg/logger/middleware.go
break 70e680eb0a
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史
- H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列
- 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案
- 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容
- 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏
- 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
2026-09-15 11:54:56 +08:00

291 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logger
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/url"
"strings"
"time"
"unicode"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
const (
// MaxBodyLogSize 限制记录的请求/响应 body 大小为 50KB
MaxBodyLogSize = 50 * 1024
redactedValue = "[已脱敏]"
)
// BodyPolicy 表示访问日志正文记录策略。
type BodyPolicy int
const (
// BodyPolicyJSON 表示只允许记录脱敏后的 JSON 正文。
BodyPolicyJSON BodyPolicy = iota + 1
// BodyPolicySummary 表示只记录不可逆安全摘要。
BodyPolicySummary
)
// SanitizedContent 表示访问日志可安全记录的正文或查询摘要。
type SanitizedContent struct {
Content string
Size int
SHA256 string
Truncated bool
}
// truncateBody 截断 body 到指定大小
func truncateBody(body []byte, maxSize int) (string, bool) {
if len(body) == 0 {
return "", false
}
if len(body) <= maxSize {
return string(body), false
}
return string(body[:maxSize]), true
}
// phoneFieldNames 是访问日志中属于手机号类的字段名集合。
// 手机号不在 sanitizer 的 forbidden 字段清单内,仅靠字段名通用清理无法脱敏;
// 这里单独识别并输出前 3 位 + **** + 后 4 位,禁止完整号码进入 query、请求正文或响应正文。
// 只覆盖手机号类字段forbidden 字段仍按原语义标记为已脱敏,两者处理互不影响。
var phoneFieldNames = map[string]struct{}{
"phone": {},
"mobile": {},
"associated_phones": {},
}
// normalizeLogFieldName 归一化字段名用于敏感字段判断。
func normalizeLogFieldName(key string) string {
var builder strings.Builder
for index, char := range key {
if unicode.IsUpper(char) && index > 0 {
builder.WriteByte('_')
}
if char == '-' || char == '.' {
builder.WriteByte('_')
continue
}
builder.WriteRune(unicode.ToLower(char))
}
return builder.String()
}
// isPhoneField 判断字段名是否属于手机号类字段(与 forbidden 字段分开处理)。
func isPhoneField(key string) bool {
_, ok := phoneFieldNames[normalizeLogFieldName(key)]
return ok
}
// maskSensitiveValue 按字段名判断并脱敏访问日志中的敏感值
func maskSensitiveValue(key, value string) string {
if value == "" {
return value
}
if shouldMaskField(key) {
return redactedValue
}
if isPhoneField(key) {
return sanitizer.MaskPhone(value)
}
return value
}
// sanitizeQuery 脱敏 query 中的密码、签名、nonce、token 等字段
func sanitizeQuery(rawQuery string) SanitizedContent {
if rawQuery == "" {
return SanitizedContent{}
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return summarize([]byte(rawQuery))
}
for key, items := range values {
for index, item := range items {
items[index] = maskSensitiveValue(key, item)
}
values[key] = items
}
content, truncated := truncateBody([]byte(values.Encode()), MaxBodyLogSize)
return SanitizedContent{Content: content, Size: len(rawQuery), SHA256: digest([]byte(rawQuery)), Truncated: truncated}
}
// sanitizeBody 按策略脱敏正文后再写入访问日志。
func sanitizeBody(rawBody []byte, policy BodyPolicy) SanitizedContent {
if len(rawBody) == 0 {
return SanitizedContent{}
}
if policy == BodyPolicySummary {
return summarize(rawBody)
}
var payload any
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
return summarize(rawBody)
}
sanitizeJSONValue(payload)
data, err := sonic.Marshal(payload)
if err != nil {
return summarize(rawBody)
}
content, truncated := truncateBody(data, MaxBodyLogSize)
return SanitizedContent{Content: content, Size: len(rawBody), SHA256: digest(rawBody), Truncated: truncated}
}
func summarize(raw []byte) SanitizedContent {
return SanitizedContent{Content: "[仅记录安全摘要]", Size: len(raw), SHA256: digest(raw), Truncated: len(raw) > MaxBodyLogSize}
}
func digest(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
// sanitizeJSONValue 递归脱敏 JSON 对象中的敏感字段
func sanitizeJSONValue(value any) {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
if masked, ok := item.(string); ok {
typed[key] = maskSensitiveValue(key, masked)
continue
}
if shouldMaskField(key) {
typed[key] = redactedValue
continue
}
if isPhoneField(key) {
// 手机号类数组逐元素脱敏;非字符串元素不保留原值。
list, ok := item.([]any)
if !ok {
if item != nil {
typed[key] = redactedValue
}
continue
}
for index, element := range list {
if text, ok := element.(string); ok {
list[index] = sanitizer.MaskPhone(text)
continue
}
list[index] = redactedValue
}
typed[key] = list
continue
}
sanitizeJSONValue(item)
}
case []any:
for _, item := range typed {
sanitizeJSONValue(item)
}
}
}
// shouldMaskField 判断字段名是否属于访问日志敏感字段
func shouldMaskField(key string) bool {
return sanitizer.IsForbiddenField(key)
}
// Middleware 创建 Fiber 日志中间件
// 记录所有 HTTP 请求到访问日志(包括请求和响应 body
func Middleware() fiber.Handler {
return MiddlewareWithLogger(GetAccessLogger())
}
// MiddlewareWithLogger 创建可注入访问日志器的 Fiber 中间件。
// 生产环境使用 Middleware该入口让集成测试捕获最终 JSON 日志而无需修改全局状态。
func MiddlewareWithLogger(accessLogger *zap.Logger) fiber.Handler {
return func(c *fiber.Ctx) error {
// 记录请求开始时间
startTime := time.Now()
c.Locals(constants.ContextKeyStartTime, startTime)
// 注入请求上下文,供 Service 层审计日志复用
ctx := c.UserContext()
requestID := ""
if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
if value, ok := rid.(string); ok && value != "" {
requestID = value
ctx = context.WithValue(ctx, constants.ContextKeyRequestID, requestID)
}
}
ctx = context.WithValue(ctx, constants.ContextKeyIP, c.IP())
ctx = context.WithValue(ctx, constants.ContextKeyUserAgent, c.Get("User-Agent"))
ctx = context.WithValue(ctx, constants.ContextKeyRequestPath, c.Path())
ctx = context.WithValue(ctx, constants.ContextKeyRequestMethod, c.Method())
ctx = auditcontext.With(ctx, auditcontext.Context{
RequestID: requestID, CorrelationID: requestID,
RequestPath: c.Path(), RequestMethod: c.Method(),
IPAddress: c.IP(), UserAgent: c.Get("User-Agent"),
})
c.SetUserContext(ctx)
// 获取请求 body在 c.Next() 之前读取)
policy := policyForPath(c.Path())
requestBody := sanitizeWithPolicy(c.Body(), c.Get("Content-Type"), policy)
// 获取 query 参数
queryParams := sanitizeQuery(string(c.Request().URI().QueryString()))
if policy.Sensitive && queryParams.Size > 0 {
queryParams = summarize([]byte(c.Request().URI().QueryString()))
}
// 处理请求
err := c.Next()
// 计算请求持续时间
duration := time.Since(startTime)
// 获取请求 ID由 requestid 中间件设置)
requestID = ""
if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
requestID = rid.(string)
}
// 获取用户 ID由 auth 中间件设置)
var userID uint
if uid := c.Locals(constants.ContextKeyUserID); uid != nil {
if id, ok := uid.(uint); ok {
userID = id
}
}
// 获取响应 body
responseBody := sanitizeWithPolicy(c.Response().Body(), string(c.Response().Header.ContentType()), policy)
// 记录访问日志
accessLogger.Info("",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("body_policy", policy.Name),
zap.String("query", queryParams.Content),
zap.Bool("query_truncated", queryParams.Truncated),
zap.Int("status", c.Response().StatusCode()),
zap.Float64("duration_ms", float64(duration.Microseconds())/1000.0),
zap.String("request_id", requestID),
zap.String("ip", c.IP()),
zap.String("user_agent", c.Get("User-Agent")),
zap.Uint("user_id", userID),
zap.String("request_body", requestBody.Content),
zap.Int("request_body_size", requestBody.Size),
zap.String("request_body_sha256", requestBody.SHA256),
zap.Bool("request_body_truncated", requestBody.Truncated),
zap.String("response_body", responseBody.Content),
zap.Int("response_body_size", responseBody.Size),
zap.String("response_body_sha256", responseBody.SHA256),
zap.Bool("response_body_truncated", responseBody.Truncated),
)
return err
}
}