diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3a37190..ce7bf24 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2d8318f..6d9f48d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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)), } } diff --git a/backend/internal/handler/open.go b/backend/internal/handler/open.go new file mode 100644 index 0000000..c43e083 --- /dev/null +++ b/backend/internal/handler/open.go @@ -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) +} diff --git a/backend/internal/handler/order.go b/backend/internal/handler/order.go index b2bb8c8..e17d0b3 100644 --- a/backend/internal/handler/order.go +++ b/backend/internal/handler/order.go @@ -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) +} diff --git a/backend/internal/middleware/open_auth.go b/backend/internal/middleware/open_auth.go new file mode 100644 index 0000000..5dda9f0 --- /dev/null +++ b/backend/internal/middleware/open_auth.go @@ -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) +} diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index 9e2ab0f..47fb0a9 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -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"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 38174b6..4fbb160 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) } } } diff --git a/backend/internal/service/order.go b/backend/internal/service/order.go index e4d990b..eaa573c 100644 --- a/backend/internal/service/order.go +++ b/backend/internal/service/order.go @@ -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"` diff --git a/docs/开放接口-皮肤源头对接.md b/docs/开放接口-皮肤源头对接.md new file mode 100644 index 0000000..09a48e6 --- /dev/null +++ b/docs/开放接口-皮肤源头对接.md @@ -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` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cff6579..e663a28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,8 @@ import Dashboard from './pages/Dashboard' import Skins from './pages/Skins' import Orders from './pages/Orders' import Distributors from './pages/Distributors' +import ShipLogs from './pages/ShipLogs' +import OpenApiDocs from './pages/OpenApiDocs' import type { ReactNode } from 'react' function PrivateRoute({ children }: { children: ReactNode }) { @@ -47,6 +49,22 @@ function AppRoutes() { } /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7fc82ba..f274802 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -4,6 +4,7 @@ import type { LoginResult, Order, PageResult, + ShipLog, Skin, User, } from '../types' @@ -58,3 +59,8 @@ export const userApi = { updateStatus: (id: number, status: number) => request.patch(`/users/${id}/status`, { status }).then((r) => r.data.data), } + +export const shipLogApi = { + list: (params?: Record) => + request.get('/ship-logs', { params }).then((r) => r.data.data as PageResult), +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e683e81..3fcc086 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -18,6 +18,8 @@ import { LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, + SendOutlined, + ApiOutlined, } from '@ant-design/icons' import { useAuth } from '../store/auth' import type { MenuProps } from 'antd' @@ -40,7 +42,11 @@ export default function MainLayout() { { key: '/orders', icon: , label: '订单管理' }, ] if (isAdmin) { - items.push({ key: '/distributors', icon: , label: '分销商' }) + items.push( + { key: '/distributors', icon: , label: '分销商' }, + { key: '/ship-logs', icon: , label: '发货记录' }, + { key: '/open-api', icon: , label: '开放接口' }, + ) } return items }, [isAdmin]) diff --git a/frontend/src/pages/OpenApiDocs.tsx b/frontend/src/pages/OpenApiDocs.tsx new file mode 100644 index 0000000..39312de --- /dev/null +++ b/frontend/src/pages/OpenApiDocs.tsx @@ -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 ( +
+ + 开放接口文档(皮肤源头) + + + 给上游发货系统对接使用。详细 Markdown 文档见仓库{' '} + docs/开放接口-皮肤源头对接.md。 + + + + 请求头必须同时携带: + X-Api-KeyX-Timestamp、 + X-NonceX-Sign +
+ 开发默认 Key: + + sk_source_dev_key_change_me + + ,Secret: + + sk_source_dev_secret_change_me + +
+ 生产环境变量: + OPEN_API_KEY / OPEN_API_SECRET /{' '} + OPEN_SIGN_SKEW +
+ } + /> + + + +
{`{api_key}
+{timestamp}
+{nonce}
+{METHOD}
+{path}
+{body}`}
+ + 大写 GET / POST + + URL.Path,不含 query,如 /api/open/v1/orders/O123 + + + 原始请求体;GET 用空字符串。POST 必须与实际发送 body 字节一致 + + + hex(HMAC-SHA256(api_secret, string_to_sign)) 小写 + + 默认 ±300 秒 + 8~64 字符,有效期内同一 Key 不可重复 + +
+ +
{signPython}
+
+ + ), + }, + { + key: 'flow', + label: '对接流程', + children: ( + + +
    +
  1. 买家在店铺下单并完成支付(订单 status = paid)
  2. +
  3. 上游拿到店铺订单号 order_no
  4. +
  5. + 带签名调用「订单查询」确认 product.sku 与{' '} + can_ship +
  6. +
  7. + can_ship=true 时执行发货(按 sku) +
  8. +
  9. 带签名调用「发货推送」回传 success / failed / processing
  10. +
  11. 我方同步订单状态;重复 success 幂等成功
  12. +
+
+ + Base URL 示例:{baseUrl} + +
+ ), + }, + { + key: 'query', + label: '订单查询', + children: ( + + + + GET + /api/open/v1/orders/{order_no} + + + Header:X-Api-Key / X-Timestamp / X-Nonce / X-Sign(见「签名规则」) + + + + {v}, + }, + { title: '说明', dataIndex: 'desc' }, + ]} + /> + + +
{v}, + }, + { + title: 'can_ship', + dataIndex: 'ship', + render: (v) => + v === 'true' ? true : false, + }, + { title: '说明', dataIndex: 'note' }, + ]} + /> + + + ), + }, + { + key: 'notify', + label: '发货推送', + children: ( + + + + POST + /api/open/v1/orders/ship-notify + + + 除签名头外,Body 参与签名。Body 示例: + +
{`{
+  "order_no": "O202607201550038000",
+  "ship_status": "success",
+  "provider_order_no": "SRC20260720001",
+  "shipped_at": "2026-07-20T16:00:00+08:00",
+  "fail_reason": ""
+}`}
+
+ + + 必填,店铺订单号 + + 必填:success / failed / processing + + 可选,上游单号 + + 可选,RFC3339;success 缺省用服务端时间 + + 可选,失败原因 + + + +
{v}, + }, + { + title: '订单状态', + dataIndex: 'order', + render: (v) => {v}, + }, + { title: '说明', dataIndex: 'note' }, + ]} + /> + + + + ), + }, + { + key: 'errors', + label: '错误码', + children: ( + +
+ + 商品 sku 对照: + 皮肤商品列表 + (字段「英文名」)或仓库 docs/商品英文名对照.md + + + ), + }, + ]} + /> + + ) +} + +const preStyle: CSSProperties = { + margin: 0, + padding: 12, + background: '#f5f5f5', + borderRadius: 6, + fontSize: 12, + overflow: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', +} diff --git a/frontend/src/pages/Orders.tsx b/frontend/src/pages/Orders.tsx index 7615e89..1d3b6e7 100644 --- a/frontend/src/pages/Orders.tsx +++ b/frontend/src/pages/Orders.tsx @@ -18,7 +18,9 @@ import type { Order } from '../types' const statusMap: Record = { 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: '已取消' }, } @@ -59,29 +61,33 @@ export default function Orders() { } const columns: ColumnsType = [ - { title: '订单号', dataIndex: 'order_no', width: 200 }, + { title: '订单号', dataIndex: 'order_no', width: 190, ellipsis: true }, { title: '皮肤', dataIndex: ['skin', 'name'], + width: 140, + ellipsis: true, render: (_, r) => r.skin?.name || `#${r.skin_id}`, }, + { + title: 'SKU', + width: 140, + ellipsis: true, + render: (_, r) => r.skin?.sku || '-', + }, { title: '分销商', dataIndex: ['distributor', 'nickname'], + width: 100, + ellipsis: true, 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: '金额', dataIndex: 'amount', - width: 100, - render: (v: number) => `¥${v.toFixed(2)}`, - }, - { - title: '佣金', - dataIndex: 'commission_amt', - width: 100, - render: (v: number) => `¥${v.toFixed(2)}`, + width: 90, + render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`, }, { title: '状态', @@ -92,10 +98,17 @@ export default function Orders() { return {s.text} }, }, + { + title: '上游单号', + dataIndex: 'provider_order_no', + width: 120, + ellipsis: true, + render: (v?: string) => v || '-', + }, { title: '时间', dataIndex: 'created_at', - width: 170, + width: 160, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), }, ] @@ -117,7 +130,7 @@ export default function Orders() { )} - {record.status === 'paid' && ( + {(record.status === 'paid' || record.status === 'ship_failed' || record.status === 'delivering') && ( @@ -146,7 +159,9 @@ export default function Orders() { options={[ { value: 'pending', label: '待支付' }, { value: 'paid', label: '已支付' }, + { value: 'delivering', label: '发货中' }, { value: 'delivered', label: '已交付' }, + { value: 'ship_failed', label: '发货失败' }, { value: 'cancelled', label: '已取消' }, ]} /> @@ -161,6 +176,8 @@ export default function Orders() { loading={loading} columns={columns} dataSource={list} + tableLayout="fixed" + scroll={{ x: 1100 }} pagination={{ current: page, pageSize: size, diff --git a/frontend/src/pages/ShipLogs.tsx b/frontend/src/pages/ShipLogs.tsx new file mode 100644 index 0000000..f48511a --- /dev/null +++ b/frontend/src/pages/ShipLogs.tsx @@ -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 = { + success: { color: 'green', text: '成功' }, + failed: { color: 'red', text: '失败' }, + processing: { color: 'cyan', text: '发货中' }, +} + +const orderStatusMap: Record = { + 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([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [size, setSize] = useState(10) + const [orderNo, setOrderNo] = useState('') + const [shipStatus, setShipStatus] = useState() + const [loading, setLoading] = useState(false) + const [detail, setDetail] = useState(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 = [ + { 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 {s.text} + }, + }, + { + title: '处理后订单状态', + dataIndex: 'result_status', + width: 130, + render: (v: string) => { + if (!v) return '-' + const s = orderStatusMap[v] || { color: 'default', text: v } + return {s.text} + }, + }, + { + 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) => ( + + ), + }, + ] + + return ( +
+ + + 发货推送记录 + + + { + setPage(1) + setOrderNo(v) + }} + style={{ width: 220 }} + /> +
`共 ${t} 条`, + onChange: (p, s) => { + setPage(p) + setSize(s) + }, + }} + /> + + setDetail(null)} + footer={null} + width={640} + > + {detail && ( + +
+ 店铺订单号: + {detail.order_no} +
+
+ 上游单号: + {detail.provider_order_no || '-'} +
+
+ 推送状态 / 结果状态: + {detail.ship_status} → {detail.result_status || '-'} +
+
+ 说明: + {detail.message || '-'} +
+
+ 失败原因: + {detail.fail_reason || '-'} +
+
+ 原始请求: +
+                {formatPayload(detail.payload)}
+              
+
+
+ )} +
+ + ) +} + +function formatPayload(raw: string) { + if (!raw) return '-' + try { + return JSON.stringify(JSON.parse(raw), null, 2) + } catch { + return raw + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a17a28e..6de201a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -37,6 +37,22 @@ export interface Order { commission_amt: number status: 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 }