Files
team-api/internal/response/response.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

149 lines
4.5 KiB
Go

package response
import (
"errors"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gvalid"
"github.com/qianfree/team-api/internal/consts"
)
// jsonResp is the unified response structure for all API endpoints.
type jsonResp struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
RequestID string `json:"request_id"`
}
// Success writes a successful response: HTTP 200 + {"code":0, "message":"ok", "data":..., "request_id":...}.
func Success(r *ghttp.Request, data interface{}) {
requestID := getRequestID(r)
r.Response.WriteJson(jsonResp{
Code: 0,
Message: consts.MsgSuccess,
Data: data,
RequestID: requestID,
})
r.SetError(nil) // 清除请求上的错误,防止 ErrorHandler 中间件重复处理
}
// Error writes an error response with automatic sanitization.
//
// Security: Only business errors (code >= 10000), validation errors, and standard HTTP 4xx errors
// expose their messages to the client. All other errors (database, network,
// internal) are replaced with "服务器内部错误" to prevent information leakage.
// The original error is logged with request_id for debugging.
func Error(r *ghttp.Request, err error) {
code := 500
msg := "服务器内部错误"
// Check for GoFrame validation error first (gvalid.Error is NOT a gerror.Error,
// so the gerror branch below won't match it — must check separately)
var validErr gvalid.Error
if errors.As(err, &validErr) {
code = 422
msg = validErr.Current().Error()
} else {
// Extract code from GoFrame gerror (supports wrapped errors via errors.As)
var gerr *gerror.Error
if errors.As(err, &gerr) {
code = gerr.Code().Code()
// Business errors (>= 10000) and HTTP 4xx have safe user-facing messages
if code >= 10000 || (code >= 400 && code < 500) {
msg = gerr.Error()
} else {
// Internal error — sanitize message, log real cause
g.Log().Warning(r.Context(), "[Response] Internal error sanitized",
"request_id", getRequestID(r),
"code", code,
"error", err.Error(),
)
// Persist system error to sys_error_logs
writeErrorLog(&ErrorLogRecord{
RequestId: getRequestID(r),
ErrorCode: code,
ErrorMessage: err.Error(),
StackTrace: captureStackTrace(3),
HttpMethod: r.Method,
RequestPath: r.URL.Path,
RequestBody: safeRequestBodyForLog(r),
Source: "api",
})
}
} else {
// Non-gerror (raw Go error, e.g. database driver error) — always sanitize
g.Log().Warning(r.Context(), "[Response] Raw error sanitized",
"request_id", getRequestID(r),
"error", err.Error(),
)
// Persist raw error to sys_error_logs
writeErrorLog(&ErrorLogRecord{
RequestId: getRequestID(r),
ErrorCode: 500,
ErrorMessage: err.Error(),
StackTrace: captureStackTrace(3),
HttpMethod: r.Method,
RequestPath: r.URL.Path,
RequestBody: safeRequestBodyForLog(r),
Source: "api",
})
}
}
if code == 0 {
code = 500
}
// Determine HTTP status
httpStatus := code
if code >= 10000 {
httpStatus = 422
} else if httpStatus < 100 || httpStatus > 599 {
// GoFrame gcode 可能返回非标准错误码(如 68=CodeNotModified),
// 不能直接用作 HTTP 状态码,否则 WriteHeader 会 panic
httpStatus = 500
}
requestID := getRequestID(r)
r.Response.WriteHeader(httpStatus)
r.Response.WriteJson(jsonResp{
Code: code,
Message: msg,
Data: nil,
RequestID: requestID,
})
r.SetError(nil) // 清除请求上的错误,防止 ErrorHandler 中间件重复处理
}
// Use this for 400/401/403/404/409/429/500 errors.
func ErrorMsg(r *ghttp.Request, code int, message string) {
ErrorWithCode(r, code, code, message)
}
// ErrorWithCode writes an error with separate HTTP status and business code.
// Use this when HTTP status differs from the business code (e.g., HTTP 401 with code 10020).
func ErrorWithCode(r *ghttp.Request, httpStatus int, code int, message string) {
requestID := getRequestID(r)
r.Response.WriteHeader(httpStatus)
r.Response.WriteJson(jsonResp{
Code: code,
Message: message,
Data: nil,
RequestID: requestID,
})
r.SetError(nil) // 清除请求上的错误,防止 ErrorHandler 中间件重复处理
}
// getRequestID extracts the request ID from context.
func getRequestID(r *ghttp.Request) string {
if v := r.GetCtxVar("RequestId"); v != nil {
return v.String()
}
return ""
}