开放接口增加详细调试日志,并支持控制台与文件双写

- OPEN_API_DEBUG 记录鉴权、查单、推送业务字段与 req_id
- LOG_FILE 默认 logs/app.log,标准日志/Gin/GORM 同时输出控制台与文件
This commit is contained in:
yml2213
2026-07-20 21:48:52 +08:00
parent 24d4e76c7f
commit 47b0dd5a49
12 changed files with 313 additions and 20 deletions
+4
View File
@@ -15,6 +15,10 @@ OPEN_API_KEY=sk_source_dev_key_change_me
OPEN_API_SECRET=sk_source_dev_secret_change_me
# 签名时间戳允许偏差(秒)
OPEN_SIGN_SKEW=300
# 开放接口详细调试日志:1 开启 / 0 关闭(默认 GIN_MODE=debug 时开启)
OPEN_API_DEBUG=1
# 日志文件(相对 backend 工作目录);非空则控制台 + 文件双写,留空则仅控制台
LOG_FILE=logs/app.log
# ========== 前端(Vite / 一键启动)==========
# 前端开发端口
+1
View File
@@ -10,6 +10,7 @@ frontend/.env
# backend
backend/bin/
backend/data/
backend/logs/
*.db
# frontend
+1
View File
@@ -1,5 +1,6 @@
bin/
data/
logs/
*.db
*.exe
.DS_Store
+22 -2
View File
@@ -8,18 +8,29 @@ import (
"affiliate_dash/internal/config"
"affiliate_dash/internal/handler"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/applog"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/router"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
cfg := config.Load()
// 日志:控制台 + 文件
logFile, err := applog.Setup(cfg.LogFile)
if err != nil {
log.Fatalf("setup log: %v", err)
}
if logFile != nil {
defer logFile.Close()
}
gin.SetMode(cfg.Mode)
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
@@ -27,7 +38,7 @@ func main() {
}
db, err := gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
Logger: applog.NewGormLogger(cfg.Mode),
})
if err != nil {
log.Fatalf("open db: %v", err)
@@ -50,6 +61,8 @@ func main() {
log.Printf("seed skins: %v", err)
}
openlog.Init(cfg.OpenAPIDebug)
h := &router.Handlers{
Auth: handler.NewAuthHandler(authSvc),
Skin: handler.NewSkinHandler(skinSvc),
@@ -60,6 +73,7 @@ func main() {
OpenAPIKey: cfg.OpenAPIKey,
OpenAPISecret: cfg.OpenAPISecret,
OpenSignSkew: cfg.OpenSignSkew,
OpenAPIDebug: cfg.OpenAPIDebug,
}
r := router.Setup(h)
@@ -67,6 +81,12 @@ func main() {
log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr)
log.Printf("默认管理员: admin / admin123")
log.Printf("开放接口鉴权: X-Api-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)")
if cfg.OpenAPIDebug {
log.Printf("开放接口调试日志: 开启 (OPEN_API_DEBUG=0 可关闭)")
}
if cfg.LogFile != "" {
log.Printf("日志文件: %s (同时输出控制台)", cfg.LogFile)
}
if err := r.Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+2
View File
@@ -34,6 +34,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+26 -1
View File
@@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/joho/godotenv"
)
@@ -19,19 +20,43 @@ type Config struct {
OpenAPISecret string
// OpenSignSkew 签名时间戳允许偏差(秒)
OpenSignSkew int64
// OpenAPIDebug 开放接口详细调试日志
OpenAPIDebug bool
// LogFile 日志文件路径;非空时同时写控制台与文件
LogFile string
}
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
func Load() *Config {
loadDotEnv()
mode := getEnv("GIN_MODE", "debug")
// OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启
debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "")
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
DBPath: getEnv("DB_PATH", "data/app.db"),
Mode: getEnv("GIN_MODE", "debug"),
Mode: mode,
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
OpenAPIDebug: debugOpen,
LogFile: getEnv("LOG_FILE", "logs/app.log"),
}
}
func getEnvBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return def
}
}
+24
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"time"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -26,8 +27,11 @@ func (h *OpenHandler) QueryOrder(c *gin.Context) {
if orderNo == "" {
orderNo = c.Query("order_no")
}
openlog.Info(c, "query_order start order_no=%s", orderNo)
data, err := h.orderSvc.QueryOpenOrder(orderNo)
if err != nil {
openlog.Warn(c, "query_order fail order_no=%s err=%s", orderNo, err.Error())
if err.Error() == "订单不存在" {
response.NotFound(c, err.Error())
return
@@ -35,6 +39,15 @@ func (h *OpenHandler) QueryOrder(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
sku, name, game := "", "", ""
if data.Product != nil {
sku, name, game = data.Product.SKU, data.Product.Name, data.Product.Game
}
openlog.Info(c, "query_order ok order_no=%s status=%s can_ship=%v reason=%q sku=%s name=%s game=%s buyer=%s amount=%.2f provider_no=%s shipped_at=%v fail_reason=%q",
data.OrderNo, data.Status, data.CanShip, data.CannotShipReason,
sku, name, game, data.BuyerName, data.Amount, data.ProviderOrderNo, data.ShippedAt, data.ShipFailReason,
)
response.OK(c, data)
}
@@ -51,15 +64,21 @@ type shipNotifyReq struct {
func (h *OpenHandler) ShipNotify(c *gin.Context) {
var req shipNotifyReq
if err := c.ShouldBindJSON(&req); err != nil {
openlog.Warn(c, "ship_notify bind_fail err=%v", err)
response.BadRequest(c, "参数错误:order_no 与 ship_status 必填")
return
}
raw, _ := json.Marshal(req)
openlog.Info(c, "ship_notify start order_no=%s ship_status=%s provider_no=%s shipped_at=%s fail_reason=%q payload=%s",
req.OrderNo, req.ShipStatus, req.ProviderOrderNo, req.ShippedAt, req.FailReason, openlog.Truncate(string(raw), 800),
)
var shippedAt *time.Time
if req.ShippedAt != "" {
t, err := time.Parse(time.RFC3339, req.ShippedAt)
if err != nil {
openlog.Warn(c, "ship_notify bad_shipped_at raw=%s err=%v", req.ShippedAt, err)
response.BadRequest(c, "shipped_at 格式错误,请使用 RFC3339,如 2026-07-20T16:00:00+08:00")
return
}
@@ -75,6 +94,8 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) {
RawPayload: string(raw),
})
if err != nil {
openlog.Warn(c, "ship_notify fail order_no=%s ship_status=%s err=%s",
req.OrderNo, req.ShipStatus, err.Error())
if err.Error() == "订单不存在" {
response.NotFound(c, err.Error())
return
@@ -82,5 +103,8 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
openlog.Info(c, "ship_notify ok order_no=%s result_status=%s message=%s",
result.OrderNo, result.Status, result.Message)
response.OK(c, result)
}
+49 -17
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"github.com/gin-gonic/gin"
@@ -23,6 +24,8 @@ type OpenAuthConfig struct {
APISecret string
// 允许的时间偏差(秒),默认 300
SkewSeconds int64
// Debug 详细日志
Debug bool
}
// nonce 防重放(进程内,重启清空;生产可换 Redis)
@@ -56,10 +59,7 @@ func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
//
// api_key, body, method, nonce, path, timestamp
//
// 按 key 字典序排序后拼接
//
// k1=v1&k2=v2&...
//
// 按 key 字典序排序后拼接 k1=v1&k2=v2&...
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
@@ -69,7 +69,13 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
store := newNonceStore()
return func(c *gin.Context) {
reqID := openlog.EnsureReqID(c)
c.Set(openlog.CtxDebug, cfg.Debug)
c.Set(openlog.CtxStart, time.Now())
c.Header("X-Request-Id", reqID)
if cfg.APIKey == "" || cfg.APISecret == "" {
openlog.Warn(c, "auth_fail reason=server_not_configured")
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
c.Abort()
return
@@ -79,18 +85,43 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
clientIP := c.ClientIP()
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
openlog.Warn(c, "auth_fail reason=read_body_error err=%v ip=%s", err, clientIP)
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
body := string(bodyBytes)
c.Set(openlog.CtxBody, body)
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
openlog.Info(c, "request in method=%s path=%s ip=%s key=%s ts=%s nonce=%s sign=%s body=%q body_len=%d",
method, path, clientIP,
openlog.MaskKey(apiKey), timestamp, nonce, openlog.MaskSign(sign),
openlog.Truncate(body, 500), len(bodyBytes),
)
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
openlog.Warn(c, "auth_fail reason=missing_headers key=%s ts=%s nonce=%s has_sign=%v",
openlog.MaskKey(apiKey), timestamp, nonce, sign != "")
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if apiKey != cfg.APIKey {
openlog.Warn(c, "auth_fail reason=invalid_api_key key=%s", openlog.MaskKey(apiKey))
response.Unauthorized(c, "无效的 API Key")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 64 {
openlog.Warn(c, "auth_fail reason=bad_nonce_len len=%d", len(nonce))
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
c.Abort()
return
@@ -98,44 +129,45 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
openlog.Warn(c, "auth_fail reason=bad_timestamp raw=%s", timestamp)
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
c.Abort()
return
}
now := time.Now().Unix()
if abs64(now-ts) > cfg.SkewSeconds {
skew := now - ts
if abs64(skew) > cfg.SkewSeconds {
openlog.Warn(c, "auth_fail reason=timestamp_skew server_now=%d ts=%d skew=%ds limit=%ds",
now, ts, skew, cfg.SkewSeconds)
response.Unauthorized(c, "请求已过期或时间偏差过大")
c.Abort()
return
}
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
openlog.Warn(c, "auth_fail reason=replay_nonce nonce=%s", nonce)
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
response.BadRequest(c, "读取请求体失败")
c.Abort()
return
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
body := string(bodyBytes)
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
// 调试时输出待签名串(不含 secret),便于源头对齐
openlog.Warn(c, "auth_fail reason=sign_mismatch sign=%s expected_prefix=%s string_to_sign=%q",
openlog.MaskSign(sign), openlog.MaskSign(expected), openlog.Truncate(stringToSign, 800))
response.Unauthorized(c, "签名校验失败")
c.Abort()
return
}
c.Set(openlog.CtxAPIKey, apiKey)
openlog.Info(c, "auth_ok skew=%ds string_to_sign_len=%d", skew, len(stringToSign))
c.Next()
// 请求结束后补一条总耗时(handler 里会打业务结果)
openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond))
}
}
+62
View File
@@ -0,0 +1,62 @@
package applog
import (
"io"
"log"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm/logger"
)
// Setup 将标准库 log、Gin 请求日志同时写到控制台与文件。
// logFile 为空则仅控制台。返回文件句柄供进程结束时 Close(可为 nil)。
func Setup(logFile string) (*os.File, error) {
writers := []io.Writer{os.Stdout}
var f *os.File
if logFile != "" {
dir := filepath.Dir(logFile)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
var err error
f, err = os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
writers = append(writers, f)
}
mw := io.MultiWriter(writers...)
log.SetOutput(mw)
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
// Gin access / recovery 日志
gin.DefaultWriter = mw
gin.DefaultErrorWriter = mw
if logFile != "" {
abs, _ := filepath.Abs(logFile)
log.Printf("[applog] 日志同时输出到控制台与文件: %s", abs)
}
return f, nil
}
// NewGormLogger 返回写到当前 log 输出(已含 MultiWriter)的 GORM 日志
func NewGormLogger(mode string) logger.Interface {
level := logger.Info
if mode == "release" {
level = logger.Warn
}
return logger.New(
log.New(log.Writer(), "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: level,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
}
+119
View File
@@ -0,0 +1,119 @@
package openlog
import (
"fmt"
"log"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const (
CtxReqID = "open_req_id"
CtxDebug = "open_debug"
CtxBody = "open_body"
CtxStart = "open_start"
CtxAPIKey = "open_api_key"
)
// Enabled 是否开启开放接口详细日志
var Enabled bool
func Init(enabled bool) {
Enabled = enabled
if enabled {
log.Printf("[open] 开放接口调试日志已开启 (OPEN_API_DEBUG)")
}
}
func NewReqID() string {
return strings.ReplaceAll(uuid.NewString(), "-", "")[:12]
}
func GetReqID(c *gin.Context) string {
if v, ok := c.Get(CtxReqID); ok {
if s, ok := v.(string); ok {
return s
}
}
return "-"
}
func EnsureReqID(c *gin.Context) string {
if id := GetReqID(c); id != "-" {
return id
}
id := NewReqID()
c.Set(CtxReqID, id)
return id
}
func IsDebug(c *gin.Context) bool {
if !Enabled {
return false
}
if v, ok := c.Get(CtxDebug); ok {
if b, ok := v.(bool); ok {
return b
}
}
return Enabled
}
// MaskKey 脱敏 api_key:保留前缀与后 4 位
func MaskKey(key string) string {
if key == "" {
return "(empty)"
}
if len(key) <= 8 {
return key[:2] + "***"
}
return key[:8] + "***" + key[len(key)-4:]
}
// MaskSign 脱敏签名:只保留前 8 位
func MaskSign(sign string) string {
if sign == "" {
return "(empty)"
}
if len(sign) <= 8 {
return sign + "..."
}
return sign[:8] + "..."
}
// Truncate 截断过长字符串
func Truncate(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("...(%d bytes)", len(s))
}
func Info(c *gin.Context, format string, args ...interface{}) {
if !IsDebug(c) {
return
}
prefix := fmt.Sprintf("[open] req_id=%s ", GetReqID(c))
log.Printf(prefix+format, args...)
}
func Warn(c *gin.Context, format string, args ...interface{}) {
// 鉴权失败等也值得在 debug 时打出
if !Enabled {
return
}
prefix := fmt.Sprintf("[open] req_id=%s ", GetReqID(c))
log.Printf(prefix+format, args...)
}
func Elapsed(c *gin.Context) time.Duration {
if v, ok := c.Get(CtxStart); ok {
if t, ok := v.(time.Time); ok {
return time.Since(t)
}
}
return 0
}
+2
View File
@@ -20,6 +20,7 @@ type Handlers struct {
OpenAPIKey string
OpenAPISecret string
OpenSignSkew int64
OpenAPIDebug bool
}
func Setup(h *Handlers) *gin.Engine {
@@ -48,6 +49,7 @@ func Setup(h *Handlers) *gin.Engine {
APIKey: h.OpenAPIKey,
APISecret: h.OpenAPISecret,
SkewSeconds: h.OpenSignSkew,
Debug: h.OpenAPIDebug,
}))
{
open.GET("/orders/:order_no", h.Open.QueryOrder)