package service import ( "context" "encoding/json" "errors" "net/http" "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 // 第一平台创建正式订单常态约 11 秒,15 秒上限会在偶发变慢时提前断开。 deliveryUpstreamTimeout = 30 * time.Second ) // 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 router *FulfillmentRouter } 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" } // linkTTLMinutes <= 0 表示链接永久有效(0 为默认值)。 if linkTTLMinutes < 0 { linkTTLMinutes = 0 } client := &http.Client{Timeout: deliveryUpstreamTimeout} dlc := NewDLCProvider(bffBaseURL, channel, client) router := NewFulfillmentRouter(dlc) return &DeliveryService{ fulfillment: fulfillment, bffBaseURL: bffBaseURL, channel: channel, linkBaseURL: linkBaseURL, linkSecret: linkSecret, linkTTL: time.Duration(linkTTLMinutes) * time.Minute, httpClient: client, router: router, } } func (s *DeliveryService) SetHTTPClient(client *http.Client) { if client != nil { s.httpClient = client if dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider); ok && dlc != nil { dlc.httpClient = client } } } func (s *DeliveryService) SetRouter(router *FulfillmentRouter) { if router != nil { s.router = router } } func (s *DeliveryService) RegisterProvider(provider FulfillmentProvider) { if s.router == nil { s.router = NewFulfillmentRouter(provider) } else if provider != nil { s.router.providers[provider.Name()] = provider } } // CancelUpstream 取消订单时同步取消已锁定/已建单的上游履约渠道。 func (s *DeliveryService) CancelUpstream(order *model.FulfillmentOrder) error { if order == nil || s.router == nil { return nil } if name := resultDataString(order.ResultData, "provider"); name != "" { if p := s.router.GetProvider(name); p != nil { return p.Cancel(context.Background(), order) } } if resultDataString(order.ResultData, "lewan_order_id") != "" { if p := s.router.GetProvider(ProviderLewan); p != nil { return p.Cancel(context.Background(), order) } } return nil } // SyncDeliveringOrder 主动向上游渠道查询处于 delivering 状态的订单结果,以补充 Webhook 丢失或延迟。 func (s *DeliveryService) SyncDeliveringOrder(ctx context.Context, order *model.FulfillmentOrder) (bool, error) { if order == nil || s.router == nil || order.OrderStatus != model.OrderStatusDelivering { return false, nil } if ctx == nil { ctx = context.Background() } providerName := resultDataString(order.ResultData, "provider") if providerName == "" && (resultDataString(order.ResultData, "lewan_order_id") != "" || resultDataString(order.ResultData, "lewan_order_no") != "" || strings.HasPrefix(order.ProviderOrderNo, "OMU")) { providerName = ProviderLewan } providers := make([]FulfillmentProvider, 0, 2) if providerName != "" { if p := s.router.GetProvider(providerName); p != nil { providers = append(providers, p) } } if len(providers) == 0 { resolved, _ := s.router.ResolveProviders(order) providers = resolved } for _, p := range providers { res, err := p.SyncStatus(ctx, order) if err != nil || res == nil { continue } if res.Status == "success" || res.Status == "failed" { input := ShipNotifyInput{ OrderNo: order.OrderNo, ShipStatus: res.Status, ProviderOrderNo: res.ProviderOrderNo, FailReason: res.FailReason, RawPayload: res.RawPayload, } if res.GameUID != "" { input.GameUID = &res.GameUID } if res.RoleName != "" { input.RoleName = &res.RoleName } if res.RoleArea != "" { input.GameChannel = &res.RoleArea } if _, err := s.fulfillment.HandleShipNotify(input); err != nil { return false, err } return true, nil } // 如果仍在发货中,且获得了游戏UID/角色名/区服,补充写入 ResultData patch := map[string]interface{}{} if res.GameUID != "" && resultDataString(order.ResultData, "game_uid") == "" { patch["game_uid"] = res.GameUID } if res.RoleName != "" && resultDataString(order.ResultData, "role_name") == "" { patch["role_name"] = res.RoleName } if res.RoleArea != "" && resultDataString(order.ResultData, "game_channel") == "" { patch["game_channel"] = res.RoleArea } if len(patch) > 0 { _ = s.persistOrderResultPatch(order, patch) } return false, nil } return false, nil } // persistOrderResultPatch 合并写入订单 result_data,不改变发货 stage。 func (s *DeliveryService) persistOrderResultPatch(order *model.FulfillmentOrder, patch map[string]interface{}) error { if order == nil || len(patch) == 0 { return nil } 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) clearSelectedProvider(order *model.FulfillmentOrder) { if order == nil || strings.TrimSpace(order.ResultData) == "" { return } var raw map[string]interface{} if err := json.Unmarshal([]byte(order.ResultData), &raw); err != nil { return } if _, ok := raw["provider"]; !ok { return } delete(raw, "provider") b, err := json.Marshal(raw) if err != nil { return } resultData := string(b) _ = s.fulfillment.db.Model(&model.FulfillmentOrder{}). Where("id = ?", order.ID). Update("result_data", resultData).Error order.ResultData = resultData } 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"` ProviderOrderNo string `json:"provider_order_no,omitempty"` ProviderName string `json:"provider_name,omitempty"` 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,omitempty"` // null 表示永久有效 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 } providers, _ := s.router.ResolveProviders(order) if len(providers) == 0 { return nil, errors.New("未配置发货渠道") } p := providers[0] res, err := p.CheckBind(context.Background(), order, bindUUID) if err != nil { return nil, err } return &DeliveryBindResult{ BindUUID: res.BindUUID, BindURL: res.BindURL, QRURL: res.QRURL, Bound: res.Bound, GameAccount: res.GameAccount, RoleName: res.RoleName, GameChannel: res.GameChannel, ExpectedGameAccount: res.ExpectedGameAccount, Mismatch: res.Mismatch, Message: res.Message, }, nil } // GetBindResultLinked 为公开发货链接场景提供查询绑定结果的能力。 func (s *DeliveryService) GetBindResultLinked(orderNo, bindUUID string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) { bindUUID = strings.TrimSpace(bindUUID) if bindUUID == "" { return nil, errors.New("bind_uuid 不能为空") } openOrder, order, goodID, err := s.loadLinkedOrder(orderNo, &auth) if err != nil { return nil, err } if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil { return nil, err } providers, _ := s.router.ResolveProviders(order) if len(providers) == 0 { return nil, errors.New("未配置发货渠道") } p := providers[0] res, err := p.CheckBind(context.Background(), order, bindUUID) if err != nil { return nil, err } return &DeliveryBindResult{ BindUUID: res.BindUUID, BindURL: res.BindURL, QRURL: res.QRURL, Bound: res.Bound, GameAccount: res.GameAccount, RoleName: res.RoleName, GameChannel: res.GameChannel, ExpectedGameAccount: res.ExpectedGameAccount, Mismatch: res.Mismatch, Message: res.Message, }, 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 } providers, _ := s.router.ResolveProviders(order) if len(providers) == 0 { return nil, errors.New("未配置发货渠道") } var lastErr error var bindRes *ProviderBindResult var usedProvider FulfillmentProvider tried := map[string]bool{} for _, p := range providers { tried[p.Name()] = true res, err := p.Bind(context.Background(), order, gameAccount) if err == nil && res != nil { bindRes = res usedProvider = p break } lastErr = err } // Prepare 可能因 GET 成功先锁住乐玩;随后写接口凭据/IP 校验失败时,清除锁定并尝试其余渠道(如 DLC)。 if bindRes == nil { lockedName := resultDataString(order.ResultData, "provider") if lockedName != "" || lastErr != nil { s.clearSelectedProvider(order) fallbackProviders, _ := s.router.ResolveProviders(order) for _, p := range fallbackProviders { if tried[p.Name()] { continue } tried[p.Name()] = true res, err := p.Bind(context.Background(), order, gameAccount) if err == nil && res != nil { bindRes = res usedProvider = p break } lastErr = err } } } if bindRes == nil { if lastErr != nil { return nil, lastErr } return nil, errors.New("发货渠道未就绪") } // 记录选定渠道与上游单号(如 lewan_order_id),不提前写入 submit claimed stage if usedProvider != nil { patch := map[string]interface{}{ "provider": usedProvider.Name(), } for k, v := range bindRes.PatchResultData { patch[k] = v } _ = s.persistOrderResultPatch(order, patch) } return &DeliveryBindResult{ BindUUID: bindRes.BindUUID, BindURL: bindRes.BindURL, QRURL: bindRes.QRURL, Bound: bindRes.Bound, GameAccount: bindRes.GameAccount, RoleName: bindRes.RoleName, GameChannel: bindRes.GameChannel, ExpectedGameAccount: bindRes.ExpectedGameAccount, Mismatch: bindRes.Mismatch, Message: bindRes.Message, }, nil } func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) { dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider) if !ok || dlc == nil { return nil, errors.New("DLC 渠道未就绪") } res, err := dlc.Bind(context.Background(), &model.FulfillmentOrder{ ProductSKU: "", }, gameAccount) if err != nil { return nil, err } return &DeliveryBindResult{ BindUUID: res.BindUUID, BindURL: res.BindURL, QRURL: res.QRURL, Bound: res.Bound, }, 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\"") || strings.Contains(order.ResultData, "\"lewan_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, "订单暂时正在发货中,请稍后查询") } providers, _ := s.router.ResolveProviders(claimed) if len(providers) == 0 { s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, "未配置发货渠道") return nil, errors.New("未配置发货渠道") } activeProvider := providers[0] submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID) if submitRes != nil && len(submitRes.PatchResultData) > 0 { stage := deliveryStageCreateOrder if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { stage = v } _ = s.recordDeliverySubmissionStage(claimed, stage, submitRes.PatchResultData) } if err != nil { failStage := deliveryStageCreateOrder if submitRes != nil { if v, ok := submitRes.PatchResultData["provider_order_stage"].(string); ok && v != "" { failStage = v } } s.markDeliverySubmissionFailed(claimed, apiClientID, failStage, err.Error()) return nil, err } resultData := mergeResultData(claimed.ResultData, submitRes.PatchResultData) updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{ MerchantID: order.MerchantID, APIClientID: apiClientID, OrderNo: order.OrderNo, Status: submitRes.NextStatus, ProviderOrderNo: submitRes.ProviderOrderNo, ResultData: resultData, }) if err != nil { // 上游已有身份时先固化 result_data,避免重试双发 _ = s.recordDeliverySubmissionStage(claimed, deliveryStageSubmitted, submitRes.PatchResultData) return nil, err } return &DeliverySubmitResult{ OrderNo: updated.OrderNo, Status: normalizeOrderStatus(updated), Message: submitRes.Message, ProviderOrderNo: submitRes.ProviderOrderNo, GameAccount: submitRes.GameAccount, UpstreamOrder: submitRes.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"}). Preload("MerchantProduct"). 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") startedAtRaw := resultDataString(order.ResultData, "submit_started_at") // 1. 如果已有协程正在活跃提交中(在 35 秒执行窗口内且处于 claimed/queue_created 阶段), // 即使已被外部 Webhook 临时推回 ship_failed,也绝对不允许并发重入发货打架 if deliverySubmissionInProgress(stage) && startedAtRaw != "" { if startedAt, parseErr := time.Parse(timeutil.APITimeLayout, startedAtRaw); parseErr == nil { if now.Sub(startedAt) < 35*time.Second { updated = order return nil } } } // 2. 正常并发防双发:仅对 paid 等非最终失败态订单检查 10 分钟未超时 if orderStatus != model.OrderStatusShipFailed && 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.Preload("MerchantProduct").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 } // 确保失败时 stage 绝不能继续保持在 in_progress(claimed / queue_created),否则阻碍重试 releaseStage := stage if deliverySubmissionInProgress(releaseStage) { releaseStage = deliveryStageCreateOrder } resultData := mergeResultData(latest.ResultData, map[string]interface{}{ "provider_order_stage": releaseStage, "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 } if order.OrderStatus == model.OrderStatusDelivering { if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) order, _ = s.fulfillment.GetOrder(merchantID, orderNo) } } goodID := "" if openOrder != nil && 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" } var ( deliveryProd *DeliveryProduct goodData map[string]interface{} ) providers, _ := s.router.ResolveProviders(order) if len(providers) > 0 { locked := resultDataString(order.ResultData, "provider") != "" matchedState, lastReason := s.tryPrepareProviders(order, openOrder, providers) // 已锁定渠道不可发时,清除锁定并按完整优先级重新选择 if matchedState == nil && locked { s.clearSelectedProvider(order) providers, _ = s.router.ResolveProviders(order) matchedState, lastReason = s.tryPrepareProviders(order, openOrder, providers) } if matchedState != nil { deliveryProd = matchedState.Product goodData = matchedState.Good if matchedState.Provider != "" { _ = s.persistOrderResultPatch(order, map[string]interface{}{ "provider": matchedState.Provider, }) } } else { canShip = false if reason == "" || reason == "订单暂不可发货" { reason = lastReason if reason == "" { reason = "未找到适用的履约渠道配置" } } } } else if goodID != "" { goodData, _ = s.goodsDetail(goodID) } if deliveryProd == nil && openOrder.Product != nil { deliveryProd = buildDeliveryProduct(openOrder.Product, goodData) } if requireCanShip && !canShip { return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason) } providerOrderNo := strings.TrimSpace(openOrder.ProviderOrderNo) if providerOrderNo == "" { providerOrderNo = strings.TrimSpace(order.ProviderOrderNo) } if providerOrderNo == "" { providerOrderNo = resultDataString(order.ResultData, "lewan_order_no") } if providerOrderNo == "" { providerOrderNo = resultDataString(order.ResultData, "provider_order_no") } providerName := resultDataString(order.ResultData, "provider") if providerName == "" { if strings.TrimSpace(order.ProviderOrderNo) != "" || resultDataString(order.ResultData, "lewan_order_id") != "" { providerName = "lewan" } } roleName := openOrder.RoleName if roleName == "" { roleName = resultDataString(order.ResultData, "role_name") } gameChannel := openOrder.GameChannel if gameChannel == "" { gameChannel = resultDataString(order.ResultData, "game_channel") } // 若角色名或区服为空,尝试从关联的 rock_tokens 表反查并自动回填 if (roleName == "" || gameChannel == "") && s.fulfillment != nil && s.fulfillment.db != nil { var rockToken model.RockToken if err := s.fulfillment.db.Where("assigned_order_no = ?", order.OrderNo).First(&rockToken).Error; err == nil { patches := map[string]interface{}{} if roleName == "" && rockToken.ActualRoleName != "" { roleName = rockToken.ActualRoleName patches["role_name"] = roleName } if gameChannel == "" && rockToken.ActualChannel != "" { gameChannel = rockToken.ActualChannel patches["game_channel"] = gameChannel } if len(patches) > 0 { _ = s.persistOrderResultPatch(order, patches) } } } info := &DeliveryOrderInfo{ OrderNo: openOrder.OrderNo, Status: openOrder.Status, CanShip: canShip, CannotShipReason: reason, Product: deliveryProd, BuyerName: openOrder.BuyerName, Amount: openOrder.Amount, ProviderOrderNo: providerOrderNo, ProviderName: providerName, CreatedAt: openOrder.CreatedAt, ShippedAt: openOrder.ShippedAt, ShipFailReason: openOrder.ShipFailReason, GameChannel: gameChannel, GameUID: openOrder.GameUID, RoleName: roleName, PayScore: openOrder.PayScore, Data: requestDataMap(order.RequestData), Good: goodData, } return info, order, goodID, nil } // tryPrepareProviders 按优先级尝试 Prepare,返回第一个 CanShip 的渠道状态。 func (s *DeliveryService) tryPrepareProviders(order *model.FulfillmentOrder, openOrder *OpenOrderQuery, providers []FulfillmentProvider) (*DeliveryOrderState, string) { lastReason := "" for _, p := range providers { state, err := p.PrepareDelivery(context.Background(), order, openOrder) if err != nil || state == nil { continue } if state.Provider == "" { state.Provider = p.Name() } if state.CanShip { return state, "" } if state.CannotShipReason != "" { lastReason = state.CannotShipReason } } return nil, lastReason } 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) { expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second) return s.buildDeliveryLinkResult(order.OrderNo, &expiresAt, requestBaseURL), nil } // 永久有效链接(已生成、未作废、无有效期)直接复用,不重复生成。 if order.DeliveryLinkGeneratedAt != nil && order.DeliveryLinkExpiresAt == nil { return s.buildDeliveryLinkResult(order.OrderNo, nil, requestBaseURL), nil } // 仅待发货(含可重试的发货失败)订单能生成或续期新链接。 canShip, reason := CanFulfill(order) if !canShip { if reason == "" { reason = "订单暂不可发货" } return nil, newDeliveryHTTPError(http.StatusBadRequest, reason) } var expiresAt *time.Time if s.linkTTL > 0 { value := now.Add(s.linkTTL).UTC().Truncate(time.Second) expiresAt = &value } 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, "delivery_link_generated_at": now, }).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) } now := time.Now().UTC() var expiresAt *time.Time if s.linkTTL > 0 { value := now.Add(s.linkTTL).Truncate(time.Second) if order.DeliveryLinkExpiresAt != nil && value.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) { value = value.Add(time.Second) } expiresAt = &value } 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, "delivery_link_generated_at": now, }).Error; err != nil { return err } metadata := map[string]interface{}{ "generated_at": timeutil.FormatAPITime(now), } if expiresAt != nil { metadata["expires_at"] = timeutil.FormatAPITime(*expiresAt) } else { metadata["permanent"] = true } return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, metadata) }); 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 } } if order.OrderStatus == model.OrderStatusDelivering { if synced, _ := s.SyncDeliveringOrder(context.Background(), order); synced { openOrder, _ = s.fulfillment.QueryOpenOrder(orderNo) order, _ = s.fulfillment.GetByOrderNo(orderNo) } } return s.buildDeliveryState(openOrder, order, "", requireCanShip) } 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] }