对接皮肤源头开放接口:查询、发货推送与 HMAC 签名鉴权

- 新增订单查询与发货结果推送开放接口,支持 can_ship 与幂等
- 鉴权采用 X-Api-Key + Timestamp + Nonce + HMAC-SHA256 签名
- 订单扩展发货字段与 ship_logs,管理端增加发货记录与开放文档页
This commit is contained in:
yml2213
2026-07-20 16:06:44 +08:00
parent 829bea309d
commit 89cdd32181
16 changed files with 1584 additions and 39 deletions
+262 -4
View File
@@ -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"`