Files
affiliate_dash/backend/internal/service/order.go
T
yml2213 e4d0a71963 支持 .env 配置,并完善源头对接文档与测试订单
- 增加 .env.example 与 godotenv 加载,start.sh 自动读环境变量
- 重写发给源头的开放接口对接文档
- 订单页支持创建测试订单(可直接已支付并复制店铺订单号)
2026-07-20 16:19:57 +08:00

418 lines
12 KiB
Go

package service
import (
"encoding/json"
"errors"
"fmt"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
type OrderService struct {
db *gorm.DB
}
func NewOrderService(db *gorm.DB) *OrderService {
return &OrderService{db: db}
}
type OrderListQuery struct {
Page int
Size int
Status string
DistributorID *uint
}
type CreateOrderInput struct {
SkinID uint
DistributorID uint
BuyerName string
Remark string
// Status 可选:pending(默认)/ paid(联调测试可直接创建可发货订单)
Status string
}
func (s *OrderService) List(q OrderListQuery) ([]model.Order, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 || q.Size > 100 {
q.Size = 20
}
tx := s.db.Model(&model.Order{})
if q.Status != "" {
tx = tx.Where("status = ?", q.Status)
}
if q.DistributorID != nil {
tx = tx.Where("distributor_id = ?", *q.DistributorID)
}
var total int64
if err := tx.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Order
err := tx.Preload("Skin").Preload("Distributor").
Order("id DESC").
Offset((q.Page - 1) * q.Size).Limit(q.Size).
Find(&list).Error
return list, total, err
}
func (s *OrderService) Create(in CreateOrderInput) (*model.Order, error) {
var skin model.Skin
if err := s.db.First(&skin, in.SkinID).Error; err != nil {
return nil, errors.New("皮肤不存在")
}
if skin.Status != 1 {
return nil, errors.New("皮肤已下架")
}
if skin.Stock == 0 {
return nil, errors.New("库存不足")
}
status := model.OrderStatusPending
if in.Status == model.OrderStatusPaid {
status = model.OrderStatusPaid
}
order := &model.Order{
OrderNo: generateOrderNo(),
SkinID: in.SkinID,
DistributorID: in.DistributorID,
BuyerName: in.BuyerName,
Amount: skin.Price,
CommissionAmt: skin.Price * skin.Commission,
Status: status,
Remark: in.Remark,
}
err := s.db.Transaction(func(tx *gorm.DB) error {
if skin.Stock > 0 {
res := tx.Model(&model.Skin{}).
Where("id = ? AND stock > 0", skin.ID).
Update("stock", gorm.Expr("stock - 1"))
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("库存不足")
}
}
return tx.Create(order).Error
})
if err != nil {
return nil, err
}
// 带回商品信息,方便前端展示 sku / 订单号联调
_ = s.db.Preload("Skin").First(order, order.ID).Error
return order, nil
}
func (s *OrderService) UpdateStatus(id uint, status string) error {
allowed := map[string]bool{
model.OrderStatusPending: true,
model.OrderStatusPaid: true,
model.OrderStatusDelivering: true,
model.OrderStatusDelivered: true,
model.OrderStatusShipFailed: true,
model.OrderStatusCancelled: true,
}
if !allowed[status] {
return errors.New("无效的订单状态")
}
res := s.db.Model(&model.Order{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return errors.New("订单不存在")
}
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"`
OrderCount int64 `json:"order_count"`
TotalSales float64 `json:"total_sales"`
TotalCommission float64 `json:"total_commission"`
PendingOrderCount int64 `json:"pending_order_count"`
}
func (s *OrderService) Dashboard() (*DashboardStats, error) {
stats := &DashboardStats{}
s.db.Model(&model.Skin{}).Count(&stats.SkinCount)
s.db.Model(&model.User{}).Where("role = ?", model.RoleDistributor).Count(&stats.DistributorCount)
s.db.Model(&model.Order{}).Count(&stats.OrderCount)
s.db.Model(&model.Order{}).Where("status = ?", model.OrderStatusPending).Count(&stats.PendingOrderCount)
s.db.Model(&model.Order{}).
Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}).
Select("COALESCE(SUM(amount),0)").Scan(&stats.TotalSales)
s.db.Model(&model.Order{}).
Where("status IN ?", []string{model.OrderStatusPaid, model.OrderStatusDelivered}).
Select("COALESCE(SUM(commission_amt),0)").Scan(&stats.TotalCommission)
return stats, nil
}
func generateOrderNo() string {
return fmt.Sprintf("O%s%04d", time.Now().Format("20060102150405"), time.Now().Nanosecond()%10000)
}