对接皮肤源头开放接口:查询、发货推送与 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)
|
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)
|
log.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,13 +55,18 @@ func main() {
|
|||||||
Skin: handler.NewSkinHandler(skinSvc),
|
Skin: handler.NewSkinHandler(skinSvc),
|
||||||
Order: handler.NewOrderHandler(orderSvc),
|
Order: handler.NewOrderHandler(orderSvc),
|
||||||
User: handler.NewUserHandler(userSvc),
|
User: handler.NewUserHandler(userSvc),
|
||||||
|
Open: handler.NewOpenHandler(orderSvc),
|
||||||
JWT: jm,
|
JWT: jm,
|
||||||
|
OpenAPIKey: cfg.OpenAPIKey,
|
||||||
|
OpenAPISecret: cfg.OpenAPISecret,
|
||||||
|
OpenSignSkew: cfg.OpenSignSkew,
|
||||||
}
|
}
|
||||||
|
|
||||||
r := router.Setup(h)
|
r := router.Setup(h)
|
||||||
addr := ":" + cfg.Port
|
addr := ":" + cfg.Port
|
||||||
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)")
|
||||||
if err := r.Run(addr); err != nil {
|
if err := r.Run(addr); err != nil {
|
||||||
log.Fatalf("server: %v", err)
|
log.Fatalf("server: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ type Config struct {
|
|||||||
JWTSecret string
|
JWTSecret string
|
||||||
DBPath string
|
DBPath string
|
||||||
Mode string // debug / release
|
Mode string // debug / release
|
||||||
|
// OpenAPIKey 皮肤源头开放接口标识(Header: X-Api-Key,可公开给对接方)
|
||||||
|
OpenAPIKey string
|
||||||
|
// OpenAPISecret 签名密钥(仅用于 HMAC,不走 Header)
|
||||||
|
OpenAPISecret string
|
||||||
|
// OpenSignSkew 签名时间戳允许偏差(秒)
|
||||||
|
OpenSignSkew int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -18,6 +24,9 @@ func Load() *Config {
|
|||||||
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: 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)
|
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"`
|
BuyerName string `gorm:"size:64" json:"buyer_name"`
|
||||||
Amount float64 `gorm:"not null" json:"amount"`
|
Amount float64 `gorm:"not null" json:"amount"`
|
||||||
CommissionAmt float64 `gorm:"default:0" json:"commission_amt"`
|
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"`
|
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 (
|
const (
|
||||||
OrderStatusPending = "pending"
|
OrderStatusPending = "pending" // 待支付
|
||||||
OrderStatusPaid = "paid"
|
OrderStatusPaid = "paid" // 已支付,可发货
|
||||||
OrderStatusDelivered = "delivered"
|
OrderStatusDelivering = "delivering" // 发货中
|
||||||
OrderStatusCancelled = "cancelled"
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ type Handlers struct {
|
|||||||
Skin *handler.SkinHandler
|
Skin *handler.SkinHandler
|
||||||
Order *handler.OrderHandler
|
Order *handler.OrderHandler
|
||||||
User *handler.UserHandler
|
User *handler.UserHandler
|
||||||
|
Open *handler.OpenHandler
|
||||||
JWT *jwt.Manager
|
JWT *jwt.Manager
|
||||||
|
OpenAPIKey string
|
||||||
|
OpenAPISecret string
|
||||||
|
OpenSignSkew int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func Setup(h *Handlers) *gin.Engine {
|
func Setup(h *Handlers) *gin.Engine {
|
||||||
@@ -24,7 +28,7 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
r.Use(cors.New(cors.Config{
|
r.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
|
AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
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"},
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
}))
|
}))
|
||||||
@@ -38,6 +42,18 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
api.POST("/auth/login", h.Auth.Login)
|
api.POST("/auth/login", h.Auth.Login)
|
||||||
api.POST("/auth/register", h.Auth.Register)
|
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 := api.Group("")
|
||||||
auth.Use(middleware.Auth(h.JWT))
|
auth.Use(middleware.Auth(h.JWT))
|
||||||
{
|
{
|
||||||
@@ -56,13 +72,14 @@ func Setup(h *Handlers) *gin.Engine {
|
|||||||
auth.POST("/orders", h.Order.Create)
|
auth.POST("/orders", h.Order.Create)
|
||||||
auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus)
|
auth.PATCH("/orders/:id/status", middleware.RequireRole(model.RoleAdmin), h.Order.UpdateStatus)
|
||||||
|
|
||||||
// 用户 / 分销商(仅管理员)
|
// 用户 / 分销商 / 发货记录(仅管理员)
|
||||||
admin := auth.Group("")
|
admin := auth.Group("")
|
||||||
admin.Use(middleware.RequireRole(model.RoleAdmin))
|
admin.Use(middleware.RequireRole(model.RoleAdmin))
|
||||||
{
|
{
|
||||||
admin.GET("/users", h.User.List)
|
admin.GET("/users", h.User.List)
|
||||||
admin.POST("/users", h.User.Create)
|
admin.POST("/users", h.User.Create)
|
||||||
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
|
admin.PATCH("/users/:id/status", h.User.UpdateStatus)
|
||||||
|
admin.GET("/ship-logs", h.Order.ListShipLogs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
@@ -105,7 +106,9 @@ func (s *OrderService) UpdateStatus(id uint, status string) error {
|
|||||||
allowed := map[string]bool{
|
allowed := map[string]bool{
|
||||||
model.OrderStatusPending: true,
|
model.OrderStatusPending: true,
|
||||||
model.OrderStatusPaid: true,
|
model.OrderStatusPaid: true,
|
||||||
|
model.OrderStatusDelivering: true,
|
||||||
model.OrderStatusDelivered: true,
|
model.OrderStatusDelivered: true,
|
||||||
|
model.OrderStatusShipFailed: true,
|
||||||
model.OrderStatusCancelled: true,
|
model.OrderStatusCancelled: true,
|
||||||
}
|
}
|
||||||
if !allowed[status] {
|
if !allowed[status] {
|
||||||
@@ -121,6 +124,261 @@ func (s *OrderService) UpdateStatus(id uint, status string) error {
|
|||||||
return nil
|
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 {
|
type DashboardStats struct {
|
||||||
SkinCount int64 `json:"skin_count"`
|
SkinCount int64 `json:"skin_count"`
|
||||||
DistributorCount int64 `json:"distributor_count"`
|
DistributorCount int64 `json:"distributor_count"`
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# 皮肤源头开放接口对接文档
|
||||||
|
|
||||||
|
版本:`v1`
|
||||||
|
Base URL:`https://{你们的域名}`(开发环境示例:`http://localhost:8080`)
|
||||||
|
统一响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `code = 0` 表示成功
|
||||||
|
- `code != 0` 表示失败,以 `message` 为准
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 鉴权(ApiKey + 签名)
|
||||||
|
|
||||||
|
开放接口采用 **双因子**:
|
||||||
|
|
||||||
|
| 凭证 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `api_key` | 标识对接方,放在请求头 `X-Api-Key` |
|
||||||
|
| `api_secret` | **仅用于本地算签名,禁止放在 Header / 前端 / 日志明文传输** |
|
||||||
|
|
||||||
|
每次请求必须带齐:
|
||||||
|
|
||||||
|
| Header | 必填 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `X-Api-Key` | 是 | 对接方 Key |
|
||||||
|
| `X-Timestamp` | 是 | Unix **秒**级时间戳 |
|
||||||
|
| `X-Nonce` | 是 | 随机串,长度 8~64,同一 Key 下有效期内不可重复 |
|
||||||
|
| `X-Sign` | 是 | HMAC-SHA256 签名,小写十六进制 |
|
||||||
|
|
||||||
|
### 1.1 签名算法
|
||||||
|
|
||||||
|
待签名字符串(UTF-8,行之间用 `\n`,共 6 行):
|
||||||
|
|
||||||
|
```text
|
||||||
|
{api_key}
|
||||||
|
{timestamp}
|
||||||
|
{nonce}
|
||||||
|
{METHOD}
|
||||||
|
{path}
|
||||||
|
{body}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 项 | 规则 |
|
||||||
|
|----|------|
|
||||||
|
| METHOD | 大写,如 `GET` / `POST` |
|
||||||
|
| path | `URL.Path`,**不含** query,如 `/api/open/v1/orders/O123` |
|
||||||
|
| body | 原始请求体字符串;GET 无 body 时用 **空字符串**(仍保留最后一行空内容,即末尾仍有换行结构中的 body 段为空) |
|
||||||
|
|
||||||
|
计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Sign = hex( HMAC-SHA256( api_secret, string_to_sign ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
- 使用 **小写** hex
|
||||||
|
- 时间戳与服务器偏差超过 **300 秒**(可配置)则拒绝
|
||||||
|
- 同一 `api_key + nonce` 在有效期内重复使用 → 拒绝(防重放)
|
||||||
|
|
||||||
|
### 1.2 Python 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hmac, hashlib, time, uuid, requests
|
||||||
|
|
||||||
|
API_KEY = "sk_source_dev_key_change_me"
|
||||||
|
API_SECRET = "sk_source_dev_secret_change_me"
|
||||||
|
BASE = "http://localhost:8080"
|
||||||
|
|
||||||
|
def sign(method: str, path: str, body: str = "") -> dict:
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
nonce = uuid.uuid4().hex
|
||||||
|
string_to_sign = "\n".join([API_KEY, ts, nonce, method.upper(), path, body])
|
||||||
|
sign_hex = hmac.new(
|
||||||
|
API_SECRET.encode(), string_to_sign.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
return {
|
||||||
|
"X-Api-Key": API_KEY,
|
||||||
|
"X-Timestamp": ts,
|
||||||
|
"X-Nonce": nonce,
|
||||||
|
"X-Sign": sign_hex,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 查询订单
|
||||||
|
path = "/api/open/v1/orders/O202607201550038000"
|
||||||
|
r = requests.get(BASE + path, headers=sign("GET", path))
|
||||||
|
print(r.json())
|
||||||
|
|
||||||
|
# 发货推送
|
||||||
|
path = "/api/open/v1/orders/ship-notify"
|
||||||
|
body = '{"order_no":"O202607201550038000","ship_status":"success","provider_order_no":"SRC001"}'
|
||||||
|
headers = {"Content-Type": "application/json", **sign("POST", path, body)}
|
||||||
|
r = requests.post(BASE + path, headers=headers, data=body.encode())
|
||||||
|
print(r.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:POST 签名用的 `body` 必须与实际发送的 body **字节级一致**(不要先 `json.dumps` 又改空格)。
|
||||||
|
|
||||||
|
### 1.3 鉴权失败示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 401, "message": "签名校验失败" }
|
||||||
|
```
|
||||||
|
|
||||||
|
常见原因:secret 错误、body 不一致、timestamp 过期、nonce 重复、path 写错(多了 query / 域名)。
|
||||||
|
|
||||||
|
### 1.4 环境变量(我方)
|
||||||
|
|
||||||
|
| 变量 | 说明 | 开发默认 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `OPEN_API_KEY` | Api Key | `sk_source_dev_key_change_me` |
|
||||||
|
| `OPEN_API_SECRET` | 签名密钥 | `sk_source_dev_secret_change_me` |
|
||||||
|
| `OPEN_SIGN_SKEW` | 时间偏差秒数 | `300` |
|
||||||
|
|
||||||
|
生产务必更换 Key / Secret。
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 对接流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 买家在店铺下单并完成支付
|
||||||
|
2. 上游拿到「店铺订单号 order_no」
|
||||||
|
3. 调用【订单查询】确认商品 sku、can_ship
|
||||||
|
4. can_ship=true 时执行发货
|
||||||
|
5. 发货结果调用【发货推送】回传
|
||||||
|
6. 我方同步订单状态;重复推送 success 幂等成功
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 订单查询(发货前置)
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/open/v1/orders/{order_no}
|
||||||
|
X-Api-Key: {api_key}
|
||||||
|
X-Timestamp: {unix_seconds}
|
||||||
|
X-Nonce: {random_8_to_64}
|
||||||
|
X-Sign: {hmac_sha256_hex}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"order_no": "O202607201550038000",
|
||||||
|
"status": "paid",
|
||||||
|
"can_ship": true,
|
||||||
|
"cannot_ship_reason": "",
|
||||||
|
"product": {
|
||||||
|
"name": "套装-糯粉咩咩",
|
||||||
|
"sku": "suit_pink_sheep",
|
||||||
|
"game": "和平精英"
|
||||||
|
},
|
||||||
|
"buyer_name": "测试买家",
|
||||||
|
"amount": 0,
|
||||||
|
"provider_order_no": "",
|
||||||
|
"created_at": "2026-07-20T15:50:03+08:00",
|
||||||
|
"shipped_at": null,
|
||||||
|
"ship_fail_reason": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| order_no | string | 店铺订单号 |
|
||||||
|
| status | string | 订单状态,见下表 |
|
||||||
|
| can_ship | bool | **是否允许发货**,请以此为准 |
|
||||||
|
| cannot_ship_reason | string | 不可发货原因(can_ship=false 时) |
|
||||||
|
| product.name | string | 商品中文名 |
|
||||||
|
| product.sku | string | **商品英文固定标识,发货请用此字段** |
|
||||||
|
| product.game | string | 游戏,如「和平精英」 |
|
||||||
|
| buyer_name | string | 买家备注名 |
|
||||||
|
| amount | number | 订单金额 |
|
||||||
|
| provider_order_no | string | 上游单号(若已回传) |
|
||||||
|
| created_at | string | 下单时间 |
|
||||||
|
| shipped_at | string\|null | 发货成功时间 |
|
||||||
|
| ship_fail_reason | string | 最近一次发货失败原因 |
|
||||||
|
|
||||||
|
### 订单状态 status
|
||||||
|
|
||||||
|
| 值 | 含义 | can_ship |
|
||||||
|
|----|------|----------|
|
||||||
|
| pending | 待支付 | false |
|
||||||
|
| paid | 已支付 | **true** |
|
||||||
|
| delivering | 发货中 | false |
|
||||||
|
| delivered | 已交付 | false |
|
||||||
|
| ship_failed | 发货失败(可重试) | **true** |
|
||||||
|
| cancelled | 已取消 | false |
|
||||||
|
|
||||||
|
### 错误
|
||||||
|
|
||||||
|
| HTTP | message |
|
||||||
|
|------|---------|
|
||||||
|
| 401 | 鉴权失败(Key / 签名 / 时间 / Nonce) |
|
||||||
|
| 404 | 订单不存在 |
|
||||||
|
| 400 | 订单号不能为空 |
|
||||||
|
|
||||||
|
### 调用说明
|
||||||
|
|
||||||
|
请使用上一节 Python 示例生成签名后请求;仅带 `X-Api-Key` 而不带签名会被拒绝。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 发货结果推送
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/open/v1/orders/ship-notify
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Api-Key: {api_key}
|
||||||
|
X-Timestamp: {unix_seconds}
|
||||||
|
X-Nonce: {random_8_to_64}
|
||||||
|
X-Sign: {hmac_sha256_hex}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_no": "O202607201550038000",
|
||||||
|
"ship_status": "success",
|
||||||
|
"provider_order_no": "SRC20260720001",
|
||||||
|
"shipped_at": "2026-07-20T16:00:00+08:00",
|
||||||
|
"fail_reason": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求字段
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| order_no | 是 | 店铺订单号 |
|
||||||
|
| ship_status | 是 | `success` / `failed` / `processing` |
|
||||||
|
| provider_order_no | 否 | 上游发货单号 |
|
||||||
|
| shipped_at | 否 | 发货时间,RFC3339;success 时缺省用服务端时间 |
|
||||||
|
| fail_reason | 否 | 失败原因(failed 时建议填写) |
|
||||||
|
|
||||||
|
### ship_status 与订单状态映射
|
||||||
|
|
||||||
|
| ship_status | 订单变为 | 说明 |
|
||||||
|
|-------------|---------|------|
|
||||||
|
| processing | delivering | 已接单/发货中 |
|
||||||
|
| success | delivered | 发货成功 |
|
||||||
|
| failed | ship_failed | 发货失败,允许再次查询后重试 |
|
||||||
|
|
||||||
|
### 幂等
|
||||||
|
|
||||||
|
- 订单已是 `delivered`,再次推送 `success` → **返回成功**,不重复处理
|
||||||
|
- 订单已是 `delivered`,推送 `failed` → 拒绝
|
||||||
|
- 订单 `cancelled` → 拒绝更新
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"order_no": "O202607201550038000",
|
||||||
|
"status": "delivered",
|
||||||
|
"message": "发货成功,订单已交付"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 调用说明
|
||||||
|
|
||||||
|
Body 与签名字符串中的 body **必须完全一致**。完整示例见 **§1.2 Python**。
|
||||||
|
|
||||||
|
| ship_status | body 示例 |
|
||||||
|
|-------------|-----------|
|
||||||
|
| processing | `{"order_no":"O...","ship_status":"processing","provider_order_no":"SRC..."}` |
|
||||||
|
| success | `{"order_no":"O...","ship_status":"success","provider_order_no":"SRC...","shipped_at":"2026-07-20T16:00:00+08:00"}` |
|
||||||
|
| failed | `{"order_no":"O...","ship_status":"failed","fail_reason":"账号不存在"}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 推荐调用顺序
|
||||||
|
|
||||||
|
```text
|
||||||
|
query → can_ship?
|
||||||
|
├─ false → 停止,展示 cannot_ship_reason
|
||||||
|
└─ true
|
||||||
|
→(可选)push processing
|
||||||
|
→ 执行发货
|
||||||
|
→ push success / failed
|
||||||
|
```
|
||||||
|
|
||||||
|
发货务必使用查询结果中的 **`product.sku`**,不要仅依赖中文名。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 联调检查清单
|
||||||
|
|
||||||
|
- [ ] 缺少 Sign / 错误 Secret 返回 401
|
||||||
|
- [ ] 错误 ApiKey 返回 401
|
||||||
|
- [ ] 过期 Timestamp / 重复 Nonce 返回 401
|
||||||
|
- [ ] 不存在的订单号返回 404
|
||||||
|
- [ ] `pending` 订单 `can_ship=false`
|
||||||
|
- [ ] `paid` 订单 `can_ship=true` 且 sku 正确
|
||||||
|
- [ ] success 推送后状态为 delivered
|
||||||
|
- [ ] 重复 success 幂等成功
|
||||||
|
- [ ] failed 后可再次 query 且 can_ship=true
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 联系方式
|
||||||
|
|
||||||
|
接口问题、密钥申请、联调环境请联系店铺方技术对接人。
|
||||||
|
商品英文 sku 对照见:`docs/商品英文名对照.md`
|
||||||
@@ -9,6 +9,8 @@ import Dashboard from './pages/Dashboard'
|
|||||||
import Skins from './pages/Skins'
|
import Skins from './pages/Skins'
|
||||||
import Orders from './pages/Orders'
|
import Orders from './pages/Orders'
|
||||||
import Distributors from './pages/Distributors'
|
import Distributors from './pages/Distributors'
|
||||||
|
import ShipLogs from './pages/ShipLogs'
|
||||||
|
import OpenApiDocs from './pages/OpenApiDocs'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: ReactNode }) {
|
function PrivateRoute({ children }: { children: ReactNode }) {
|
||||||
@@ -47,6 +49,22 @@ function AppRoutes() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="ship-logs"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<ShipLogs />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="open-api"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<OpenApiDocs />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
LoginResult,
|
LoginResult,
|
||||||
Order,
|
Order,
|
||||||
PageResult,
|
PageResult,
|
||||||
|
ShipLog,
|
||||||
Skin,
|
Skin,
|
||||||
User,
|
User,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
@@ -58,3 +59,8 @@ export const userApi = {
|
|||||||
updateStatus: (id: number, status: number) =>
|
updateStatus: (id: number, status: number) =>
|
||||||
request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data),
|
request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const shipLogApi = {
|
||||||
|
list: (params?: Record<string, unknown>) =>
|
||||||
|
request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult<ShipLog>),
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
ApiOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useAuth } from '../store/auth'
|
import { useAuth } from '../store/auth'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
@@ -40,7 +42,11 @@ export default function MainLayout() {
|
|||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单管理' },
|
||||||
]
|
]
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ key: '/distributors', icon: <TeamOutlined />, label: '分销商' })
|
items.push(
|
||||||
|
{ key: '/distributors', icon: <TeamOutlined />, label: '分销商' },
|
||||||
|
{ key: '/ship-logs', icon: <SendOutlined />, label: '发货记录' },
|
||||||
|
{ key: '/open-api', icon: <ApiOutlined />, label: '开放接口' },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}, [isAdmin])
|
}, [isAdmin])
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { Alert, Card, Descriptions, Space, Table, Tabs, Tag, Typography } from 'antd'
|
||||||
|
|
||||||
|
const { Title, Paragraph, Text, Link } = Typography
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':8080') : 'http://localhost:8080'
|
||||||
|
|
||||||
|
const signPython = `import hmac, hashlib, time, uuid, requests
|
||||||
|
|
||||||
|
API_KEY = "sk_source_dev_key_change_me"
|
||||||
|
API_SECRET = "sk_source_dev_secret_change_me"
|
||||||
|
BASE = "${baseUrl}"
|
||||||
|
|
||||||
|
def sign_headers(method: str, path: str, body: str = "") -> dict:
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
nonce = uuid.uuid4().hex
|
||||||
|
raw = "\\n".join([API_KEY, ts, nonce, method.upper(), path, body])
|
||||||
|
sign = hmac.new(API_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return {
|
||||||
|
"X-Api-Key": API_KEY,
|
||||||
|
"X-Timestamp": ts,
|
||||||
|
"X-Nonce": nonce,
|
||||||
|
"X-Sign": sign,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 查询
|
||||||
|
path = "/api/open/v1/orders/O你的订单号"
|
||||||
|
print(requests.get(BASE + path, headers=sign_headers("GET", path)).json())
|
||||||
|
|
||||||
|
# 推送(body 必须与签名一致)
|
||||||
|
path = "/api/open/v1/orders/ship-notify"
|
||||||
|
body = '{"order_no":"O你的订单号","ship_status":"success","provider_order_no":"SRC001"}'
|
||||||
|
headers = {"Content-Type": "application/json", **sign_headers("POST", path, body)}
|
||||||
|
print(requests.post(BASE + path, headers=headers, data=body.encode()).json())`
|
||||||
|
|
||||||
|
export default function OpenApiDocs() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginTop: 0 }}>
|
||||||
|
开放接口文档(皮肤源头)
|
||||||
|
</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
给上游发货系统对接使用。详细 Markdown 文档见仓库{' '}
|
||||||
|
<Text code>docs/开放接口-皮肤源头对接.md</Text>。
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="鉴权:X-Api-Key + HMAC 签名(必填)"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
请求头必须同时携带:
|
||||||
|
<Text code>X-Api-Key</Text>、<Text code>X-Timestamp</Text>、
|
||||||
|
<Text code>X-Nonce</Text>、<Text code>X-Sign</Text>
|
||||||
|
<br />
|
||||||
|
开发默认 Key:
|
||||||
|
<Text code copyable>
|
||||||
|
sk_source_dev_key_change_me
|
||||||
|
</Text>
|
||||||
|
,Secret:
|
||||||
|
<Text code copyable>
|
||||||
|
sk_source_dev_secret_change_me
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
生产环境变量:
|
||||||
|
<Text code>OPEN_API_KEY</Text> / <Text code>OPEN_API_SECRET</Text> /{' '}
|
||||||
|
<Text code>OPEN_SIGN_SKEW</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'auth',
|
||||||
|
label: '签名规则',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Card size="small" title="待签名字符串(6 行,\\n 分隔)">
|
||||||
|
<pre style={preStyle}>{`{api_key}
|
||||||
|
{timestamp}
|
||||||
|
{nonce}
|
||||||
|
{METHOD}
|
||||||
|
{path}
|
||||||
|
{body}`}</pre>
|
||||||
|
<Descriptions size="small" column={1} bordered style={{ marginTop: 12 }}>
|
||||||
|
<Descriptions.Item label="METHOD">大写 GET / POST</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="path">
|
||||||
|
URL.Path,不含 query,如 /api/open/v1/orders/O123
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="body">
|
||||||
|
原始请求体;GET 用空字符串。POST 必须与实际发送 body 字节一致
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="X-Sign">
|
||||||
|
hex(HMAC-SHA256(api_secret, string_to_sign)) 小写
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间窗">默认 ±300 秒</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Nonce">8~64 字符,有效期内同一 Key 不可重复</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="Python 完整示例">
|
||||||
|
<pre style={preStyle}>{signPython}</pre>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'flow',
|
||||||
|
label: '对接流程',
|
||||||
|
children: (
|
||||||
|
<Card size="small">
|
||||||
|
<Paragraph>
|
||||||
|
<ol>
|
||||||
|
<li>买家在店铺下单并完成支付(订单 status = paid)</li>
|
||||||
|
<li>上游拿到店铺订单号 <Text code>order_no</Text></li>
|
||||||
|
<li>
|
||||||
|
带签名调用「订单查询」确认 <Text code>product.sku</Text> 与{' '}
|
||||||
|
<Text code>can_ship</Text>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Text code>can_ship=true</Text> 时执行发货(按 sku)
|
||||||
|
</li>
|
||||||
|
<li>带签名调用「发货推送」回传 success / failed / processing</li>
|
||||||
|
<li>我方同步订单状态;重复 success 幂等成功</li>
|
||||||
|
</ol>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
|
Base URL 示例:<Text code>{baseUrl}</Text>
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'query',
|
||||||
|
label: '订单查询',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Card size="small" title="请求">
|
||||||
|
<Paragraph>
|
||||||
|
<Tag color="blue">GET</Tag>
|
||||||
|
<Text code>/api/open/v1/orders/{order_no}</Text>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||||
|
Header:X-Api-Key / X-Timestamp / X-Nonce / X-Sign(见「签名规则」)
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="响应字段">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="field"
|
||||||
|
dataSource={[
|
||||||
|
{ field: 'order_no', desc: '店铺订单号' },
|
||||||
|
{ field: 'status', desc: '订单状态' },
|
||||||
|
{ field: 'can_ship', desc: '是否可发货(发货前置请以此为准)' },
|
||||||
|
{ field: 'cannot_ship_reason', desc: '不可发货原因' },
|
||||||
|
{ field: 'product.sku', desc: '商品英文固定标识(发货用)' },
|
||||||
|
{ field: 'product.name', desc: '商品中文名' },
|
||||||
|
{ field: 'product.game', desc: '游戏,如和平精英' },
|
||||||
|
{ field: 'buyer_name', desc: '买家名' },
|
||||||
|
{ field: 'amount', desc: '金额' },
|
||||||
|
{ field: 'shipped_at', desc: '发货成功时间' },
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '字段',
|
||||||
|
dataIndex: 'field',
|
||||||
|
width: 200,
|
||||||
|
render: (v) => <Text code>{v}</Text>,
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'desc' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="can_ship 规则">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="status"
|
||||||
|
dataSource={[
|
||||||
|
{ status: 'pending', ship: 'false', note: '未支付' },
|
||||||
|
{ status: 'paid', ship: 'true', note: '可发' },
|
||||||
|
{ status: 'delivering', ship: 'false', note: '发货中' },
|
||||||
|
{ status: 'delivered', ship: 'false', note: '已完成' },
|
||||||
|
{ status: 'ship_failed', ship: 'true', note: '可重试' },
|
||||||
|
{ status: 'cancelled', ship: 'false', note: '已取消' },
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (v) => <Text code>{v}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'can_ship',
|
||||||
|
dataIndex: 'ship',
|
||||||
|
render: (v) =>
|
||||||
|
v === 'true' ? <Tag color="green">true</Tag> : <Tag>false</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'note' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notify',
|
||||||
|
label: '发货推送',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
<Card size="small" title="请求">
|
||||||
|
<Paragraph>
|
||||||
|
<Tag color="green">POST</Tag>
|
||||||
|
<Text code>/api/open/v1/orders/ship-notify</Text>
|
||||||
|
</Paragraph>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
除签名头外,Body 参与签名。Body 示例:
|
||||||
|
</Paragraph>
|
||||||
|
<pre style={preStyle}>{`{
|
||||||
|
"order_no": "O202607201550038000",
|
||||||
|
"ship_status": "success",
|
||||||
|
"provider_order_no": "SRC20260720001",
|
||||||
|
"shipped_at": "2026-07-20T16:00:00+08:00",
|
||||||
|
"fail_reason": ""
|
||||||
|
}`}</pre>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="请求参数">
|
||||||
|
<Descriptions size="small" column={1} bordered>
|
||||||
|
<Descriptions.Item label="order_no">必填,店铺订单号</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="ship_status">
|
||||||
|
必填:success / failed / processing
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="provider_order_no">可选,上游单号</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="shipped_at">
|
||||||
|
可选,RFC3339;success 缺省用服务端时间
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="fail_reason">可选,失败原因</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
<Card size="small" title="状态映射与幂等">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="ship"
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
dataSource={[
|
||||||
|
{ ship: 'processing', order: 'delivering', note: '已接单/发货中' },
|
||||||
|
{ ship: 'success', order: 'delivered', note: '发货成功' },
|
||||||
|
{ ship: 'failed', order: 'ship_failed', note: '失败可重试' },
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'ship_status',
|
||||||
|
dataIndex: 'ship',
|
||||||
|
render: (v) => <Text code>{v}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '订单状态',
|
||||||
|
dataIndex: 'order',
|
||||||
|
render: (v) => <Text code>{v}</Text>,
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'note' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="幂等:订单已 delivered 时再次推送 success 仍返回成功,不会重复处理。"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errors',
|
||||||
|
label: '错误码',
|
||||||
|
children: (
|
||||||
|
<Card size="small">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
rowKey="code"
|
||||||
|
dataSource={[
|
||||||
|
{ code: 0, http: 200, msg: '成功' },
|
||||||
|
{ code: 401, http: 401, msg: '鉴权失败:Key/签名/时间/Nonce' },
|
||||||
|
{ code: 404, http: 404, msg: '订单不存在' },
|
||||||
|
{ code: 400, http: 400, msg: '参数错误 / 状态不允许' },
|
||||||
|
]}
|
||||||
|
columns={[
|
||||||
|
{ title: 'code', dataIndex: 'code', width: 80 },
|
||||||
|
{ title: 'HTTP', dataIndex: 'http', width: 80 },
|
||||||
|
{ title: '说明', dataIndex: 'msg' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Paragraph type="secondary" style={{ marginTop: 12, marginBottom: 0 }}>
|
||||||
|
商品 sku 对照:
|
||||||
|
<Link href="/skins"> 皮肤商品列表</Link>
|
||||||
|
(字段「英文名」)或仓库 docs/商品英文名对照.md
|
||||||
|
</Paragraph>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const preStyle: CSSProperties = {
|
||||||
|
margin: 0,
|
||||||
|
padding: 12,
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ import type { Order } from '../types'
|
|||||||
const statusMap: Record<string, { color: string; text: string }> = {
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||||||
pending: { color: 'orange', text: '待支付' },
|
pending: { color: 'orange', text: '待支付' },
|
||||||
paid: { color: 'blue', text: '已支付' },
|
paid: { color: 'blue', text: '已支付' },
|
||||||
|
delivering: { color: 'cyan', text: '发货中' },
|
||||||
delivered: { color: 'green', text: '已交付' },
|
delivered: { color: 'green', text: '已交付' },
|
||||||
|
ship_failed: { color: 'red', text: '发货失败' },
|
||||||
cancelled: { color: 'default', text: '已取消' },
|
cancelled: { color: 'default', text: '已取消' },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,29 +61,33 @@ export default function Orders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Order> = [
|
const columns: ColumnsType<Order> = [
|
||||||
{ title: '订单号', dataIndex: 'order_no', width: 200 },
|
{ title: '订单号', dataIndex: 'order_no', width: 190, ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '皮肤',
|
title: '皮肤',
|
||||||
dataIndex: ['skin', 'name'],
|
dataIndex: ['skin', 'name'],
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
render: (_, r) => r.skin?.name || `#${r.skin_id}`,
|
render: (_, r) => r.skin?.name || `#${r.skin_id}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'SKU',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => r.skin?.sku || '-',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '分销商',
|
title: '分销商',
|
||||||
dataIndex: ['distributor', 'nickname'],
|
dataIndex: ['distributor', 'nickname'],
|
||||||
|
width: 100,
|
||||||
|
ellipsis: true,
|
||||||
render: (_, r) => r.distributor?.nickname || r.distributor?.username || `#${r.distributor_id}`,
|
render: (_, r) => r.distributor?.nickname || r.distributor?.username || `#${r.distributor_id}`,
|
||||||
},
|
},
|
||||||
{ title: '买家', dataIndex: 'buyer_name', width: 100 },
|
{ title: '买家', dataIndex: 'buyer_name', width: 90, ellipsis: true },
|
||||||
{
|
{
|
||||||
title: '金额',
|
title: '金额',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
width: 100,
|
width: 90,
|
||||||
render: (v: number) => `¥${v.toFixed(2)}`,
|
render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '佣金',
|
|
||||||
dataIndex: 'commission_amt',
|
|
||||||
width: 100,
|
|
||||||
render: (v: number) => `¥${v.toFixed(2)}`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -92,10 +98,17 @@ export default function Orders() {
|
|||||||
return <Tag color={s.color}>{s.text}</Tag>
|
return <Tag color={s.color}>{s.text}</Tag>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '上游单号',
|
||||||
|
dataIndex: 'provider_order_no',
|
||||||
|
width: 120,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v?: string) => v || '-',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
width: 170,
|
width: 160,
|
||||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -117,7 +130,7 @@ export default function Orders() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{record.status === 'paid' && (
|
{(record.status === 'paid' || record.status === 'ship_failed' || record.status === 'delivering') && (
|
||||||
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'delivered')}>
|
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'delivered')}>
|
||||||
标记交付
|
标记交付
|
||||||
</Button>
|
</Button>
|
||||||
@@ -146,7 +159,9 @@ export default function Orders() {
|
|||||||
options={[
|
options={[
|
||||||
{ value: 'pending', label: '待支付' },
|
{ value: 'pending', label: '待支付' },
|
||||||
{ value: 'paid', label: '已支付' },
|
{ value: 'paid', label: '已支付' },
|
||||||
|
{ value: 'delivering', label: '发货中' },
|
||||||
{ value: 'delivered', label: '已交付' },
|
{ value: 'delivered', label: '已交付' },
|
||||||
|
{ value: 'ship_failed', label: '发货失败' },
|
||||||
{ value: 'cancelled', label: '已取消' },
|
{ value: 'cancelled', label: '已取消' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -161,6 +176,8 @@ export default function Orders() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={list}
|
dataSource={list}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: size,
|
pageSize: size,
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd'
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { shipLogApi } from '../api'
|
||||||
|
import type { ShipLog } from '../types'
|
||||||
|
|
||||||
|
const shipStatusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
success: { color: 'green', text: '成功' },
|
||||||
|
failed: { color: 'red', text: '失败' },
|
||||||
|
processing: { color: 'cyan', text: '发货中' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderStatusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
pending: { color: 'orange', text: '待支付' },
|
||||||
|
paid: { color: 'blue', text: '已支付' },
|
||||||
|
delivering: { color: 'cyan', text: '发货中' },
|
||||||
|
delivered: { color: 'green', text: '已交付' },
|
||||||
|
ship_failed: { color: 'red', text: '发货失败' },
|
||||||
|
cancelled: { color: 'default', text: '已取消' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShipLogs() {
|
||||||
|
const [list, setList] = useState<ShipLog[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [size, setSize] = useState(10)
|
||||||
|
const [orderNo, setOrderNo] = useState('')
|
||||||
|
const [shipStatus, setShipStatus] = useState<string | undefined>()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [detail, setDetail] = useState<ShipLog | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await shipLogApi.list({
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
order_no: orderNo || undefined,
|
||||||
|
ship_status: shipStatus,
|
||||||
|
})
|
||||||
|
setList(data.list || [])
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, size, orderNo, shipStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const columns: ColumnsType<ShipLog> = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||||
|
{ title: '店铺订单号', dataIndex: 'order_no', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '推送状态',
|
||||||
|
dataIndex: 'ship_status',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => {
|
||||||
|
const s = shipStatusMap[v] || { color: 'default', text: v }
|
||||||
|
return <Tag color={s.color}>{s.text}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '处理后订单状态',
|
||||||
|
dataIndex: 'result_status',
|
||||||
|
width: 130,
|
||||||
|
render: (v: string) => {
|
||||||
|
if (!v) return '-'
|
||||||
|
const s = orderStatusMap[v] || { color: 'default', text: v }
|
||||||
|
return <Tag color={s.color}>{s.text}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上游单号',
|
||||||
|
dataIndex: 'provider_order_no',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '说明',
|
||||||
|
dataIndex: 'message',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '失败原因',
|
||||||
|
dataIndex: 'fail_reason',
|
||||||
|
width: 140,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 170,
|
||||||
|
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 90,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Button type="link" size="small" onClick={() => setDetail(record)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
发货推送记录
|
||||||
|
</Typography.Title>
|
||||||
|
<Space>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="店铺订单号"
|
||||||
|
allowClear
|
||||||
|
onSearch={(v) => {
|
||||||
|
setPage(1)
|
||||||
|
setOrderNo(v)
|
||||||
|
}}
|
||||||
|
style={{ width: 220 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="推送状态"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={shipStatus}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPage(1)
|
||||||
|
setShipStatus(v)
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'success', label: '成功' },
|
||||||
|
{ value: 'failed', label: '失败' },
|
||||||
|
{ value: 'processing', label: '发货中' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: -4 }}>
|
||||||
|
记录皮肤源头回调的发货结果,便于对账与排错。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={list}
|
||||||
|
tableLayout="fixed"
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: size,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: (p, s) => {
|
||||||
|
setPage(p)
|
||||||
|
setSize(s)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="推送详情"
|
||||||
|
open={!!detail}
|
||||||
|
onCancel={() => setDetail(null)}
|
||||||
|
footer={null}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">店铺订单号:</Typography.Text>
|
||||||
|
<Typography.Text copyable>{detail.order_no}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">上游单号:</Typography.Text>
|
||||||
|
{detail.provider_order_no || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">推送状态 / 结果状态:</Typography.Text>
|
||||||
|
{detail.ship_status} → {detail.result_status || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">说明:</Typography.Text>
|
||||||
|
{detail.message || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">失败原因:</Typography.Text>
|
||||||
|
{detail.fail_reason || '-'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">原始请求:</Typography.Text>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 12,
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 6,
|
||||||
|
maxHeight: 280,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatPayload(detail.payload)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPayload(raw: string) {
|
||||||
|
if (!raw) return '-'
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,22 @@ export interface Order {
|
|||||||
commission_amt: number
|
commission_amt: number
|
||||||
status: string
|
status: string
|
||||||
remark: string
|
remark: string
|
||||||
|
provider_order_no?: string
|
||||||
|
shipped_at?: string | null
|
||||||
|
ship_fail_reason?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShipLog {
|
||||||
|
id: number
|
||||||
|
order_no: string
|
||||||
|
order_id: number
|
||||||
|
ship_status: string
|
||||||
|
provider_order_no: string
|
||||||
|
fail_reason: string
|
||||||
|
payload: string
|
||||||
|
result_status: string
|
||||||
|
message: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user