Files
team-api/internal/response/error_log.go
T
martin a6ed67d841 fix: 完成 P3 细节优化项
依据《代码分析结果.md》P3 章节,逐条修复 20 项细节问题:

正确性修复:
- P3-1 锁定剩余分钟改用 math.Ceil,避免「0.9 分钟」显示为「0 分钟」
- P3-12 移除 CheckAndUpgradeLevel 中恒真的 hasUpdate 标志
- P3-14 移除 CheckTrialExpiry 中恒真表达式(count > 0 || true)调试残留,改用 RowsAffected
- P3-2 成员不存在统一返回 NewNotFoundError(404),与其它方法语义一致

安全加固:
- P3-6 X-Request-Id 加格式校验 ^[A-Za-z0-9-]{1,64}$,防日志注入
- P3-7 新增公共 EscapeLikePattern,转义 admin LIKE 搜索的 %/_ 通配符
- P3-8 Encrypt 输出加 v1: 版本前缀,Decrypt 兼容无前缀历史密文
- P3-9 turnstile 响应体读取加 64KB 上限,防内存耗尽
- P3-11 backup codes 熵由 48-bit 提升到 64-bit
- P3-15 recordAudit 对 multipart/二进制体跳过 Unmarshal 与原文落库
- P3-17 错误日志按 Content-Type 跳过 multipart/二进制体读取
- P3-16 HandleRealtime 加 panic recover 与连接生命周期日志

可维护性/一致性:
- P3-3 oauth.go 时间格式统一为 time.RFC3339
- P3-5 MsgSuccess 统一为 ok,response.go 改用常量引用
- P3-4 错误码分段策略加注释,说明空洞与跳跃的成因
- P3-13 ModelMultiplier 占位语义加注释(计费公式设计位 + 快照列)
- P3-19 抽取 registerCronJobs,主启动流程清爽

重构:
- P3-18 删除死代码 BuildBillingSummary/SummaryParams(仅测试引用),保留共用 helper
- P3-20 抽取 relay.InvalidateMemberModelScopeCache 单一失效入口,
  消除三处 member_model 前缀字面量重复(原为有意跨包失效,原建议改名会破坏)

经核实保留 + 文档化:
- P3-10 TOTP 暂保留 SHA-1:totp.Validate 硬编码 SHA-1 且存储不保存算法元信息,
  直接切 SHA-256 会导致校验恒失败,需配合 schema 改动,超出 P3 范围
2026-07-26 12:16:33 +08:00

237 lines
6.2 KiB
Go

