开放接口增加详细调试日志,并支持控制台与文件双写
- OPEN_API_DEBUG 记录鉴权、查单、推送业务字段与 req_id - LOG_FILE 默认 logs/app.log,标准日志/Gin/GORM 同时输出控制台与文件
This commit is contained in:
@@ -15,6 +15,10 @@ OPEN_API_KEY=sk_source_dev_key_change_me
|
|||||||
OPEN_API_SECRET=sk_source_dev_secret_change_me
|
OPEN_API_SECRET=sk_source_dev_secret_change_me
|
||||||
# 签名时间戳允许偏差(秒)
|
# 签名时间戳允许偏差(秒)
|
||||||
OPEN_SIGN_SKEW=300
|
OPEN_SIGN_SKEW=300
|
||||||
|
# 开放接口详细调试日志:1 开启 / 0 关闭(默认 GIN_MODE=debug 时开启)
|
||||||
|
OPEN_API_DEBUG=1
|
||||||
|
# 日志文件(相对 backend 工作目录);非空则控制台 + 文件双写,留空则仅控制台
|
||||||
|
LOG_FILE=logs/app.log
|
||||||
|
|
||||||
# ========== 前端(Vite / 一键启动)==========
|
# ========== 前端(Vite / 一键启动)==========
|
||||||
# 前端开发端口
|
# 前端开发端口
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ frontend/.env
|
|||||||
# backend
|
# backend
|
||||||
backend/bin/
|
backend/bin/
|
||||||
backend/data/
|
backend/data/
|
||||||
|
backend/logs/
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# frontend
|
# frontend
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
bin/
|
bin/
|
||||||
data/
|
data/
|
||||||
|
logs/
|
||||||
*.db
|
*.db
|
||||||
*.exe
|
*.exe
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -8,18 +8,29 @@ import (
|
|||||||
"affiliate_dash/internal/config"
|
"affiliate_dash/internal/config"
|
||||||
"affiliate_dash/internal/handler"
|
"affiliate_dash/internal/handler"
|
||||||
"affiliate_dash/internal/model"
|
"affiliate_dash/internal/model"
|
||||||
|
"affiliate_dash/internal/pkg/applog"
|
||||||
"affiliate_dash/internal/pkg/jwt"
|
"affiliate_dash/internal/pkg/jwt"
|
||||||
|
"affiliate_dash/internal/pkg/openlog"
|
||||||
"affiliate_dash/internal/router"
|
"affiliate_dash/internal/router"
|
||||||
"affiliate_dash/internal/service"
|
"affiliate_dash/internal/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
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)
|
gin.SetMode(cfg.Mode)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
|
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{
|
db, err := gorm.Open(sqlite.Open(cfg.DBPath), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
Logger: applog.NewGormLogger(cfg.Mode),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open db: %v", err)
|
log.Fatalf("open db: %v", err)
|
||||||
@@ -50,6 +61,8 @@ func main() {
|
|||||||
log.Printf("seed skins: %v", err)
|
log.Printf("seed skins: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openlog.Init(cfg.OpenAPIDebug)
|
||||||
|
|
||||||
h := &router.Handlers{
|
h := &router.Handlers{
|
||||||
Auth: handler.NewAuthHandler(authSvc),
|
Auth: handler.NewAuthHandler(authSvc),
|
||||||
Skin: handler.NewSkinHandler(skinSvc),
|
Skin: handler.NewSkinHandler(skinSvc),
|
||||||
@@ -60,6 +73,7 @@ func main() {
|
|||||||
OpenAPIKey: cfg.OpenAPIKey,
|
OpenAPIKey: cfg.OpenAPIKey,
|
||||||
OpenAPISecret: cfg.OpenAPISecret,
|
OpenAPISecret: cfg.OpenAPISecret,
|
||||||
OpenSignSkew: cfg.OpenSignSkew,
|
OpenSignSkew: cfg.OpenSignSkew,
|
||||||
|
OpenAPIDebug: cfg.OpenAPIDebug,
|
||||||
}
|
}
|
||||||
|
|
||||||
r := router.Setup(h)
|
r := router.Setup(h)
|
||||||
@@ -67,6 +81,12 @@ func main() {
|
|||||||
log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr)
|
log.Printf("游戏皮肤分销系统 API 启动: http://localhost%s", addr)
|
||||||
log.Printf("默认管理员: admin / admin123")
|
log.Printf("默认管理员: admin / admin123")
|
||||||
log.Printf("开放接口鉴权: X-Api-Key + X-Timestamp + X-Nonce + X-Sign (HMAC-SHA256)")
|
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 {
|
if err := r.Run(addr); err != nil {
|
||||||
log.Fatalf("server: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
|||||||
@@ -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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
)
|
)
|
||||||
@@ -19,19 +20,43 @@ type Config struct {
|
|||||||
OpenAPISecret string
|
OpenAPISecret string
|
||||||
// OpenSignSkew 签名时间戳允许偏差(秒)
|
// OpenSignSkew 签名时间戳允许偏差(秒)
|
||||||
OpenSignSkew int64
|
OpenSignSkew int64
|
||||||
|
// OpenAPIDebug 开放接口详细调试日志
|
||||||
|
OpenAPIDebug bool
|
||||||
|
// LogFile 日志文件路径;非空时同时写控制台与文件
|
||||||
|
LogFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
loadDotEnv()
|
loadDotEnv()
|
||||||
|
mode := getEnv("GIN_MODE", "debug")
|
||||||
|
// OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启
|
||||||
|
debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "")
|
||||||
return &Config{
|
return &Config{
|
||||||
Port: getEnv("PORT", "8080"),
|
Port: getEnv("PORT", "8080"),
|
||||||
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
|
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
|
||||||
DBPath: getEnv("DB_PATH", "data/app.db"),
|
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"),
|
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
|
||||||
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
|
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
|
||||||
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/pkg/openlog"
|
||||||
"affiliate_dash/internal/pkg/response"
|
"affiliate_dash/internal/pkg/response"
|
||||||
"affiliate_dash/internal/service"
|
"affiliate_dash/internal/service"
|
||||||
|
|
||||||
@@ -26,8 +27,11 @@ func (h *OpenHandler) QueryOrder(c *gin.Context) {
|
|||||||
if orderNo == "" {
|
if orderNo == "" {
|
||||||
orderNo = c.Query("order_no")
|
orderNo = c.Query("order_no")
|
||||||
}
|
}
|
||||||
|
openlog.Info(c, "query_order start order_no=%s", orderNo)
|
||||||
|
|
||||||
data, err := h.orderSvc.QueryOpenOrder(orderNo)
|
data, err := h.orderSvc.QueryOpenOrder(orderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
openlog.Warn(c, "query_order fail order_no=%s err=%s", orderNo, err.Error())
|
||||||
if err.Error() == "订单不存在" {
|
if err.Error() == "订单不存在" {
|
||||||
response.NotFound(c, err.Error())
|
response.NotFound(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -35,6 +39,15 @@ func (h *OpenHandler) QueryOrder(c *gin.Context) {
|
|||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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)
|
response.OK(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,15 +64,21 @@ type shipNotifyReq struct {
|
|||||||
func (h *OpenHandler) ShipNotify(c *gin.Context) {
|
func (h *OpenHandler) ShipNotify(c *gin.Context) {
|
||||||
var req shipNotifyReq
|
var req shipNotifyReq
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
openlog.Warn(c, "ship_notify bind_fail err=%v", err)
|
||||||
response.BadRequest(c, "参数错误:order_no 与 ship_status 必填")
|
response.BadRequest(c, "参数错误:order_no 与 ship_status 必填")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, _ := json.Marshal(req)
|
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
|
var shippedAt *time.Time
|
||||||
if req.ShippedAt != "" {
|
if req.ShippedAt != "" {
|
||||||
t, err := time.Parse(time.RFC3339, req.ShippedAt)
|
t, err := time.Parse(time.RFC3339, req.ShippedAt)
|
||||||
if err != nil {
|
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")
|
response.BadRequest(c, "shipped_at 格式错误,请使用 RFC3339,如 2026-07-20T16:00:00+08:00")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -75,6 +94,8 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) {
|
|||||||
RawPayload: string(raw),
|
RawPayload: string(raw),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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() == "订单不存在" {
|
if err.Error() == "订单不存在" {
|
||||||
response.NotFound(c, err.Error())
|
response.NotFound(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -82,5 +103,8 @@ func (h *OpenHandler) ShipNotify(c *gin.Context) {
|
|||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
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)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"affiliate_dash/internal/pkg/openlog"
|
||||||
"affiliate_dash/internal/pkg/response"
|
"affiliate_dash/internal/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -23,6 +24,8 @@ type OpenAuthConfig struct {
|
|||||||
APISecret string
|
APISecret string
|
||||||
// 允许的时间偏差(秒),默认 300
|
// 允许的时间偏差(秒),默认 300
|
||||||
SkewSeconds int64
|
SkewSeconds int64
|
||||||
|
// Debug 详细日志
|
||||||
|
Debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// nonce 防重放(进程内,重启清空;生产可换 Redis)
|
// nonce 防重放(进程内,重启清空;生产可换 Redis)
|
||||||
@@ -56,10 +59,7 @@ func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
|
|||||||
//
|
//
|
||||||
// api_key, body, method, nonce, path, timestamp
|
// api_key, body, method, nonce, path, timestamp
|
||||||
//
|
//
|
||||||
// 按 key 字典序排序后拼接:
|
// 按 key 字典序排序后拼接 k1=v1&k2=v2&...
|
||||||
//
|
|
||||||
// k1=v1&k2=v2&...
|
|
||||||
//
|
|
||||||
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
|
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
|
||||||
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
|
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
|
||||||
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||||
@@ -69,7 +69,13 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
|||||||
store := newNonceStore()
|
store := newNonceStore()
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
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 == "" {
|
if cfg.APIKey == "" || cfg.APISecret == "" {
|
||||||
|
openlog.Warn(c, "auth_fail reason=server_not_configured")
|
||||||
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
|
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -79,18 +85,43 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
|||||||
timestamp := c.GetHeader("X-Timestamp")
|
timestamp := c.GetHeader("X-Timestamp")
|
||||||
nonce := c.GetHeader("X-Nonce")
|
nonce := c.GetHeader("X-Nonce")
|
||||||
sign := c.GetHeader("X-Sign")
|
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 == "" {
|
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")
|
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if apiKey != cfg.APIKey {
|
if apiKey != cfg.APIKey {
|
||||||
|
openlog.Warn(c, "auth_fail reason=invalid_api_key key=%s", openlog.MaskKey(apiKey))
|
||||||
response.Unauthorized(c, "无效的 API Key")
|
response.Unauthorized(c, "无效的 API Key")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(nonce) < 8 || len(nonce) > 64 {
|
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 之间")
|
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
@@ -98,44 +129,45 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
|||||||
|
|
||||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
openlog.Warn(c, "auth_fail reason=bad_timestamp raw=%s", timestamp)
|
||||||
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
|
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now().Unix()
|
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, "请求已过期或时间偏差过大")
|
response.Unauthorized(c, "请求已过期或时间偏差过大")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
|
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
|
||||||
|
openlog.Warn(c, "auth_fail reason=replay_nonce nonce=%s", nonce)
|
||||||
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
|
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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)
|
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
|
||||||
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
|
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
|
||||||
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
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, "签名校验失败")
|
response.Unauthorized(c, "签名校验失败")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.Set(openlog.CtxAPIKey, apiKey)
|
||||||
|
openlog.Info(c, "auth_ok skew=%ds string_to_sign_len=%d", skew, len(stringToSign))
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
|
// 请求结束后补一条总耗时(handler 里会打业务结果)
|
||||||
|
openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type Handlers struct {
|
|||||||
OpenAPIKey string
|
OpenAPIKey string
|
||||||
OpenAPISecret string
|
OpenAPISecret string
|
||||||
OpenSignSkew int64
|
OpenSignSkew int64
|
||||||
|
OpenAPIDebug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup(h *Handlers) *gin.Engine {
|
func Setup(h *Handlers) *gin.Engine {
|
||||||
@@ -48,6 +49,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
APIKey: h.OpenAPIKey,
|
APIKey: h.OpenAPIKey,
|
||||||
APISecret: h.OpenAPISecret,
|
APISecret: h.OpenAPISecret,
|
||||||
SkewSeconds: h.OpenSignSkew,
|
SkewSeconds: h.OpenSignSkew,
|
||||||
|
Debug: h.OpenAPIDebug,
|
||||||
}))
|
}))
|
||||||
{
|
{
|
||||||
open.GET("/orders/:order_no", h.Open.QueryOrder)
|
open.GET("/orders/:order_no", h.Open.QueryOrder)
|
||||||
|
|||||||
Reference in New Issue
Block a user