Files
affiliate_dash/backend/internal/service/delivery.go
T
yml2213 11bce9c87e 优化:已交付订单保留复制/打开发货链接,去掉作废操作
- 已交付订单仍可复用未过期链接查看兑换结果,但不再生成/续期新链接
- 商户后台已交付订单仅显示复制/打开,已作废/已过期单独标识
- 发货中/已取消订单不展示链接操作,避免无效按钮
2026-08-14 13:19:27 +08:00

820 lines
29 KiB
Go

package service
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/timeutil"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff"
// 发货提交阶段,持久化在 result_data.provider_order_stage 中,供排障与补偿扫描区分处理。
const (
deliveryStageClaimed = "claimed" // 已锁定订单,尚未建 queue
deliveryStageQueueCreated = "queue_created" // 已在上游建 queue,尚未生成正式订单
deliveryStageSubmitted = "submitted" // 已在上游创建正式订单
deliveryStageBindVerify = "bind_verify" // 绑定账号校验失败
deliveryStageBindMismatch = "bind_mismatch" // 玩家编号与绑定账号不一致
deliveryStageCreateQueue = "create_queue" // 创建上游 queue 失败
deliveryStagePatchQueue = "patch_queue" // 补充 queue 账号信息失败
deliveryStageCreateOrder = "create_upstream" // 创建上游正式订单失败
deliverySubmissionStaleTimeout = 10 * time.Minute
)
// deliverySubmittedUpstream 判断订单是否已成功提交到上游正式订单。
// 已提交上游的失败订单禁止自动重发,避免重复发货。
func deliverySubmittedUpstream(order *model.FulfillmentOrder) bool {
if order == nil {
return false
}
if strings.TrimSpace(order.ProviderOrderNo) != "" {
return true
}
m := resultDataMap(order.ResultData)
if _, ok := m["delivery"]; ok {
return true
}
if _, ok := m["upstream_order"]; ok { // 兼容历史数据
return true
}
return resultDataString(order.ResultData, "provider_order_stage") == deliveryStageSubmitted
}
type DeliveryService struct {
fulfillment *FulfillmentService
bffBaseURL string
channel string
linkBaseURL string
linkSecret string
linkTTL time.Duration
httpClient *http.Client
}
func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, linkBaseURL, linkSecret string, linkTTLMinutes int) *DeliveryService {
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
if bffBaseURL == "" {
bffBaseURL = defaultDeliveryBFFBaseURL
}
channel = strings.TrimSpace(channel)
if channel == "" {
channel = "dlc"
}
linkBaseURL = strings.TrimRight(strings.TrimSpace(linkBaseURL), "/")
linkSecret = strings.TrimSpace(linkSecret)
if linkSecret == "" {
linkSecret = "affiliate-dash-delivery-link-secret"
}
if linkTTLMinutes <= 0 {
linkTTLMinutes = 120
}
return &DeliveryService{
fulfillment: fulfillment,
bffBaseURL: bffBaseURL,
channel: channel,
linkBaseURL: linkBaseURL,
linkSecret: linkSecret,
linkTTL: time.Duration(linkTTLMinutes) * time.Minute,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *DeliveryService) SetHTTPClient(client *http.Client) {
if client != nil {
s.httpClient = client
}
}
type DeliveryOrderInfo struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
CanShip bool `json:"can_ship"`
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
Product *DeliveryProduct `json:"product,omitempty"`
BuyerName string `json:"buyer_name"`
Amount int64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
ShippedAt *time.Time `json:"shipped_at"`
ShipFailReason string `json:"ship_fail_reason,omitempty"`
GameChannel string `json:"game_channel,omitempty"`
GameUID string `json:"game_uid,omitempty"`
RoleName string `json:"role_name,omitempty"`
PayScore int `json:"pay_score,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Good map[string]interface{} `json:"good,omitempty"`
}
type DeliveryProduct struct {
Name string `json:"name"`
SKU string `json:"sku"`
Game string `json:"game"`
Image string `json:"image,omitempty"`
}
type DeliveryBindResult struct {
BindUUID string `json:"bind_uuid"`
BindURL string `json:"bind_url"`
QRURL string `json:"qr_url"`
Bound bool `json:"bound"`
GameAccount string `json:"game_account,omitempty"`
RoleName string `json:"role_name,omitempty"`
GameChannel string `json:"game_channel,omitempty"`
ExpectedGameAccount string `json:"expected_game_account,omitempty"`
Mismatch bool `json:"mismatch,omitempty"`
Message string `json:"message,omitempty"`
}
type DeliveryLinkResult struct {
OrderNo string `json:"order_no"`
DeliveryURL string `json:"delivery_url"`
ExpiresAt time.Time `json:"expires_at"`
Exp int64 `json:"exp"`
Sign string `json:"sign"`
}
type DeliverySubmitResult struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
Message string `json:"message"`
ProviderOrderNo string `json:"provider_order_no,omitempty"`
GameAccount map[string]interface{} `json:"game_account,omitempty"`
UpstreamOrder interface{} `json:"delivery,omitempty"`
}
type DeliveryLinkAuth struct {
Exp int64
Sign string
}
type deliveryHTTPError struct {
status int
message string
}
func (e deliveryHTTPError) Error() string {
return e.message
}
func (e deliveryHTTPError) HTTPStatus() int {
return e.status
}
func newDeliveryHTTPError(status int, message string) error {
return deliveryHTTPError{status: status, message: message}
}
func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*DeliveryOrderInfo, error) {
info, _, _, err := s.prepareOrder(orderNo, false, &auth)
return info, err
}
func (s *DeliveryService) GetMerchantOrder(merchantID uint, orderNo string) (*DeliveryOrderInfo, error) {
openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false)
return info, err
}
func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) {
return s.bindWithOrder(orderNo, gameAccount, &auth, 0)
}
func (s *DeliveryService) BindForMerchant(merchantID uint, orderNo, gameAccount string) (*DeliveryBindResult, error) {
return s.bindWithOrder(orderNo, gameAccount, nil, merchantID)
}
// GetBindResult 查询绑定结果(自建发货页轮询用)。
// 绑定未完成时返回 bound=false(不报错),由前端提示继续扫码。
func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID string) (*DeliveryBindResult, error) {
bindUUID = strings.TrimSpace(bindUUID)
if bindUUID == "" {
return nil, errors.New("bind_uuid 不能为空")
}
openOrder, order, goodID, err := s.loadMerchantOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil {
return nil, err
}
account, err := s.accountBound(bindUUID, goodID)
if err != nil {
if strings.Contains(err.Error(), "尚未绑定") || strings.Contains(err.Error(), "未绑定") {
return &DeliveryBindResult{BindUUID: bindUUID, Bound: false, Message: "尚未绑定,请先完成扫码绑定"}, nil
}
return nil, err
}
boundAccount := stringFromMap(account, "game_account")
result := &DeliveryBindResult{
BindUUID: bindUUID,
Bound: true,
GameAccount: boundAccount,
RoleName: stringFromMap(account, "game_account_role_name"),
GameChannel: gameChannelText(account),
Message: "绑定成功",
}
// 下单时 data 透传的预期账号:绑定账号与预期不一致时提示,供自建页提前拦截。
if expected := requestDataString(order.RequestData, "game_account"); expected != "" {
result.ExpectedGameAccount = expected
if boundAccount != expected {
result.Mismatch = true
result.Message = "绑定成功,但绑定账号与下单预期账号不一致,请确认"
}
}
return result, nil
}
func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *DeliveryLinkAuth, merchantID uint) (*DeliveryBindResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
if gameAccount == "" {
return nil, errors.New("请输入玩家编号")
}
var (
openOrder *OpenOrderQuery
order *model.FulfillmentOrder
goodID string
err error
)
if merchantID > 0 {
openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo)
} else {
openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth)
}
if err != nil {
return nil, err
}
_, _, _, err = s.buildDeliveryState(openOrder, order, goodID, true)
if err != nil {
return nil, err
}
return s.bindGood(gameAccount, goodID)
}
func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) {
var out struct {
BindUUID string `json:"bind_uuid"`
URL string `json:"url"`
}
if err := s.signProxy("/public/games/bind-account", "POST", map[string]interface{}{
"game_account": gameAccount,
"gameAccount": gameAccount,
"goodId": goodID,
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
if out.BindUUID == "" || out.URL == "" {
return nil, errors.New("绑定服务未返回绑定二维码")
}
return &DeliveryBindResult{
BindUUID: out.BindUUID,
BindURL: out.URL,
QRURL: "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(out.URL),
}, nil
}
func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth DeliveryLinkAuth) (*DeliverySubmitResult, error) {
return s.submit(orderNo, gameAccount, bindUUID, 0, &auth, 0)
}
func (s *DeliveryService) SubmitForMerchant(merchantID, apiClientID uint, orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) {
return s.submit(orderNo, gameAccount, bindUUID, apiClientID, nil, merchantID)
}
func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClientID uint, auth *DeliveryLinkAuth, merchantID uint) (*DeliverySubmitResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
bindUUID = strings.TrimSpace(bindUUID)
orderNo = strings.TrimSpace(orderNo)
if gameAccount == "" || bindUUID == "" {
return nil, errors.New("玩家编号和绑定凭证不能为空")
}
var (
openOrder *OpenOrderQuery
order *model.FulfillmentOrder
goodID string
err error
)
if merchantID > 0 {
openOrder, order, goodID, err = s.loadMerchantOrder(merchantID, orderNo)
} else {
openOrder, order, goodID, err = s.loadLinkedOrder(orderNo, auth)
}
if err != nil {
return nil, err
}
info, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false)
if err != nil {
return nil, err
}
orderStatus := normalizeOrderStatus(order)
if orderStatus == model.OrderStatusDelivering ||
orderStatus == model.OrderStatusDelivered ||
strings.TrimSpace(order.ProviderOrderNo) != "" ||
strings.Contains(order.ResultData, "\"queue_order_id\"") {
if existing := buildExistingDeliverySubmitResult(order); existing != nil {
return existing, nil
}
}
// 已在上游创建正式订单的失败订单禁止自动重发,避免重复发货。
if orderStatus == model.OrderStatusShipFailed && deliverySubmittedUpstream(order) {
return nil, newDeliveryHTTPError(http.StatusConflict, "该订单已进入发货流程,为避免重复发货不能直接重试,请稍后查询订单状态或联系平台处理")
}
if !info.CanShip {
reason := info.CannotShipReason
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
claimed, claimedNow, err := s.claimDeliverySubmission(order.MerchantID, apiClientID, order.OrderNo)
if err != nil {
return nil, err
}
if !claimedNow {
if existing := buildExistingDeliverySubmitResult(claimed); existing != nil {
return existing, nil
}
return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询")
}
boundAccount, err := s.accountBound(bindUUID, goodID)
if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindVerify, err.Error())
return nil, err
}
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindMismatch, err.Error())
return nil, err
}
queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo)
if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateQueue, err.Error())
return nil, err
}
// 建 queue 后只持久化阶段信息,不提前改成 delivering。
// 上游创建正式订单前会回查开放接口,此时订单必须仍然 can_ship=true。
if err := s.recordDeliverySubmissionStage(claimed, deliveryStageQueueCreated, map[string]interface{}{
"queue_order_id": queueOrderID,
}); err != nil {
return nil, err
}
if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStagePatchQueue, err.Error())
return nil, err
}
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, err.Error())
return nil, err
}
providerOrderNo := firstNonEmpty(
stringFromMap(upstreamOrder, "order_no"),
stringFromMap(upstreamOrder, "_id"),
stringFromMap(upstreamOrder, "id"),
stringFromMap(upstreamOrder, "order_sn"),
queueOrderID,
)
resultData := mergeResultData(claimed.ResultData, map[string]interface{}{
"provider_order_stage": deliveryStageSubmitted,
"queue_order_id": queueOrderID,
"provider_order_no": providerOrderNo,
"game_uid": gameAccount,
"role_name": stringFromMap(boundAccount, "game_account_role_name"),
"game_channel": gameChannelText(boundAccount),
"delivery": upstreamOrder,
})
nextStatus := model.OrderStatusDelivering
message := "已提交发货,等待发货结果回传"
if upstreamDeliverySucceeded(upstreamOrder) {
nextStatus = model.OrderStatusDelivered
message = "发货已完成"
}
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID,
APIClientID: apiClientID,
OrderNo: order.OrderNo,
Status: nextStatus,
ProviderOrderNo: providerOrderNo,
ResultData: resultData,
})
if err != nil {
return nil, err
}
return &DeliverySubmitResult{
OrderNo: updated.OrderNo,
Status: normalizeOrderStatus(updated),
Message: message,
ProviderOrderNo: providerOrderNo,
GameAccount: boundAccount,
UpstreamOrder: upstreamOrder,
}, nil
}
func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, orderNo string) (*model.FulfillmentOrder, bool, error) {
var updated model.FulfillmentOrder
claimedNow := false
err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
var order model.FulfillmentOrder
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("merchant_id = ? AND order_no = ?", merchantID, orderNo).
First(&order).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("订单不存在")
}
return err
}
orderStatus := normalizeOrderStatus(&order)
if orderStatus == model.OrderStatusDelivering || orderStatus == model.OrderStatusDelivered {
updated = order
return nil
}
if err := validateOrderStatusTransition(&order, model.OrderStatusDelivering, fulfillmentTransitionUpdate); err != nil {
return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货")
}
now := time.Now()
stage := resultDataString(order.ResultData, "provider_order_stage")
if deliverySubmissionInProgress(stage) && !deliverySubmissionStale(&order, now) {
updated = order
return nil
}
patch := map[string]interface{}{
"source": "delivery_proxy",
"submit_started_at": timeutil.FormatAPITime(now),
"provider_order_stage": deliveryStageClaimed,
"ship_attempts": resultDataNumber(order.ResultData, "ship_attempts") + 1,
}
if err := tx.Model(&order).Updates(map[string]interface{}{
"result_data": mergeResultData(order.ResultData, patch),
"failure_reason": "",
}).Error; err != nil {
return err
}
claimedNow = true
if err := tx.First(&updated, order.ID).Error; err != nil {
return err
}
apiClientIDPtr := optionalUint(apiClientID)
if err := writeAudit(tx, &merchantID, nil, apiClientIDPtr, "delivery.submit.claim", "fulfillment_order", order.OrderNo, nil); err != nil {
return err
}
return nil
})
if err != nil {
return nil, false, err
}
return &updated, claimedNow, nil
}
func (s *DeliveryService) recordDeliverySubmissionStage(order *model.FulfillmentOrder, stage string, patch map[string]interface{}) error {
if order == nil {
return errors.New("订单不存在")
}
if patch == nil {
patch = map[string]interface{}{}
}
patch["provider_order_stage"] = stage
resultData := mergeResultData(order.ResultData, patch)
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Update("result_data", resultData).Error; err != nil {
return err
}
order.ResultData = resultData
return nil
}
func (s *DeliveryService) markDeliverySubmissionFailed(order *model.FulfillmentOrder, apiClientID uint, stage, reason string) {
if order == nil {
return
}
latest := order
if fresh, err := s.fulfillment.GetOrder(order.MerchantID, order.OrderNo); err == nil {
latest = fresh
}
resultData := mergeResultData(latest.ResultData, map[string]interface{}{
"provider_order_stage": stage,
"fail_stage": stage,
"fail_reason": reason,
"fail_at": timeutil.FormatAPITime(time.Now()),
})
_, _ = s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID,
APIClientID: apiClientID,
OrderNo: order.OrderNo,
Status: model.OrderStatusShipFailed,
FailureReason: reason,
ResultData: resultData,
})
}
func (s *DeliveryService) loadLinkedOrder(orderNo string, auth *DeliveryLinkAuth) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) {
openOrder, err := s.fulfillment.QueryOpenOrder(orderNo)
if err != nil {
return nil, nil, "", err
}
order, err := s.fulfillment.GetByOrderNo(orderNo)
if err != nil {
return nil, nil, "", err
}
if auth != nil {
if err := s.authorizeDeliveryLink(order, *auth); err != nil {
return nil, nil, "", err
}
}
goodID := ""
if openOrder.Product != nil {
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
}
return openOrder, order, goodID, nil
}
func (s *DeliveryService) loadMerchantOrder(merchantID uint, orderNo string) (*OpenOrderQuery, *model.FulfillmentOrder, string, error) {
if merchantID == 0 {
return nil, nil, "", errors.New("无效的商户")
}
openOrder, err := s.fulfillment.QueryOpenOrder(orderNo)
if err != nil {
return nil, nil, "", err
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, nil, "", err
}
goodID := ""
if openOrder.Product != nil {
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
}
return openOrder, order, goodID, nil
}
func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *model.FulfillmentOrder, goodID string, requireCanShip bool) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) {
if openOrder == nil || order == nil {
return nil, nil, "", errors.New("订单不存在")
}
canShip, reason := CanFulfill(order)
if !openOrder.CanShip {
reason = openOrder.CannotShipReason
canShip = false
}
if !canShip && reason == "" {
reason = "订单暂不可发货"
}
if openOrder.Product == nil || openOrder.Product.SKU == "" {
canShip = false
reason = "订单缺少商品 SKU"
} else if goodID == "" {
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
if goodID == "" {
canShip = false
reason = "未找到对应的商品发货配置"
}
}
if requireCanShip && !canShip {
return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
}
var good map[string]interface{}
if goodID != "" {
good, _ = s.goodsDetail(goodID)
}
product := buildDeliveryProduct(openOrder.Product, good)
info := &DeliveryOrderInfo{
OrderNo: openOrder.OrderNo,
Status: openOrder.Status,
CanShip: canShip,
CannotShipReason: reason,
Product: product,
BuyerName: openOrder.BuyerName,
Amount: openOrder.Amount,
CreatedAt: openOrder.CreatedAt,
ShippedAt: openOrder.ShippedAt,
ShipFailReason: openOrder.ShipFailReason,
GameChannel: openOrder.GameChannel,
GameUID: openOrder.GameUID,
RoleName: openOrder.RoleName,
PayScore: openOrder.PayScore,
Data: requestDataMap(order.RequestData),
Good: good,
}
return info, order, goodID, nil
}
func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
// 已取消订单即使存在未过期链接也不再发放(买家侧授权同样拦截已取消订单)。
if normalizeOrderStatus(order) == model.OrderStatusCancelled {
return nil, newDeliveryHTTPError(http.StatusBadRequest, "订单已取消,无法获取发货链接")
}
now := time.Now().UTC()
if order.DeliveryLinkRevokedAt != nil {
return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
}
// 复用未过期链接:已交付/发货中订单也允许查看兑换结果或进度,仅停止发放新链接。
if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) {
return s.buildDeliveryLinkResult(order.OrderNo, order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second), requestBaseURL), nil
}
// 仅待发货(含可重试的发货失败)订单能生成或续期新链接。
canShip, reason := CanFulfill(order)
if !canShip {
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
expiresAt := now.Add(s.linkTTL).UTC().Truncate(time.Second)
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_expires_at": expiresAt,
"delivery_link_revoked_at": nil,
}).Error; err != nil {
return nil, err
}
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
}
func (s *DeliveryService) RevokeDeliveryLink(merchantID, actorUserID uint, orderNo string) error {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return err
}
now := time.Now().UTC().Truncate(time.Second)
return s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_revoked_at": now,
}).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.revoke", "fulfillment_order", order.OrderNo, map[string]interface{}{
"revoked_at": timeutil.FormatAPITime(now),
})
})
}
func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
canShip, reason := CanFulfill(order)
if !canShip {
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
expiresAt := time.Now().UTC().Add(s.linkTTL).Truncate(time.Second)
if order.DeliveryLinkExpiresAt != nil && expiresAt.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) {
expiresAt = expiresAt.Add(time.Second)
}
if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_expires_at": expiresAt,
"delivery_link_revoked_at": nil,
}).Error; err != nil {
return err
}
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, map[string]interface{}{
"expires_at": timeutil.FormatAPITime(expiresAt),
})
}); err != nil {
return nil, err
}
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
}
func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth *DeliveryLinkAuth) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, nil, "", errors.New("订单号不能为空")
}
openOrder, err := s.fulfillment.QueryOpenOrder(orderNo)
if err != nil {
return nil, nil, "", err
}
order, err := s.fulfillment.GetByOrderNo(orderNo)
if err != nil {
return nil, nil, "", err
}
if auth != nil {
if err := s.authorizeDeliveryLink(order, *auth); err != nil {
return nil, nil, "", err
}
}
canShip, reason := CanFulfill(order)
if !openOrder.CanShip {
reason = openOrder.CannotShipReason
canShip = false
}
if !canShip && reason == "" {
reason = "订单暂不可发货"
}
goodID := ""
if openOrder.Product == nil || openOrder.Product.SKU == "" {
canShip = false
reason = "订单缺少商品 SKU"
} else {
goodID = deliveryGoodID(s.channel, openOrder.Product.SKU)
if goodID == "" {
canShip = false
reason = "未找到对应的商品发货配置"
}
}
if requireCanShip && !canShip {
return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
}
var good map[string]interface{}
if goodID != "" {
good, _ = s.goodsDetail(goodID)
}
product := buildDeliveryProduct(openOrder.Product, good)
info := &DeliveryOrderInfo{
OrderNo: openOrder.OrderNo,
Status: openOrder.Status,
CanShip: canShip,
CannotShipReason: reason,
Product: product,
BuyerName: openOrder.BuyerName,
Amount: openOrder.Amount,
CreatedAt: openOrder.CreatedAt,
ShippedAt: openOrder.ShippedAt,
ShipFailReason: openOrder.ShipFailReason,
GameChannel: openOrder.GameChannel,
GameUID: openOrder.GameUID,
RoleName: openOrder.RoleName,
PayScore: openOrder.PayScore,
Data: requestDataMap(order.RequestData),
Good: good,
}
return info, order, goodID, nil
}
func deliveryGoodID(channel, sku string) string {
if channel != "dlc" {
return ""
}
return map[string]string{
"suit_alan_walker": "682ef3c5a8f40c4234c59f47",
"suit_shadow_gothic": "682ef39ca8f40c4234c59f36",
"top_black_elite_trainer": "682ef39ca8f40c4234c59f37",
"m416_hamster_gray": "682ef3c5a8f40c4234c59f52",
"bag_cute_bear": "682ef3c5a8f40c4234c59f4a",
"suit_dual_fluffy": "682ef3c5a8f40c4234c59f49",
"suit_pink_sheep": "682ef3c5a8f40c4234c59f4d",
"suit_first_peach": "682ef3c5a8f40c4234c59f48",
"suit_romantic_destiny": "682ef39ca8f40c4234c59f38",
"pack_western_cowboy": "682ef3c5a8f40c4234c59f4c",
"smoke_pink_sheep": "682ef3c5a8f40c4234c59f50",
"frag_pink_sheep": "682ef3c5a8f40c4234c59f51",
"suit_hamster_gray": "682ef3c5a8f40c4234c59f53",
"suit_cute_bear": "682ef3c5a8f40c4234c59f4b",
"bag_pink_sheep": "682ef3c5a8f40c4234c59f4f",
"helmet_pink_sheep": "682ef3c5a8f40c4234c59f4e",
"bag_hamster_gray": "682ef3c5a8f40c4234c59f55",
"helmet_hamster_gray": "682ef3c5a8f40c4234c59f54",
"suit_western_mystery": "682ef39ca8f40c4234c59f35",
"helmet_panda_treasure": "682ef39ca8f40c4234c59f39",
"suit_panda_round": "682ef39ca8f40c4234c59f3a",
"suit_panda_tuan": "682ef39ca8f40c4234c59f3b",
"pack_lava_ranger": "682ef39ca8f40c4234c59f3c",
"suit_sand_dancer": "682ef39ca8f40c4234c59f3d",
"pack_star_roam_outfit": "682ef39ca8f40c4234c59f3e",
"pack_star_roam_weapon": "682ef39ca8f40c4234c59f3f",
"suit_cloud_bear": "682ef39ca8f40c4234c59f40",
"honor_medal_x2": "682ef39ca8f40c4234c59f45",
"honor_medal_x30": "682ef39ca8f40c4234c59f43",
"honor_medal_x90": "682ef39ca8f40c4234c59f41",
"lucky_coin_x2": "682ef39ca8f40c4234c59f46",
"lucky_coin_x30": "682ef39ca8f40c4234c59f44",
"lucky_coin_x90": "682ef39ca8f40c4234c59f42",
}[sku]
}