对接皮肤源头开放接口:查询、发货推送与 HMAC 签名鉴权
- 新增订单查询与发货结果推送开放接口,支持 can_ship 与幂等 - 鉴权采用 X-Api-Key + Timestamp + Nonce + HMAC-SHA256 签名 - 订单扩展发货字段与 ship_logs,管理端增加发货记录与开放文档页
This commit is contained in:
@@ -33,7 +33,7 @@ func main() {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Skin{}, &model.Order{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Skin{}, &model.Order{}, &model.ShipLog{}); err != nil {
|
||||
log.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
@@ -51,17 +51,22 @@ func main() {
|
||||
}
|
||||
|
||||
h := &router.Handlers{
|
||||
Auth: handler.NewAuthHandler(authSvc),
|
||||
Skin: handler.NewSkinHandler(skinSvc),
|
||||
Order: handler.NewOrderHandler(orderSvc),
|
||||
User: handler.NewUserHandler(userSvc),
|
||||
JWT: jm,
|
||||
Auth: handler.NewAuthHandler(authSvc),
|
||||
Skin: handler.NewSkinHandler(skinSvc),
|
||||
Order: handler.NewOrderHandler(orderSvc),
|
||||
User: handler.NewUserHandler(userSvc),
|
||||
Open: handler.NewOpenHandler(orderSvc),
|
||||
JWT: jm,
|
||||
OpenAPIKey: cfg.OpenAPIKey,
|
||||
OpenAPISecret: cfg.OpenAPISecret,
|
||||
OpenSignSkew: cfg.OpenSignSkew,
|
||||
}
|
||||
|
||||
r := router.Setup(h)
|
||||
addr := ":" + cfg.Port
|
||||
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 err := r.Run(addr); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,23 @@ type Config struct {
|
||||
JWTSecret string
|
||||
DBPath string
|
||||
Mode string // debug / release
|
||||
// OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方)
|
||||
OpenAPIKey string
|
||||
// OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header)
|
||||
OpenAPISecret string
|
||||
// OpenSignSkew 签名时间戳允许偏差(秒)
|
||||
OpenSignSkew int64
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
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"),
|
||||
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"),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenHandler 皮肤源头开放接口
|
||||
type OpenHandler struct {
|
||||
orderSvc *service.OrderService
|
||||
}
|
||||
|
||||
func NewOpenHandler(orderSvc *service.OrderService) *OpenHandler {
|
||||
return &OpenHandler{orderSvc: orderSvc}
|
||||
}
|
||||
|
||||
// QueryOrder GET /api/open/v1/orders/:order_no
|
||||
// 上游输入店铺订单号,查询商品与是否可发货
|
||||
func (h *OpenHandler) QueryOrder(c *gin.Context) {
|
||||
orderNo := c.Param("order_no")
|
||||
if orderNo == "" {
|
||||
orderNo = c.Query("order_no")
|
||||
}
|
||||
data, err := h.orderSvc.QueryOpenOrder(orderNo)
|
||||
if err != nil {
|
||||
if err.Error() == "订单不存在" {
|
||||
response.NotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, data)
|
||||
}
|
||||
|
||||
type shipNotifyReq struct {
|
||||
OrderNo string `json:"order_no" binding:"required"`
|
||||
ShipStatus string `json:"ship_status" binding:"required"` // success/failed/processing
|
||||
ProviderOrderNo string `json:"provider_order_no"`
|
||||
ShippedAt string `json:"shipped_at"` // RFC3339 可选
|
||||
FailReason string `json:"fail_reason"`
|
||||
}
|
||||
|
||||
// ShipNotify POST /api/open/v1/orders/ship-notify
|
||||
// 上游发货后推送结果,同步订单状态
|
||||
func (h *OpenHandler) ShipNotify(c *gin.Context) {
|
||||
var req shipNotifyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误:order_no 与 ship_status 必填")
|
||||
return
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(req)
|
||||
var shippedAt *time.Time
|
||||
if req.ShippedAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, req.ShippedAt)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "shipped_at 格式错误,请使用 RFC3339,如 2026-07-20T16:00:00+08:00")
|
||||
return
|
||||
}
|
||||
shippedAt = &t
|
||||
}
|
||||
|
||||
result, err := h.orderSvc.HandleShipNotify(service.ShipNotifyInput{
|
||||
OrderNo: req.OrderNo,
|
||||
ShipStatus: req.ShipStatus,
|
||||
ProviderOrderNo: req.ProviderOrderNo,
|
||||
ShippedAt: shippedAt,
|
||||
FailReason: req.FailReason,
|
||||
RawPayload: string(raw),
|
||||
})
|
||||
if err != nil {
|
||||
if err.Error() == "订单不存在" {
|
||||
response.NotFound(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
@@ -103,3 +103,20 @@ func (h *OrderHandler) Dashboard(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
// ListShipLogs 发货推送记录(管理端)
|
||||
func (h *OrderHandler) ListShipLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
list, total, err := h.svc.ListShipLogs(service.ShipLogListQuery{
|
||||
Page: page,
|
||||
Size: size,
|
||||
OrderNo: c.Query("order_no"),
|
||||
ShipStatus: c.Query("ship_status"),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Page(c, list, total, page, size)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenAuthConfig 开放接口鉴权配置
|
||||
type OpenAuthConfig struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
// 允许的时间偏差(秒),默认 300
|
||||
SkewSeconds int64
|
||||
}
|
||||
|
||||
// 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 签名
|
||||
//
|
||||
// 待签名字符串(UTF-8,\n 换行):
|
||||
//
|
||||
// {apiKey}\n{timestamp}\n{nonce}\n{METHOD}\n{path}\n{body}
|
||||
//
|
||||
// path 为 URL.Path(不含 query),METHOD 大写;GET 时 body 为空字符串。
|
||||
// sign = hex(hmac_sha256(apiSecret, stringToSign)),小写十六进制。
|
||||
func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
|
||||
if cfg.SkewSeconds <= 0 {
|
||||
cfg.SkewSeconds = 300
|
||||
}
|
||||
store := newNonceStore()
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if cfg.APIKey == "" || cfg.APISecret == "" {
|
||||
response.ServerError(c, "服务端未配置 OPEN_API_KEY / OPEN_API_SECRET")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := c.GetHeader("X-Api-Key")
|
||||
timestamp := c.GetHeader("X-Timestamp")
|
||||
nonce := c.GetHeader("X-Nonce")
|
||||
sign := c.GetHeader("X-Sign")
|
||||
|
||||
if apiKey == "" || timestamp == "" || nonce == "" || sign == "" {
|
||||
response.Unauthorized(c, "缺少鉴权头:需要 X-Api-Key、X-Timestamp、X-Nonce、X-Sign")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if apiKey != cfg.APIKey {
|
||||
response.Unauthorized(c, "无效的 API Key")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if len(nonce) < 8 || len(nonce) > 64 {
|
||||
response.Unauthorized(c, "X-Nonce 长度需在 8~64 之间")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "X-Timestamp 格式错误,需为 Unix 秒级时间戳")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if abs64(now-ts) > cfg.SkewSeconds {
|
||||
response.Unauthorized(c, "请求已过期或时间偏差过大")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 防重放:同一 nonce 在时间窗口内只能用一次
|
||||
if store.seen(apiKey+":"+nonce, now, cfg.SkewSeconds) {
|
||||
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 := strings.Join([]string{
|
||||
apiKey,
|
||||
timestamp,
|
||||
nonce,
|
||||
method,
|
||||
path,
|
||||
body,
|
||||
}, "\n")
|
||||
|
||||
expected := hmacSHA256Hex(cfg.APISecret, stringToSign)
|
||||
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
|
||||
response.Unauthorized(c, "签名校验失败")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func hmacSHA256Hex(secret, content string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(content))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func abs64(v int64) int64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// BuildOpenSign 供测试或内部生成签名(与 OpenAuth 规则一致)
|
||||
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
|
||||
stringToSign := strings.Join([]string{
|
||||
apiKey,
|
||||
timestamp,
|
||||
nonce,
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
body,
|
||||
}, "\n")
|
||||
return hmacSHA256Hex(apiSecret, stringToSign)
|
||||
}
|
||||
@@ -63,14 +63,43 @@ type Order struct {
|
||||
BuyerName string `gorm:"size:64" json:"buyer_name"`
|
||||
Amount float64 `gorm:"not null" json:"amount"`
|
||||
CommissionAmt float64 `gorm:"default:0" json:"commission_amt"`
|
||||
Status string `gorm:"size:32;default:pending" json:"status"` // pending/paid/delivered/cancelled
|
||||
Status string `gorm:"size:32;default:pending;index" json:"status"`
|
||||
Remark string `gorm:"size:255" json:"remark"`
|
||||
|
||||
// 发货相关(上游皮肤源头对接)
|
||||
ProviderOrderNo string `gorm:"size:64;index" json:"provider_order_no"` // 上游单号
|
||||
ShippedAt *time.Time `json:"shipped_at"` // 发货成功时间
|
||||
ShipFailReason string `gorm:"size:512" json:"ship_fail_reason"` // 最近一次失败原因
|
||||
}
|
||||
|
||||
// 订单状态
|
||||
const (
|
||||
OrderStatusPending = "pending"
|
||||
OrderStatusPaid = "paid"
|
||||
OrderStatusDelivered = "delivered"
|
||||
OrderStatusCancelled = "cancelled"
|
||||
OrderStatusPending = "pending" // 待支付
|
||||
OrderStatusPaid = "paid" // 已支付,可发货
|
||||
OrderStatusDelivering = "delivering" // 发货中
|
||||
OrderStatusDelivered = "delivered" // 已交付
|
||||
OrderStatusShipFailed = "ship_failed" // 发货失败(可重试)
|
||||
OrderStatusCancelled = "cancelled" // 已取消
|
||||
)
|
||||
|
||||
// 上游推送的发货状态
|
||||
const (
|
||||
ShipNotifySuccess = "success"
|
||||
ShipNotifyFailed = "failed"
|
||||
ShipNotifyProcessing = "processing"
|
||||
)
|
||||
|
||||
// ShipLog 发货推送记录(上游回调留痕)
|
||||
type ShipLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
OrderNo string `gorm:"size:64;index;not null" json:"order_no"`
|
||||
OrderID uint `gorm:"index" json:"order_id"`
|
||||
ShipStatus string `gorm:"size:32;not null" json:"ship_status"` // success/failed/processing
|
||||
ProviderOrderNo string `gorm:"size:64" json:"provider_order_no"`
|
||||
FailReason string `gorm:"size:512" json:"fail_reason"`
|
||||
Payload string `gorm:"type:text" json:"payload"` // 原始请求 JSON
|
||||
ResultStatus string `gorm:"size:32" json:"result_status"` // 处理后订单状态
|
||||
Message string `gorm:"size:255" json:"message"`
|
||||
}
|
||||
|
||||
@@ -11,11 +11,15 @@ import (
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Auth *handler.AuthHandler
|
||||
Skin *handler.SkinHandler
|
||||
Order *handler.OrderHandler
|
||||
User *handler.UserHandler
|
||||
JWT *jwt.Manager
|
||||
Auth *handler.AuthHandler
|
||||
Skin *handler.SkinHandler
|
||||
Order *handler.OrderHandler
|
||||
User *handler.UserHandler
|
||||
Open *handler.OpenHandler
|
||||
JWT *jwt.Manager
|
||||
OpenAPIKey string
|
||||
OpenAPISecret string
|
||||
OpenSignSkew int64
|
||||
}
|
||||
|
||||
func Setup(h *Handlers) *gin.Engine {
|
||||
@@ -24,7 +28,7 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
@@ -38,6 +42,18 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
api.POST("/auth/login", h.Auth.Login)
|
||||
api.POST("/auth/register", h.Auth.Register)
|
||||
|
||||
// 皮肤源头开放接口(ApiKey + HMAC 签名)
|
||||
open := api.Group("/open/v1")
|
||||
open.Use(middleware.OpenAuth(middleware.OpenAuthConfig{
|
||||
APIKey: h.OpenAPIKey,
|
||||
APISecret: h.OpenAPISecret,
|
||||
SkewSeconds: h.OpenSignSkew,
|
||||
}))
|
||||
{
|
||||
open.GET("/orders/:order_no", h.Open.QueryOrder)
|
||||
open.POST("/orders/ship-notify", h.Open.ShipNotify)
|
||||
}
|
||||
|
||||
auth := api.Group("")
|
||||
auth.Use(middleware.Auth(h.JWT))
|
||||
{
|
||||
@@ -56,13 +72,14 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
auth.POST("/orders", h.Order.Create)
|
||||
auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus)
|
||||
|
||||
// 用户 / 分销商(仅管理员)
|
||||
// 用户 / 分销商 / 发货记录(仅管理员)
|
||||
admin := auth.Group("")
|
||||
admin.Use(middleware.RequireRole(model.RoleAdmin))
|
||||
{
|
||||
admin.GET("/users", h.User.List)
|
||||
admin.POST("/users", h.User.Create)
|
||||
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
|
||||
admin.GET("/ship-logs", h.Order.ListShipLogs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -103,10 +104,12 @@ func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
|
||||
|
||||
func (s *OrderService) UpdateStatus(id uint, status string) error {
|
||||
allowed := map[string]bool{
|
||||
model.OrderStatusPending: true,
|
||||
model.OrderStatusPaid: true,
|
||||
model.OrderStatusDelivered: true,
|
||||
model.OrderStatusCancelled: true,
|
||||
model.OrderStatusPending: true,
|
||||
model.OrderStatusPaid: true,
|
||||
model.OrderStatusDelivering: true,
|
||||
model.OrderStatusDelivered: true,
|
||||
model.OrderStatusShipFailed: true,
|
||||
model.OrderStatusCancelled: true,
|
||||
}
|
||||
if !allowed[status] {
|
||||
return errors.New("无效的订单状态")
|
||||
@@ -121,6 +124,261 @@ func (s *OrderService) UpdateStatus(id uint, status string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ----- 开放接口:皮肤源头对接 -----
|
||||
|
||||
// OpenOrderQuery 开放接口订单查询结果
|
||||
type OpenOrderQuery struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
CanShip bool `json:"can_ship"`
|
||||
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
|
||||
Product *OpenOrderProduct `json:"product,omitempty"`
|
||||
BuyerName string `json:"buyer_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
ProviderOrderNo string `json:"provider_order_no,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ShippedAt *time.Time `json:"shipped_at"`
|
||||
ShipFailReason string `json:"ship_fail_reason,omitempty"`
|
||||
}
|
||||
|
||||
type OpenOrderProduct struct {
|
||||
Name string `json:"name"`
|
||||
SKU string `json:"sku"`
|
||||
Game string `json:"game"`
|
||||
}
|
||||
|
||||
// ShipNotifyInput 上游发货结果推送
|
||||
type ShipNotifyInput struct {
|
||||
OrderNo string
|
||||
ShipStatus string // success / failed / processing
|
||||
ProviderOrderNo string
|
||||
ShippedAt *time.Time
|
||||
FailReason string
|
||||
RawPayload string
|
||||
}
|
||||
|
||||
type ShipNotifyResult struct {
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *OrderService) GetByOrderNo(orderNo string) (*model.Order, error) {
|
||||
var order model.Order
|
||||
err := s.db.Preload("Skin").Where("order_no = ?", orderNo).First(&order).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// QueryOpenOrder 供上游查询:商品信息 + 是否可发货
|
||||
func (s *OrderService) QueryOpenOrder(orderNo string) (*OpenOrderQuery, error) {
|
||||
if orderNo == "" {
|
||||
return nil, errors.New("订单号不能为空")
|
||||
}
|
||||
order, err := s.GetByOrderNo(orderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canShip, reason := evaluateCanShip(order)
|
||||
out := &OpenOrderQuery{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
CanShip: canShip,
|
||||
CannotShipReason: reason,
|
||||
BuyerName: order.BuyerName,
|
||||
Amount: order.Amount,
|
||||
ProviderOrderNo: order.ProviderOrderNo,
|
||||
CreatedAt: order.CreatedAt,
|
||||
ShippedAt: order.ShippedAt,
|
||||
ShipFailReason: order.ShipFailReason,
|
||||
}
|
||||
if order.Skin != nil {
|
||||
out.Product = &OpenOrderProduct{
|
||||
Name: order.Skin.Name,
|
||||
SKU: order.Skin.SKU,
|
||||
Game: order.Skin.Game,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func evaluateCanShip(order *model.Order) (bool, string) {
|
||||
switch order.Status {
|
||||
case model.OrderStatusPaid, model.OrderStatusShipFailed:
|
||||
return true, ""
|
||||
case model.OrderStatusPending:
|
||||
return false, "订单未支付"
|
||||
case model.OrderStatusDelivering:
|
||||
return false, "订单发货中"
|
||||
case model.OrderStatusDelivered:
|
||||
return false, "订单已发货完成"
|
||||
case model.OrderStatusCancelled:
|
||||
return false, "订单已取消"
|
||||
default:
|
||||
return false, "当前状态不可发货: " + order.Status
|
||||
}
|
||||
}
|
||||
|
||||
// HandleShipNotify 处理上游发货结果推送(幂等)
|
||||
func (s *OrderService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyResult, error) {
|
||||
if in.OrderNo == "" {
|
||||
return nil, errors.New("订单号不能为空")
|
||||
}
|
||||
switch in.ShipStatus {
|
||||
case model.ShipNotifySuccess, model.ShipNotifyFailed, model.ShipNotifyProcessing:
|
||||
default:
|
||||
return nil, errors.New("无效的 ship_status,仅支持 success/failed/processing")
|
||||
}
|
||||
|
||||
order, err := s.GetByOrderNo(in.OrderNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 已交付:success 推送幂等成功
|
||||
if order.Status == model.OrderStatusDelivered && in.ShipStatus == model.ShipNotifySuccess {
|
||||
_ = s.appendShipLog(order, in, order.Status, "订单已是已交付状态,幂等忽略")
|
||||
return &ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
Message: "订单已交付,幂等成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 已取消不允许再推成功
|
||||
if order.Status == model.OrderStatusCancelled {
|
||||
_ = s.appendShipLog(order, in, order.Status, "订单已取消,拒绝更新")
|
||||
return nil, errors.New("订单已取消,无法更新发货状态")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
shippedAt := in.ShippedAt
|
||||
if shippedAt == nil && in.ShipStatus == model.ShipNotifySuccess {
|
||||
shippedAt = &now
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
var nextStatus string
|
||||
var msg string
|
||||
|
||||
switch in.ShipStatus {
|
||||
case model.ShipNotifySuccess:
|
||||
// 仅 paid / ship_failed / delivering 可转为 delivered
|
||||
if order.Status != model.OrderStatusPaid &&
|
||||
order.Status != model.OrderStatusShipFailed &&
|
||||
order.Status != model.OrderStatusDelivering {
|
||||
_ = s.appendShipLog(order, in, order.Status, "当前状态不允许标记发货成功")
|
||||
return nil, fmt.Errorf("当前状态 %s 不允许标记发货成功", order.Status)
|
||||
}
|
||||
nextStatus = model.OrderStatusDelivered
|
||||
updates["status"] = nextStatus
|
||||
updates["shipped_at"] = shippedAt
|
||||
updates["ship_fail_reason"] = ""
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
msg = "发货成功,订单已交付"
|
||||
case model.ShipNotifyFailed:
|
||||
if order.Status == model.OrderStatusDelivered {
|
||||
_ = s.appendShipLog(order, in, order.Status, "订单已交付,忽略失败推送")
|
||||
return nil, errors.New("订单已交付,不能标记发货失败")
|
||||
}
|
||||
nextStatus = model.OrderStatusShipFailed
|
||||
updates["status"] = nextStatus
|
||||
updates["ship_fail_reason"] = in.FailReason
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
msg = "已记录发货失败"
|
||||
case model.ShipNotifyProcessing:
|
||||
if order.Status == model.OrderStatusDelivered {
|
||||
_ = s.appendShipLog(order, in, order.Status, "订单已交付,忽略发货中推送")
|
||||
return &ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
Message: "订单已交付,忽略 processing",
|
||||
}, nil
|
||||
}
|
||||
if order.Status != model.OrderStatusPaid &&
|
||||
order.Status != model.OrderStatusShipFailed &&
|
||||
order.Status != model.OrderStatusDelivering {
|
||||
_ = s.appendShipLog(order, in, order.Status, "当前状态不允许进入发货中")
|
||||
return nil, fmt.Errorf("当前状态 %s 不允许进入发货中", order.Status)
|
||||
}
|
||||
nextStatus = model.OrderStatusDelivering
|
||||
updates["status"] = nextStatus
|
||||
if in.ProviderOrderNo != "" {
|
||||
updates["provider_order_no"] = in.ProviderOrderNo
|
||||
}
|
||||
msg = "订单已标记为发货中"
|
||||
}
|
||||
|
||||
if err := s.db.Model(&model.Order{}).Where("id = ?", order.ID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.appendShipLog(order, in, nextStatus, msg)
|
||||
|
||||
return &ShipNotifyResult{
|
||||
OrderNo: order.OrderNo,
|
||||
Status: nextStatus,
|
||||
Message: msg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OrderService) appendShipLog(order *model.Order, in ShipNotifyInput, resultStatus, message string) error {
|
||||
payload := in.RawPayload
|
||||
if payload == "" {
|
||||
b, _ := json.Marshal(in)
|
||||
payload = string(b)
|
||||
}
|
||||
log := &model.ShipLog{
|
||||
OrderNo: order.OrderNo,
|
||||
OrderID: order.ID,
|
||||
ShipStatus: in.ShipStatus,
|
||||
ProviderOrderNo: in.ProviderOrderNo,
|
||||
FailReason: in.FailReason,
|
||||
Payload: payload,
|
||||
ResultStatus: resultStatus,
|
||||
Message: message,
|
||||
}
|
||||
return s.db.Create(log).Error
|
||||
}
|
||||
|
||||
type ShipLogListQuery struct {
|
||||
Page int
|
||||
Size int
|
||||
OrderNo string
|
||||
ShipStatus string
|
||||
}
|
||||
|
||||
func (s *OrderService) ListShipLogs(q ShipLogListQuery) ([]model.ShipLog, int64, error) {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.Size < 1 || q.Size > 100 {
|
||||
q.Size = 20
|
||||
}
|
||||
tx := s.db.Model(&model.ShipLog{})
|
||||
if q.OrderNo != "" {
|
||||
tx = tx.Where("order_no LIKE ?", "%"+q.OrderNo+"%")
|
||||
}
|
||||
if q.ShipStatus != "" {
|
||||
tx = tx.Where("ship_status = ?", q.ShipStatus)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []model.ShipLog
|
||||
err := tx.Order("id DESC").Offset((q.Page - 1) * q.Size).Limit(q.Size).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
SkinCount int64 `json:"skin_count"`
|
||||
DistributorCount int64 `json:"distributor_count"`
|
||||
|
||||
Reference in New Issue
Block a user