实现多商户履约平台基础

This commit is contained in:
yml2213
2026-07-30 12:02:32 +08:00
parent dde6f2c269
commit 83429456a6
41 changed files with 5487 additions and 504 deletions
+131 -135
View File
@@ -6,191 +6,192 @@ import (
"crypto/sha256"
"encoding/hex"
"io"
"sort"
"strconv"
"strings"
"sync"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OpenAuthConfig 开放接口鉴权配置
const (
CtxAPIClient = "api_client"
CtxAPIClientID = "api_client_id"
CtxMerchantID = "merchant_id"
)
// OpenAuthConfig 配置数据库化的开放接口认证。
type OpenAuthConfig struct {
APIKey string
APISecret string
// 允许的时间偏差(秒),默认 300
DB *gorm.DB
Codec *service.SecretCodec
SkewSeconds int64
// Debug 详细日志
Debug bool
Debug bool
}
// nonce 防重放(进程内,重启清空;生产可换 Redis)
type nonceStore struct {
mu sync.Mutex
data map[string]int64 // nonce -> expire unix
}
func newNonceStore() *nonceStore {
return &nonceStore{data: make(map[string]int64)}
}
func (s *nonceStore) seen(nonce string, now, ttl int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
for k, exp := range s.data {
if exp < now {
delete(s.data, k)
}
}
if exp, ok := s.data[nonce]; ok && exp >= now {
return true
}
s.data[nonce] = now + ttl
return false
}
// OpenAuth 校验 X-Api-Key + 时间戳 + nonce + HMAC-SHA256 签名
//
// 待签名参数(value 原样,不做 URL encode):
//
// api_key, body, method, nonce, path, timestamp
//
// 按 key 字典序排序后拼接 k1=v1&k2=v2&...
// method 大写;path 为 URL.Path(不含 query);GET 时 body 为空串。
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
// OpenAuth 校验独立 API 客户端、时间戳、持久化 nonce 与 HMAC 签名。
// 客户侧新接口只使用 X-App-Key 和 body SHA256;上游发货接口独立使用 SourceOpenAuth。
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
if cfg.SkewSeconds <= 0 {
cfg.SkewSeconds = 300
}
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")
if cfg.DB == nil || cfg.Codec == nil {
response.ServerError(c, "开放接口认证服务未初始化")
c.Abort()
return
}
appKey := c.GetHeader("X-App-Key")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if appKey == "" || timestamp == "" || nonce == "" || sign == "" {
response.Unauthorized(c, "缺少鉴权头:X-App-Key、X-Timestamp、X-Nonce、X-Sign")
c.Abort()
return
}
if len(nonce) < 8 || len(nonce) > 96 {
response.Unauthorized(c, "X-Nonce 长度需在 8~96 之间")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
return
}
apiKey := c.GetHeader("X-Api-Key")
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))
var client model.APIClient
if err := cfg.DB.Where("app_key = ? AND status = ?", appKey, model.APIClientStatusActive).First(&client).Error; err != nil {
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 之间")
if client.ExpiresAt != nil && client.ExpiresAt.Before(time.Now()) {
response.Unauthorized(c, "API Key 已过期")
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
secret, err := cfg.Codec.Decrypt(client.SecretCiphertext)
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()
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.ServerError(c, "API 客户端密钥不可用")
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
}
stringToSign := BuildSignString(apiKey, timestamp, nonce, method, path, body)
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
expected := BuildOpenV1Sign(secret, timestamp, nonce, method, path, bodyBytes)
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()
now := time.Now()
nonceTTL := time.Duration(cfg.SkewSeconds) * time.Second
_ = cfg.DB.Where("expires_at < ?", now).Delete(&model.APIRequestNonce{}).Error
nonceRow := model.APIRequestNonce{
APIClientID: client.ID,
Nonce: nonce,
ExpiresAt: now.Add(nonceTTL),
}
created := cfg.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&nonceRow)
if created.Error != nil {
response.ServerError(c, "记录请求 nonce 失败")
c.Abort()
return
}
if created.RowsAffected == 0 {
response.Unauthorized(c, "重复的 X-Nonce(请勿重放请求)")
c.Abort()
return
}
// 请求结束后补一条总耗时(handler 里会打业务结果)
openlog.Info(c, "request done status=%d cost=%s", c.Writer.Status(), openlog.Elapsed(c).Round(time.Microsecond))
c.Set(CtxAPIClient, &client)
c.Set(CtxAPIClientID, client.ID)
c.Set(CtxMerchantID, client.MerchantID)
c.Set(openlog.CtxAPIKey, appKey)
_ = cfg.DB.Model(&model.APIClient{}).Where("id = ?", client.ID).Update("last_used_at", now).Error
c.Next()
}
}
// BuildSignString 生成待签名字符串:参数字典序 + & 拼接
// RequireAPIScope 要求客户端至少拥有一个指定权限。
func RequireAPIScope(scopes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
client := GetAPIClient(c)
if client == nil || !service.HasAnyScope(client.Scopes, scopes...) {
response.Forbidden(c, "API 客户端权限不足")
c.Abort()
return
}
c.Next()
}
}
func GetAPIClient(c *gin.Context) *model.APIClient {
value, ok := c.Get(CtxAPIClient)
if !ok {
return nil
}
client, _ := value.(*model.APIClient)
return client
}
func GetMerchantID(c *gin.Context) uint {
value, _ := c.Get(CtxMerchantID)
merchantID, _ := value.(uint)
return merchantID
}
// BuildOpenV1Sign 生成新开放接口签名:timestamp、nonce、method、path 与 body SHA256。
func BuildOpenV1Sign(secret, timestamp, nonce, method, path string, body []byte) string {
bodyHash := sha256.Sum256(body)
content := strings.Join([]string{
timestamp,
nonce,
strings.ToUpper(method),
path,
hex.EncodeToString(bodyHash[:]),
}, "\n")
return hmacSHA256Hex(secret, content)
}
// BuildSignString 保留旧接口的字典序签名算法,供兼容客户端和测试使用。
func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string {
params := map[string]string{
"api_key": apiKey,
"body": body,
"method": strings.ToUpper(method),
"nonce": nonce,
"path": path,
"timestamp": timestamp,
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
return strings.Join([]string{
"api_key=" + apiKey,
"body=" + body,
"method=" + strings.ToUpper(method),
"nonce=" + nonce,
"path=" + path,
"timestamp=" + timestamp,
}, "&")
}
// BuildOpenSign 供旧发货系统兼容使用。
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
}
func hmacSHA256Hex(secret, content string) string {
@@ -199,14 +200,9 @@ func hmacSHA256Hex(secret, content string) string {
return hex.EncodeToString(mac.Sum(nil))
}
func abs64(v int64) int64 {
if v < 0 {
return -v
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return v
}
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
return value
}