diff --git a/.env.example b/.env.example index 16db981..c71ab8d 100644 --- a/.env.example +++ b/.env.example @@ -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 / 一键启动)========== # 前端开发端口 diff --git a/.gitignore b/.gitignore index fbcf18c..b081867 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ frontend/.env # backend backend/bin/ backend/data/ +backend/logs/ *.db # frontend diff --git a/backend/.gitignore b/backend/.gitignore index 79d8818..b02b88f 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,5 +1,6 @@ bin/ data/ +logs/ *.db *.exe .DS_Store diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ce7bf24..192e6aa 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) } diff --git a/backend/go.mod b/backend/go.mod index 46bbbb7..2bcd284 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 9824e6b..31dab42 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 4ea83ad..706b74d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } } diff --git a/backend/internal/handler/open.go b/backend/internal/handler/open.go index c43e083..bb38d7a 100644 --- a/backend/internal/handler/open.go +++ b/backend/internal/handler/open.go @@ -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) } diff --git a/backend/internal/middleware/open_auth.go b/backend/internal/middleware/open_auth.go index 73a1cdd..db80ebe 100644 --- a/backend/internal/middleware/open_auth.go +++ b/backend/internal/middleware/open_auth.go @@ -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)) } } diff --git a/backend/internal/pkg/applog/applog.go b/backend/internal/pkg/applog/applog.go new file mode 100644 index 0000000..fcbc289 --- /dev/null +++ b/backend/internal/pkg/applog/applog.go @@ -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, + }, + ) +} diff --git a/backend/internal/pkg/openlog/openlog.go b/backend/internal/pkg/openlog/openlog.go new file mode 100644 index 0000000..e0229de --- /dev/null +++ b/backend/internal/pkg/openlog/openlog.go @@ -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 +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4fbb160..04726a8 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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)