package response
import (
"context"
"encoding/json"
"errors"
"fmt"
"runtime"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/common"
)
// ErrorLogRecord is a lightweight struct for async error log persistence.
type ErrorLogRecord struct {
RequestId string
ErrorCode int
ErrorMessage string
StackTrace string
HttpMethod string
RequestPath string
RequestBody string
Source string
}
// errorLogWriter is the global async writer for sys_error_logs.
var errorLogWriter *common.UsageLogWriter
// InitErrorLogWriter initializes the global error log writer.
func InitErrorLogWriter() {
errorLogWriter = common.NewUsageLogWriter(common.UsageLogWriterConfig{
Table: "sys_error_logs",
QueueSize: 2048,
BatchSize: 16,
FlushInterval: 2 * time.Second,
Workers: 2,
Overflow: common.OverflowDrop,
})
errorLogWriter.Start()
}
// CloseErrorLogWriter flushes and closes the global error log writer.
func CloseErrorLogWriter() {
if errorLogWriter != nil {
errorLogWriter.Close()
}
}
// captureStackTrace captures the current goroutine's stack trace.
func captureStackTrace(skip int) string {
pcs := make([]uintptr, 32)
n := runtime.Callers(skip+2, pcs)
if n == 0 {
return ""
}
frames := runtime.CallersFrames(pcs[:n])
var b strings.Builder
for {
frame, more := frames.Next()
fmt.Fprintf(&b, "%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line)
if !more {
break
}
}
return b.String()
}
// truncateString truncates a string to maxLen bytes.
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// sensitiveBodyKeys 命中这些子串(大小写不敏感)的 JSON 字段值会被脱敏,
// 防止明文密码/密钥/令牌/验证码等随请求体写入 sys_error_logs。
var sensitiveBodyKeys = []string{
"password", "passwd", "pwd", "secret", "token",
"api_key", "apikey", "access_key", "private_key",
"credential", "authorization", "otp", "totp", "code",
}
// isSensitiveKey 判断字段名是否命中敏感词。
func isSensitiveKey(key string) bool {
lower := strings.ToLower(key)
for _, s := range sensitiveBodyKeys {
if strings.Contains(lower, s) {
return true
}
}
return false
}
// redactValue 递归脱敏 map/slice 中命中敏感词的字段。
func redactValue(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
for k, item := range val {
if isSensitiveKey(k) {
val[k] = "[REDACTED]"
} else {
val[k] = redactValue(item)
}
}
return val
case []interface{}:
for i, item := range val {
val[i] = redactValue(item)
}
return val
default:
return v
}
}
// sanitizeRequestBody 对 JSON 请求体做敏感字段脱敏后返回。
// 非 JSON 或解析失败时无法可靠定位敏感字段,直接返回占位符而不落原文,
// 确保任何情况下都不会把明文密码/密钥写入错误日志表。
func sanitizeRequestBody(body string) string {
if body == "" {
return ""
}
var parsed interface{}
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
return "[non-json body omitted]"
}
out, err := json.Marshal(redactValue(parsed))
if err != nil {
return "[redaction failed]"
}
return string(out)
}
// safeRequestBodyForLog 按 Content-Type 决定是否读取并记录请求体:
// - multipart/二进制:跳过 r.GetBodyString()(避免把整段 base64 图片/二进制读入
// 内存并落库),直接返回占位符;
// - 其它:脱敏 + 截断后返回。
//
// 调用方原本无条件 r.GetBodyString(),对大体积 multipart 上传会把整个文件读进内存,
// 即便后续 sanitize 会丢弃也无法省去这次拷贝。
func safeRequestBodyForLog(r *ghttp.Request) string {
contentType := r.Header.Get("Content-Type")
// multipart/form-data、image/*、audio/*、video/*、application/octet-stream 等均视为二进制
if isBinaryContentType(contentType) {
return "[binary body omitted]"
}
return truncateString(sanitizeRequestBody(r.GetBodyString()), 2000)
}
// isBinaryContentType 判断 Content-Type 是否为不应落库的二进制/大体积类型。
func isBinaryContentType(contentType string) bool {
lower := strings.ToLower(contentType)
if lower == "" {
return false
}
// 取分号前的主类型,如 "multipart/form-data; boundary=..." → "multipart/form-data"
if i := strings.IndexByte(lower, ';'); i >= 0 {
lower = strings.TrimSpace(lower[:i])
}
switch {
case strings.HasPrefix(lower, "multipart/"):
return true
case strings.HasPrefix(lower, "image/"):
return true
case strings.HasPrefix(lower, "audio/"):
return true
case strings.HasPrefix(lower, "video/"):
return true
case lower == "application/octet-stream":
return true
}
return false
}
// writeErrorLog submits an error record to the async writer.
func writeErrorLog(r *ErrorLogRecord) {
if errorLogWriter == nil {
return
}
errorLogWriter.Submit(r)
}
// WriteErrorLogFromCtx writes an error log from non-HTTP context (cron/background).
func WriteErrorLogFromCtx(ctx context.Context, err error, source string) {
if errorLogWriter == nil {
return
}
code := 500
var gerr *gerror.Error
if errors.As(err, &gerr) {
code = gerr.Code().Code()
if code == 0 {
code = 500
}
}
errorLogWriter.Submit(&ErrorLogRecord{
ErrorCode: code,
ErrorMessage: err.Error(),
StackTrace: captureStackTrace(2),
Source: source,
RequestId: getRequestIDFromCtx(ctx),
})
}
// WritePanicLog writes a panic record to sys_error_logs (called from Recovery middleware).
func WritePanicLog(requestID, httpMethod, requestPath, errMsg, stack string) {
if errorLogWriter == nil {
return
}
errorLogWriter.Submit(&ErrorLogRecord{
RequestId: requestID,
ErrorCode: 500,
ErrorMessage: errMsg,
StackTrace: stack,
HttpMethod: httpMethod,
RequestPath: requestPath,
Source: "panic",
})
}
// getRequestIDFromCtx extracts request ID from GoFrame context.
func getRequestIDFromCtx(ctx context.Context) string {
if ctx != nil {
if v := ctx.Value("RequestId"); v != nil {
if s, ok := v.(string); ok {
return s
}
}
}
return ""
}