依据《代码分析结果.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 范围
237 lines
6.2 KiB
Go
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 ""
|
|
}
|