功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性

在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-11 15:48:03 +08:00
co-authored by Cursor
parent a314f3f51b
commit f7f48d0feb
22 changed files with 6489 additions and 189 deletions
+244 -164
View File
@@ -1,9 +1,10 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
@@ -57,6 +58,7 @@ type DeliveryService struct {
linkSecret string
linkTTL time.Duration
httpClient *http.Client
router *FulfillmentRouter
}
func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, linkBaseURL, linkSecret string, linkTTLMinutes int) *DeliveryService {
@@ -77,6 +79,9 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, li
if linkTTLMinutes < 0 {
linkTTLMinutes = 0
}
client := &http.Client{Timeout: 15 * time.Second}
dlc := NewDLCProvider(bffBaseURL, channel, client)
router := NewFulfillmentRouter(dlc)
return &DeliveryService{
fulfillment: fulfillment,
bffBaseURL: bffBaseURL,
@@ -84,16 +89,90 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, li
linkBaseURL: linkBaseURL,
linkSecret: linkSecret,
linkTTL: time.Duration(linkTTLMinutes) * time.Minute,
httpClient: &http.Client{Timeout: 15 * time.Second},
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
}
// 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"`
@@ -208,31 +287,27 @@ func (s *DeliveryService) GetBindResult(merchantID uint, orderNo, bindUUID strin
if _, _, _, err := s.buildDeliveryState(openOrder, order, goodID, false); err != nil {
return nil, err
}
account, err := s.accountBound(bindUUID, goodID)
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 {
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
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) {
@@ -258,29 +333,72 @@ func (s *DeliveryService) bindWithOrder(orderNo, gameAccount string, auth *Deliv
if err != nil {
return nil, err
}
return s.bindGood(gameAccount, goodID)
providers, _ := s.router.ResolveProviders(order)
if len(providers) == 0 {
return nil, errors.New("未配置发货渠道")
}
var lastErr error
var bindRes *ProviderBindResult
var usedProvider FulfillmentProvider
for _, p := range providers {
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) {
var out struct {
BindUUID string `json:"bind_uuid"`
URL string `json:"url"`
dlc, ok := s.router.GetProvider(ProviderDLC).(*DLCProvider)
if !ok || dlc == nil {
return nil, errors.New("DLC 渠道未就绪")
}
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 {
res, err := dlc.Bind(context.Background(), &model.FulfillmentOrder{
ProductSKU: "",
}, gameAccount)
if 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),
BindUUID: res.BindUUID,
BindURL: res.BindURL,
QRURL: res.QRURL,
Bound: res.Bound,
}, nil
}
@@ -321,7 +439,8 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
if orderStatus == model.OrderStatusDelivering ||
orderStatus == model.OrderStatusDelivered ||
strings.TrimSpace(order.ProviderOrderNo) != "" ||
strings.Contains(order.ResultData, "\"queue_order_id\"") {
strings.Contains(order.ResultData, "\"queue_order_id\"") ||
strings.Contains(order.ResultData, "\"lewan_order_id\"") {
if existing := buildExistingDeliverySubmitResult(order); existing != nil {
return existing, nil
}
@@ -347,74 +466,27 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
}
return nil, newDeliveryHTTPError(http.StatusConflict, "订单暂时正在发货中,请稍后查询")
}
boundAccount, err := s.accountBound(bindUUID, goodID)
if err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindVerify, err.Error())
return nil, err
providers, _ := s.router.ResolveProviders(claimed)
if len(providers) == 0 {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageCreateOrder, "未配置发货渠道")
return nil, errors.New("未配置发货渠道")
}
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindMismatch, err.Error())
return nil, err
}
goodsNo := ""
goodsName := ""
if info != nil && info.Product != nil {
goodsNo = info.Product.SKU
goodsName = info.Product.Name
} else if openOrder != nil && openOrder.Product != nil {
goodsNo = openOrder.Product.SKU
goodsName = openOrder.Product.Name
}
queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo, goodsNo)
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, goodsNo, goodsName); err != nil {
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStagePatchQueue, err.Error())
return nil, err
}
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
activeProvider := providers[0]
submitRes, err := activeProvider.Submit(context.Background(), claimed, gameAccount, bindUUID)
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 = "发货已完成"
}
resultData := mergeResultData(claimed.ResultData, submitRes.PatchResultData)
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID,
APIClientID: apiClientID,
OrderNo: order.OrderNo,
Status: nextStatus,
ProviderOrderNo: providerOrderNo,
Status: submitRes.NextStatus,
ProviderOrderNo: submitRes.ProviderOrderNo,
ResultData: resultData,
})
if err != nil {
@@ -423,10 +495,10 @@ func (s *DeliveryService) submit(orderNo, gameAccount, bindUUID string, apiClien
return &DeliverySubmitResult{
OrderNo: updated.OrderNo,
Status: normalizeOrderStatus(updated),
Message: message,
ProviderOrderNo: providerOrderNo,
GameAccount: boundAccount,
UpstreamOrder: upstreamOrder,
Message: submitRes.Message,
ProviderOrderNo: submitRes.ProviderOrderNo,
GameAccount: submitRes.GameAccount,
UpstreamOrder: submitRes.UpstreamOrder,
}, nil
}
@@ -436,6 +508,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
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) {
@@ -470,7 +543,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint,
return err
}
claimedNow = true
if err := tx.First(&updated, order.ID).Error; err != nil {
if err := tx.Preload("MerchantProduct").First(&updated, order.ID).Error; err != nil {
return err
}
apiClientIDPtr := optionalUint(apiClientID)
@@ -582,27 +655,58 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
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 = "未找到对应的商品发货配置"
}
}
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)
}
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,
Product: deliveryProd,
BuyerName: openOrder.BuyerName,
Amount: openOrder.Amount,
CreatedAt: openOrder.CreatedAt,
@@ -613,11 +717,32 @@ func (s *DeliveryService) buildDeliveryState(openOrder *OpenOrderQuery, order *m
RoleName: openOrder.RoleName,
PayScore: openOrder.PayScore,
Data: requestDataMap(order.RequestData),
Good: good,
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 == "" {
@@ -761,52 +886,7 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth
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
return s.buildDeliveryState(openOrder, order, "", requireCanShip)
}
func deliveryGoodID(channel, sku string) string {
+14 -2
View File
@@ -16,14 +16,20 @@ import (
)
type FulfillmentService struct {
db *gorm.DB
callbacks *CallbackService
db *gorm.DB
callbacks *CallbackService
upstreamCancel func(order *model.FulfillmentOrder) error
}
func NewFulfillmentService(db *gorm.DB, callbacks *CallbackService) *FulfillmentService {
return &FulfillmentService{db: db, callbacks: callbacks}
}
// SetUpstreamCancel 注册取消订单时同步取消上游履约单的钩子(如乐玩 CancelOrder)。
func (s *FulfillmentService) SetUpstreamCancel(fn func(order *model.FulfillmentOrder) error) {
s.upstreamCancel = fn
}
type CreateFulfillmentOrderInput struct {
MerchantID uint
APIClientID uint
@@ -511,6 +517,12 @@ func (s *FulfillmentService) cancelOrder(in CancelOrderInput) (*model.Fulfillmen
if normalizeOrderStatus(&order) == model.OrderStatusShipFailed && deliverySubmittedUpstream(&order) {
return errors.New("订单已提交上游发货,为避免重复发货无法取消,请先在上游确认订单状态")
}
// 先取消上游履约单,避免本地退款后上游仍占库存/资金
if s.upstreamCancel != nil {
if err := s.upstreamCancel(&order); err != nil {
return errors.New("取消上游履约订单失败: " + err.Error())
}
}
now := time.Now()
updates := map[string]interface{}{"order_status": model.OrderStatusCancelled}
updates["failure_reason"] = in.Reason
+216 -20
View File
@@ -1,8 +1,10 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strings"
"affiliate_dash/internal/model"
@@ -11,16 +13,18 @@ import (
)
type ProductCatalogItem struct {
ID uint `json:"id"`
ProductID uint `json:"product_id"`
SKU string `json:"sku"`
DisplayName string `json:"display_name"`
Category string `json:"category"`
PriceAmount int64 `json:"price_amount"`
CostAmount int64 `json:"cost_amount"`
Currency string `json:"currency"`
Stock int64 `json:"stock"`
Status string `json:"status"`
ID uint `json:"id"`
ProductID uint `json:"product_id"`
SKU string `json:"sku"`
DisplayName string `json:"display_name"`
Category string `json:"category"`
PriceAmount int64 `json:"price_amount"`
CostAmount int64 `json:"cost_amount"`
Currency string `json:"currency"`
Stock int64 `json:"stock"`
Status string `json:"status"`
FulfillmentConfig string `json:"fulfillment_config,omitempty"`
Routing ProductRoutingConfig `json:"routing"`
}
// ListProductCatalog 返回自营商户的全部可售商品,作为平台默认商品目录供分配。
@@ -42,17 +46,20 @@ func (s *MerchantService) ListProductCatalog() ([]ProductCatalogItem, error) {
if p.Product != nil {
category = p.Product.Category
}
routing := ParseProductRoutingConfig(p.FulfillmentConfig, p.SKU)
items = append(items, ProductCatalogItem{
ID: p.ID,
ProductID: p.ProductID,
SKU: p.SKU,
DisplayName: p.DisplayName,
Category: category,
PriceAmount: p.PriceAmount,
CostAmount: p.CostAmount,
Currency: p.Currency,
Stock: p.Stock,
Status: p.Status,
ID: p.ID,
ProductID: p.ProductID,
SKU: p.SKU,
DisplayName: p.DisplayName,
Category: category,
PriceAmount: p.PriceAmount,
CostAmount: p.CostAmount,
Currency: p.Currency,
Stock: p.Stock,
Status: p.Status,
FulfillmentConfig: p.FulfillmentConfig,
Routing: routing,
})
}
return items, nil
@@ -260,3 +267,192 @@ func ensureProduct(tx *gorm.DB, in CreateMerchantProductInput) (*model.Product,
}
return product, nil
}
// UpdateProductRouting 更新平台目录商品的履约渠道与优先级配置
func (s *MerchantService) UpdateProductRouting(catalogID uint, routing ProductRoutingConfig, actorUserID uint) error {
switch routing.RoutingMode {
case RoutingModePriority, RoutingModeLewan, RoutingModeDLC:
default:
return errors.New("routing_mode 仅支持 priority / lewan / dlc")
}
if routing.RoutingMode == RoutingModePriority {
if len(routing.Priority) == 0 {
routing.Priority = []string{ProviderLewan, ProviderDLC}
}
for _, name := range routing.Priority {
if name != ProviderLewan && name != ProviderDLC {
return errors.New("priority 仅支持 lewan / dlc")
}
}
}
if routing.RoutingMode == RoutingModeLewan || routing.RoutingMode == RoutingModePriority {
if strings.TrimSpace(routing.LewanProductID) == "" {
return errors.New("乐玩渠道需配置 lewan_product_id")
}
}
if routing.DeliveryType != "" && routing.DeliveryType != "LINK_BINDING" && routing.DeliveryType != "EMAIL_BINDING" {
return errors.New("delivery_type 仅支持 LINK_BINDING / EMAIL_BINDING")
}
configBytes, err := json.Marshal(routing)
if err != nil {
return err
}
configStr := string(configBytes)
return s.db.Transaction(func(tx *gorm.DB) error {
var item model.MerchantProduct
if err := tx.Preload("Product").Where("id = ?", catalogID).First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("目录商品不存在")
}
return err
}
// 更新自营模板
if err := tx.Model(&model.MerchantProduct{}).Where("id = ?", item.ID).Update("fulfillment_config", configStr).Error; err != nil {
return err
}
// 同步更新所有商户同 SKU 商品的履约配置,保证全平台生效
if item.SKU != "" {
_ = tx.Model(&model.MerchantProduct{}).Where("sku = ?", item.SKU).Update("fulfillment_config", configStr).Error
}
return writeAudit(tx, &item.MerchantID, &actorUserID, nil, "catalog.routing.update", "merchant_product", fmt.Sprint(catalogID), map[string]string{
"sku": item.SKU,
"routing_mode": routing.RoutingMode,
"priority": strings.Join(routing.Priority, ","),
})
})
}
type SyncLewanProductInput struct {
LewanProductID string `json:"lewan_product_id"`
Name string `json:"name"`
Category string `json:"category"`
SKU string `json:"sku"`
Price int64 `json:"price"`
DeliveryType string `json:"delivery_type"`
}
// SyncLewanProductToCatalog 导入或关联乐玩商品至平台商品目录
func (s *MerchantService) SyncLewanProductToCatalog(in SyncLewanProductInput, actorUserID uint) (*ProductCatalogItem, error) {
if in.LewanProductID == "" {
return nil, errors.New("乐玩商品ID不能为空")
}
if in.Name == "" {
return nil, errors.New("商品名称不能为空")
}
var res *ProductCatalogItem
err := s.db.Transaction(func(tx *gorm.DB) error {
var selfMerchant model.Merchant
if err := tx.Where("code = ?", model.MerchantCodeSelfOperated).First(&selfMerchant).Error; err != nil {
return errors.New("自营商户未初始化")
}
sku := strings.TrimSpace(in.SKU)
if sku == "" {
// 自动根据乐玩 UUID 派生 SKU
cleanID := strings.ReplaceAll(in.LewanProductID, "-", "")
if len(cleanID) > 12 {
cleanID = cleanID[:12]
}
sku = "lewan_" + cleanID
}
// 检查 Product
var product model.Product
if err := tx.Where("code = ?", sku).First(&product).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
product = model.Product{
Code: sku,
Name: in.Name,
Category: in.Category,
Status: model.ProductStatusActive,
}
if err := tx.Create(&product).Error; err != nil {
return err
}
} else {
return err
}
}
routing := ProductRoutingConfig{
RoutingMode: RoutingModeLewan,
Priority: []string{ProviderLewan, ProviderDLC},
LewanProductID: in.LewanProductID,
LewanName: in.Name,
DeliveryType: in.DeliveryType,
}
if routing.DeliveryType == "" {
routing.DeliveryType = "LINK_BINDING"
}
if routing.DeliveryType != "LINK_BINDING" && routing.DeliveryType != "EMAIL_BINDING" {
return errors.New("delivery_type 仅支持 LINK_BINDING / EMAIL_BINDING")
}
if builtinDLC := deliveryGoodID("dlc", sku); builtinDLC != "" {
routing.DLCGoodID = builtinDLC
routing.RoutingMode = RoutingModePriority
}
configBytes, _ := json.Marshal(routing)
configStr := string(configBytes)
// 检查自营商户商品
var mp model.MerchantProduct
if err := tx.Where("merchant_id = ? AND sku = ?", selfMerchant.ID, sku).First(&mp).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
price := in.Price
if price <= 0 {
price = 1000
}
mp = model.MerchantProduct{
MerchantID: selfMerchant.ID,
ProductID: product.ID,
SKU: sku,
DisplayName: in.Name,
PriceAmount: price,
CostAmount: price,
Currency: "POINT",
Stock: -1,
Status: model.ProductStatusActive,
FulfillmentConfig: configStr,
}
if err := tx.Create(&mp).Error; err != nil {
return err
}
} else {
return err
}
} else {
// 更新现有配置
_ = tx.Model(&mp).Update("fulfillment_config", configStr).Error
}
res = &ProductCatalogItem{
ID: mp.ID,
ProductID: product.ID,
SKU: sku,
DisplayName: mp.DisplayName,
Category: product.Category,
PriceAmount: mp.PriceAmount,
CostAmount: mp.CostAmount,
Currency: mp.Currency,
Stock: mp.Stock,
Status: mp.Status,
FulfillmentConfig: configStr,
Routing: routing,
}
return writeAudit(tx, &selfMerchant.ID, &actorUserID, nil, "catalog.lewan.sync", "merchant_product", fmt.Sprint(mp.ID), map[string]string{
"lewan_product_id": in.LewanProductID,
"sku": sku,
})
})
if err != nil {
return nil, err
}
return res, nil
}
+211
View File
@@ -0,0 +1,211 @@
package service
import (
"context"
"encoding/json"
"net/url"
"strings"
"affiliate_dash/internal/model"
)
const (
ProviderDLC = "dlc"
ProviderLewan = "lewan"
RoutingModePriority = "priority"
RoutingModeLewan = "lewan"
RoutingModeDLC = "dlc"
)
// ProductRoutingConfig 商品履约配置
type ProductRoutingConfig struct {
RoutingMode string `json:"routing_mode"` // priority | lewan | dlc
Priority []string `json:"priority"` // ["lewan", "dlc"] 或 ["dlc", "lewan"]
DLCGoodID string `json:"dlc_good_id"` // 第一平台 GoodID
LewanProductID string `json:"lewan_product_id"` // 乐玩商品 UUID
LewanName string `json:"lewan_name,omitempty"`
DeliveryType string `json:"delivery_type,omitempty"` // LINK_BINDING / EMAIL_BINDING
}
// ParseProductRoutingConfig 解析商品履约配置,若未配置则根据 SKU 默认走 DLC 兜底。
func ParseProductRoutingConfig(fulfillmentConfig string, sku string) ProductRoutingConfig {
var cfg ProductRoutingConfig
if strings.TrimSpace(fulfillmentConfig) != "" {
_ = json.Unmarshal([]byte(fulfillmentConfig), &cfg)
}
// 兼容老配置或单 provider 字段
if cfg.RoutingMode == "" {
var legacy struct {
Provider string `json:"provider"`
LewanProductID string `json:"lewan_product_id"`
DLCGoodID string `json:"dlc_good_id"`
}
if strings.TrimSpace(fulfillmentConfig) != "" {
_ = json.Unmarshal([]byte(fulfillmentConfig), &legacy)
}
if legacy.Provider == ProviderLewan || legacy.LewanProductID != "" {
cfg.RoutingMode = RoutingModeLewan
cfg.LewanProductID = legacy.LewanProductID
} else if legacy.Provider == ProviderDLC {
cfg.RoutingMode = RoutingModeDLC
cfg.DLCGoodID = legacy.DLCGoodID
}
}
// 默认兜底:若第一平台有内置 GoodID,补齐 DLCGoodID
builtinDLC := deliveryGoodID("dlc", sku)
if cfg.DLCGoodID == "" && builtinDLC != "" {
cfg.DLCGoodID = builtinDLC
}
// 若未配置 RoutingMode
if cfg.RoutingMode == "" {
if cfg.LewanProductID != "" && cfg.DLCGoodID != "" {
cfg.RoutingMode = RoutingModePriority
cfg.Priority = []string{ProviderLewan, ProviderDLC}
} else if cfg.LewanProductID != "" {
cfg.RoutingMode = RoutingModeLewan
} else {
cfg.RoutingMode = RoutingModeDLC
}
}
if len(cfg.Priority) == 0 && cfg.RoutingMode == RoutingModePriority {
cfg.Priority = []string{ProviderLewan, ProviderDLC}
}
return cfg
}
// DeliveryOrderState 抽象商品履约状态
type DeliveryOrderState struct {
CanShip bool
CannotShipReason string
Product *DeliveryProduct
Good map[string]interface{}
Provider string
}
// ProviderBindResult 抽象绑定返回
type ProviderBindResult 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"`
Provider string `json:"provider,omitempty"`
// PatchResultData 绑定成功后写入订单 result_data(如 lewan_order_id)
PatchResultData map[string]interface{} `json:"-"`
}
// ProviderSubmitResult 抽象提交返回
type ProviderSubmitResult struct {
ProviderOrderNo string `json:"provider_order_no"`
NextStatus string `json:"next_status"` // delivering / delivered
Message string `json:"message"`
GameAccount map[string]interface{} `json:"game_account,omitempty"`
UpstreamOrder interface{} `json:"delivery,omitempty"`
PatchResultData map[string]interface{} `json:"patch_result_data,omitempty"`
}
// FulfillmentProvider 统一履约提供商接口
type FulfillmentProvider interface {
Name() string
PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error)
Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error)
CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error)
Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error)
Cancel(ctx context.Context, order *model.FulfillmentOrder) error
}
// FulfillmentRouter 履约路由器
type FulfillmentRouter struct {
providers map[string]FulfillmentProvider
}
func NewFulfillmentRouter(providers ...FulfillmentProvider) *FulfillmentRouter {
m := make(map[string]FulfillmentProvider)
for _, p := range providers {
if p != nil {
m[p.Name()] = p
}
}
return &FulfillmentRouter{providers: m}
}
func (r *FulfillmentRouter) GetProvider(name string) FulfillmentProvider {
return r.providers[name]
}
// ResolveProviders 返回订单可选的履约渠道列表(按优先级排序)
func (r *FulfillmentRouter) ResolveProviders(order *model.FulfillmentOrder) ([]FulfillmentProvider, ProductRoutingConfig) {
sku := ""
configStr := ""
if order != nil {
sku = order.ProductSKU
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
}
cfg := ParseProductRoutingConfig(configStr, sku)
// 如果订单已有选定渠道记录(例如已经对乐玩或 DLC 发起过 bind/submit),则锁定该渠道
if order != nil && strings.TrimSpace(order.ResultData) != "" {
if active := resultDataString(order.ResultData, "provider"); active != "" {
if p, ok := r.providers[active]; ok {
return []FulfillmentProvider{p}, cfg
}
}
}
var list []FulfillmentProvider
switch cfg.RoutingMode {
case RoutingModeLewan:
if p, ok := r.providers[ProviderLewan]; ok {
list = append(list, p)
}
case RoutingModeDLC:
if p, ok := r.providers[ProviderDLC]; ok {
list = append(list, p)
}
case RoutingModePriority:
for _, name := range cfg.Priority {
if p, ok := r.providers[name]; ok {
list = append(list, p)
}
}
}
// 最终安全兜底:若解析为空,优先尝试 DLC,若无则尝试任意可用 provider
if len(list) == 0 {
if dlc, ok := r.providers[ProviderDLC]; ok {
list = append(list, dlc)
} else {
for _, p := range r.providers {
list = append(list, p)
break
}
}
}
return list, cfg
}
func qrCodeURL(targetURL string) string {
return "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(targetURL)
}
func truncateText(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
+398
View File
@@ -0,0 +1,398 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"affiliate_dash/internal/model"
)
// DLCProvider 实现第一平台(聚鑫源 / dlc)履约逻辑
type DLCProvider struct {
bffBaseURL string
channel string
httpClient *http.Client
}
func NewDLCProvider(bffBaseURL, channel string, httpClient *http.Client) *DLCProvider {
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
if bffBaseURL == "" {
bffBaseURL = defaultDeliveryBFFBaseURL
}
channel = strings.TrimSpace(channel)
if channel == "" {
channel = "dlc"
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * timeSecond(15)}
}
return &DLCProvider{
bffBaseURL: bffBaseURL,
channel: channel,
httpClient: httpClient,
}
}
func (p *DLCProvider) Name() string {
return ProviderDLC
}
func (p *DLCProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) {
sku := ""
configStr := ""
if order != nil {
sku = order.ProductSKU
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "未找到对应的第一平台商品发货配置",
Provider: ProviderDLC,
}, nil
}
var good map[string]interface{}
good, _ = p.goodsDetail(goodID)
var deliveryProd *DeliveryProduct
if openOrder != nil && openOrder.Product != nil {
deliveryProd = buildDeliveryProduct(openOrder.Product, good)
}
return &DeliveryOrderState{
CanShip: true,
Product: deliveryProd,
Good: good,
Provider: ProviderDLC,
}, nil
}
func (p *DLCProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) {
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return nil, errors.New("未找到对应的商品发货配置")
}
var out struct {
BindUUID string `json:"bind_uuid"`
URL string `json:"url"`
}
if err := p.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 &ProviderBindResult{
BindUUID: out.BindUUID,
BindURL: out.URL,
QRURL: qrCodeURL(out.URL),
Bound: false,
Provider: ProviderDLC,
}, nil
}
func (p *DLCProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) {
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return nil, errors.New("未找到对应的商品发货配置")
}
account, err := p.accountBound(bindUUID, goodID)
if err != nil {
if strings.Contains(err.Error(), "尚未绑定") || strings.Contains(err.Error(), "未绑定") {
return &ProviderBindResult{
BindUUID: bindUUID,
Bound: false,
Message: "尚未绑定,请先完成扫码绑定",
Provider: ProviderDLC,
}, nil
}
return nil, err
}
boundAccount := stringFromMap(account, "game_account")
result := &ProviderBindResult{
BindUUID: bindUUID,
Bound: true,
GameAccount: boundAccount,
RoleName: stringFromMap(account, "game_account_role_name"),
GameChannel: gameChannelText(account),
Message: "绑定成功",
Provider: ProviderDLC,
}
if expected := requestDataString(order.RequestData, "game_account"); expected != "" {
result.ExpectedGameAccount = expected
if boundAccount != expected {
result.Mismatch = true
result.Message = "绑定成功,但绑定账号与下单预期账号不一致,请确认"
}
}
return result, nil
}
func (p *DLCProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
goodID := cfg.DLCGoodID
if goodID == "" {
goodID = deliveryGoodID(p.channel, sku)
}
if goodID == "" {
return nil, errors.New("未找到对应的商品发货配置")
}
boundAccount, err := p.accountBound(bindUUID, goodID)
if err != nil {
return nil, fmt.Errorf("绑定校验失败: %w", err)
}
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
return nil, errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
}
goodsNo := sku
goodsName := order.ProductName
queueOrderID, err := p.createOrderQueue(goodID, order.OrderNo, goodsNo)
if err != nil {
return nil, fmt.Errorf("创建发货队列失败: %w", err)
}
if err := p.patchOrderQueue(queueOrderID, gameAccount, bindUUID, goodsNo, goodsName); err != nil {
return nil, fmt.Errorf("补充发货账号失败: %w", err)
}
upstreamOrder, err := p.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
if err != nil {
return nil, fmt.Errorf("创建上游正式订单失败: %w", err)
}
providerOrderNo := firstNonEmpty(
stringFromMap(upstreamOrder, "order_no"),
stringFromMap(upstreamOrder, "_id"),
stringFromMap(upstreamOrder, "id"),
stringFromMap(upstreamOrder, "order_sn"),
queueOrderID,
)
nextStatus := model.OrderStatusDelivering
message := "已提交发货,等待发货结果回传"
if upstreamDeliverySucceeded(upstreamOrder) {
nextStatus = model.OrderStatusDelivered
message = "发货已完成"
}
return &ProviderSubmitResult{
ProviderOrderNo: providerOrderNo,
NextStatus: nextStatus,
Message: message,
GameAccount: boundAccount,
UpstreamOrder: upstreamOrder,
PatchResultData: map[string]interface{}{
"provider": ProviderDLC,
"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,
},
}, nil
}
func (p *DLCProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error {
// DLC 不支持在已提交后自动取消,直接返回 nil
return nil
}
// -----------------------------------------------------------------------------
// DLC 内部 HTTP 交互
// -----------------------------------------------------------------------------
func (p *DLCProvider) goodsDetail(goodID string) (map[string]interface{}, error) {
var out struct {
Good map[string]interface{} `json:"good"`
}
if err := p.signProxy("/public/goods/detail", "POST", map[string]interface{}{
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
return out.Good, nil
}
func (p *DLCProvider) accountBound(bindUUID, goodID string) (map[string]interface{}, error) {
var out struct {
GameAccount map[string]interface{} `json:"gameAccount"`
Snake map[string]interface{} `json:"game_account"`
}
if err := p.signProxy("/public/games/account-bound", "POST", map[string]interface{}{
"bind_uuid": bindUUID,
"bindUuid": bindUUID,
"goodId": goodID,
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
if out.GameAccount == nil {
out.GameAccount = out.Snake
}
if out.GameAccount == nil {
return nil, errors.New("账号尚未绑定,请扫码完成绑定后再提交")
}
return out.GameAccount, nil
}
func (p *DLCProvider) createOrderQueue(goodID, orderNo, goodsNo string) (string, error) {
var out struct {
Orders []map[string]interface{} `json:"orders"`
}
payload := map[string]interface{}{
"good_id": goodID,
"quantity": 1,
"order_sn": orderNo,
"h5_prefix": p.channel,
}
if goodsNo != "" {
payload["goods_no"] = goodsNo
}
if err := p.signProxy("/public/users/orders-queue", "POST", payload, &out); err != nil {
return "", err
}
if len(out.Orders) == 0 {
return "", errors.New("发货服务未返回队列订单")
}
orderID := firstNonEmpty(stringFromMap(out.Orders[0], "_id"), stringFromMap(out.Orders[0], "id"))
if orderID == "" {
return "", errors.New("发货服务未返回队列订单 ID")
}
return orderID, nil
}
func (p *DLCProvider) patchOrderQueue(orderID, gameAccount, bindUUID, goodsNo, goodsName string) error {
var out map[string]interface{}
payload := map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"bind_uuid": bindUUID,
"h5_prefix": p.channel,
}
if goodsNo != "" {
payload["goods_no"] = goodsNo
}
if goodsName != "" {
payload["goods_name"] = goodsName
}
return p.signProxy("/public/users/orders-queue", "PATCH", payload, &out)
}
func (p *DLCProvider) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) {
var out struct {
Order map[string]interface{} `json:"order"`
}
if err := p.signProxy("/public/users/orders", "POST", map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"good_id": goodID,
"h5_prefix": p.channel,
"order_sn": orderNo,
}, &out); err != nil {
return nil, err
}
if out.Order == nil {
out.Order = map[string]interface{}{"order_id": orderID, "order_sn": orderNo}
}
return out.Order, nil
}
func (p *DLCProvider) signProxy(path, method string, data interface{}, out interface{}) error {
payload, err := json.Marshal(map[string]interface{}{
"path": path,
"method": strings.ToUpper(method),
"data": data,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, p.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("发货服务请求失败:%w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var envelope struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("发货服务响应无法解析:%s", truncateDeliveryText(string(body)))
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || !isZeroCode(envelope.Code) {
if envelope.Message != "" {
return errors.New(envelope.Message)
}
return fmt.Errorf("发货服务请求失败:HTTP %d", resp.StatusCode)
}
if out == nil || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return nil
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("发货服务数据无法解析:%w", err)
}
return nil
}
func timeSecond(s int) time.Duration {
return time.Duration(s) * time.Second
}
+358
View File
@@ -0,0 +1,358 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/lewan"
)
// LewanProvider 实现乐玩开放平台履约逻辑
type LewanProvider struct {
client *lewan.Client
}
func NewLewanProvider(client *lewan.Client) *LewanProvider {
return &LewanProvider{client: client}
}
func (p *LewanProvider) Name() string {
return ProviderLewan
}
func (p *LewanProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) {
if p.client == nil {
return nil, errors.New("乐玩平台客户端未初始化")
}
sku := ""
configStr := ""
if order != nil {
sku = order.ProductSKU
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
}
cfg := ParseProductRoutingConfig(configStr, sku)
if cfg.LewanProductID == "" {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "未配置乐玩商品ID",
Provider: ProviderLewan,
}, nil
}
lewanProd, err := p.client.GetProduct(ctx, cfg.LewanProductID)
if err != nil {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: fmt.Sprintf("获取乐玩商品失败: %v", err),
Provider: ProviderLewan,
}, nil
}
if lewanProd.AccessMode != "PURCHASE" {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "乐玩商品当前不可购买 (accessMode != PURCHASE)",
Provider: ProviderLewan,
}, nil
}
if lewanProd.Stock == 0 {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "乐玩商品当前无库存",
Provider: ProviderLewan,
}, nil
}
deliveryProd := &DeliveryProduct{
Name: lewanProd.Name,
SKU: sku,
Game: lewanProd.CategoryName,
Image: lewanProd.ThumbnailUrl,
}
if openOrder != nil && openOrder.Product != nil && openOrder.Product.Name != "" {
deliveryProd.Name = openOrder.Product.Name
}
goodMap := map[string]interface{}{
"id": lewanProd.ID,
"name": lewanProd.Name,
"category_name": lewanProd.CategoryName,
"stock": lewanProd.Stock,
"delivery_type": lewanProd.DeliveryType,
"price": lewanProd.EffectiveUnitPrice,
}
return &DeliveryOrderState{
CanShip: true,
Product: deliveryProd,
Good: goodMap,
Provider: ProviderLewan,
}, nil
}
func (p *LewanProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) {
if p.client == nil {
return nil, errors.New("乐玩平台客户端未初始化")
}
sku := order.ProductSKU
configStr := ""
if order.MerchantProduct != nil {
configStr = order.MerchantProduct.FulfillmentConfig
}
cfg := ParseProductRoutingConfig(configStr, sku)
if cfg.LewanProductID == "" {
return nil, errors.New("该商品未配置乐玩商品ID")
}
// 1. 检查或获取现有乐玩单号
lewanOrderID := resultDataString(order.ResultData, "lewan_order_id")
var (
lewanOrder *lewan.LewanOrder
bindingAction *lewan.BindingAction
err error
)
if lewanOrderID != "" {
// 已创建过乐玩订单,查单或恢复动作
lewanOrder, err = p.client.GetOrder(ctx, lewanOrderID)
if err == nil && lewanOrder != nil {
bindingAction = lewanOrder.BindingAction
if bindingAction != nil && bindingAction.BindingUrlExpired {
restored, restoreErr := p.client.RestoreBindingAction(ctx, lewanOrderID)
if restoreErr == nil && restored != nil {
bindingAction = restored.BindingAction
}
}
}
}
// 2. 若尚未建单,调乐玩下单
if lewanOrder == nil {
quantity := order.Quantity
if quantity <= 0 {
quantity = 1
}
createRes, createErr := p.client.CreateOrder(ctx, lewan.CreateOrderReq{
CallerOrderID: order.OrderNo,
RoleID: gameAccount,
Items: []lewan.OrderItem{
{
ProductID: cfg.LewanProductID,
Quantity: quantity,
},
},
})
if createErr != nil {
// 如果提示 callerOrderId 已存在,尝试按 callerOrderId 查单
if strings.Contains(createErr.Error(), "ALREADY_EXISTS") || strings.Contains(createErr.Error(), "DUPLICATE") {
byCaller, getErr := p.client.GetOrderByCallerOrder(ctx, order.OrderNo)
if getErr == nil && byCaller != nil {
lewanOrder = byCaller
bindingAction = byCaller.BindingAction
} else {
return nil, createErr
}
} else {
return nil, createErr
}
} else if createRes != nil {
lewanOrder = createRes.Order
bindingAction = createRes.BindingAction
}
}
if lewanOrder == nil {
return nil, errors.New("乐玩创建订单未返回有效订单数据")
}
lewanOrderID = lewanOrder.ID
patch := map[string]interface{}{
"provider": ProviderLewan,
"lewan_order_id": lewanOrderID,
"lewan_order_no": lewanOrder.OrderNo,
"lewan_status": lewanOrder.Status,
}
// 3. 判断是否需要绑定
if bindingAction != nil && !bindingAction.BindingRequired {
// 角色已在乐玩绑定过,免扫码直接已绑定
return &ProviderBindResult{
BindUUID: lewanOrderID,
Bound: true,
GameAccount: gameAccount,
Message: "账号已在平台授权,免扫码直接进入发货",
Provider: ProviderLewan,
PatchResultData: patch,
}, nil
}
bindURL := ""
if bindingAction != nil {
bindURL = bindingAction.BindingUrl
}
qrURL := ""
if bindURL != "" {
qrURL = qrCodeURL(bindURL)
}
return &ProviderBindResult{
BindUUID: lewanOrderID,
BindURL: bindURL,
QRURL: qrURL,
Bound: false,
GameAccount: gameAccount,
Message: "请扫码或打开链接完成授权绑定",
Provider: ProviderLewan,
PatchResultData: patch,
}, nil
}
func (p *LewanProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) {
if p.client == nil {
return nil, errors.New("乐玩平台客户端未初始化")
}
lewanOrderID := bindUUID
if lewanOrderID == "" {
lewanOrderID = resultDataString(order.ResultData, "lewan_order_id")
}
if lewanOrderID == "" {
return nil, errors.New("缺少乐玩订单编号")
}
refreshRes, err := p.client.RefreshBinding(ctx, lewanOrderID)
if err != nil {
// 查询订单兜底判断
orderDetail, getErr := p.client.GetOrder(ctx, lewanOrderID)
if getErr == nil && orderDetail != nil {
if orderDetail.Status != "WAITING_BINDING" {
return &ProviderBindResult{
BindUUID: lewanOrderID,
Bound: true,
GameAccount: orderDetail.RoleID,
Message: "绑定已完成",
Provider: ProviderLewan,
}, nil
}
}
return &ProviderBindResult{
BindUUID: bindUUID,
Bound: false,
Message: "尚未完成绑定,请继续扫码操作",
Provider: ProviderLewan,
}, nil
}
bound := false
gameAccount := ""
roleName := ""
if refreshRes != nil && refreshRes.Binding != nil {
bound = refreshRes.Binding.Matched
gameAccount = refreshRes.Binding.RoleID
roleName = refreshRes.Binding.RoleName
}
if refreshRes != nil && refreshRes.Order != nil && refreshRes.Order.Status != "WAITING_BINDING" {
bound = true
}
msg := "绑定成功"
if !bound {
msg = "尚未完成扫码绑定或角色未匹配"
}
return &ProviderBindResult{
BindUUID: lewanOrderID,
Bound: bound,
GameAccount: gameAccount,
RoleName: roleName,
Message: msg,
Provider: ProviderLewan,
}, nil
}
func (p *LewanProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
if p.client == nil {
return nil, errors.New("乐玩平台客户端未初始化")
}
lewanOrderID := bindUUID
if lewanOrderID == "" {
lewanOrderID = resultDataString(order.ResultData, "lewan_order_id")
}
if lewanOrderID == "" {
return nil, errors.New("缺少乐玩订单编号")
}
// 刷新绑定状态并推动队列
refreshRes, err := p.client.RefreshBinding(ctx, lewanOrderID)
var lewanOrder *lewan.LewanOrder
if refreshRes != nil && refreshRes.Order != nil {
lewanOrder = refreshRes.Order
} else {
lewanOrder, _ = p.client.GetOrder(ctx, lewanOrderID)
}
if lewanOrder == nil {
if err != nil {
return nil, fmt.Errorf("提交乐玩发货失败: %w", err)
}
return nil, errors.New("未获取到乐玩订单")
}
providerOrderNo := lewanOrder.OrderNo
if providerOrderNo == "" {
providerOrderNo = lewanOrder.ID
}
nextStatus := model.OrderStatusDelivering
message := "已提交乐玩平台发货,等待发货结果"
if lewanOrder.Status == "SUCCEEDED" {
nextStatus = model.OrderStatusDelivered
message = "乐玩平台已发货完成"
} else if lewanOrder.Status == "FAILED" {
nextStatus = model.OrderStatusShipFailed
message = "乐玩平台发货失败"
}
roleName := ""
if refreshRes != nil && refreshRes.Binding != nil {
roleName = refreshRes.Binding.RoleName
}
return &ProviderSubmitResult{
ProviderOrderNo: providerOrderNo,
NextStatus: nextStatus,
Message: message,
GameAccount: map[string]interface{}{
"game_account": gameAccount,
"role_name": roleName,
},
UpstreamOrder: lewanOrder,
PatchResultData: map[string]interface{}{
"provider": ProviderLewan,
"provider_order_stage": deliveryStageSubmitted,
"provider_order_no": providerOrderNo,
"lewan_order_id": lewanOrder.ID,
"lewan_order_no": lewanOrder.OrderNo,
"lewan_status": lewanOrder.Status,
"game_uid": gameAccount,
"role_name": roleName,
"delivery": lewanOrder,
},
}, nil
}
func (p *LewanProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error {
if p.client == nil {
return nil
}
lewanOrderID := resultDataString(order.ResultData, "lewan_order_id")
if lewanOrderID == "" {
return nil
}
_, err := p.client.CancelOrder(ctx, lewanOrderID)
return err
}
+120
View File
@@ -0,0 +1,120 @@
package service
import (
"testing"
"affiliate_dash/internal/model"
)
func TestParseProductRoutingConfig(t *testing.T) {
// Case 1: 存量老商品未配置,根据 SKU 默认走 DLC
cfg1 := ParseProductRoutingConfig("", "suit_pink_sheep")
if cfg1.RoutingMode != RoutingModeDLC {
t.Fatalf("expected RoutingModeDLC, got %s", cfg1.RoutingMode)
}
if cfg1.DLCGoodID != "682ef3c5a8f40c4234c59f4d" {
t.Fatalf("expected builtin goodID 682ef3c5a8f40c4234c59f4d, got %s", cfg1.DLCGoodID)
}
// Case 2: 仅配置乐玩
cfg2JSON := `{"routing_mode":"lewan","lewan_product_id":"uuid-001","delivery_type":"LINK_BINDING"}`
cfg2 := ParseProductRoutingConfig(cfg2JSON, "any_sku")
if cfg2.RoutingMode != RoutingModeLewan {
t.Fatalf("expected RoutingModeLewan, got %s", cfg2.RoutingMode)
}
if cfg2.LewanProductID != "uuid-001" {
t.Fatalf("expected lewan_product_id uuid-001, got %s", cfg2.LewanProductID)
}
// Case 3: 双平台优先级 (乐玩优先)
cfg3JSON := `{"routing_mode":"priority","priority":["lewan","dlc"],"dlc_good_id":"dlc-123","lewan_product_id":"uuid-123"}`
cfg3 := ParseProductRoutingConfig(cfg3JSON, "any_sku")
if cfg3.RoutingMode != RoutingModePriority {
t.Fatalf("expected RoutingModePriority, got %s", cfg3.RoutingMode)
}
if len(cfg3.Priority) != 2 || cfg3.Priority[0] != ProviderLewan || cfg3.Priority[1] != ProviderDLC {
t.Fatalf("expected priority [lewan, dlc], got %+v", cfg3.Priority)
}
if cfg3.DLCGoodID != "dlc-123" || cfg3.LewanProductID != "uuid-123" {
t.Fatalf("mismatched IDs: dlc=%s lewan=%s", cfg3.DLCGoodID, cfg3.LewanProductID)
}
// Case 4: 兼容历史 legacy 单 provider 字段
cfg4JSON := `{"provider":"lewan","lewan_product_id":"uuid-999"}`
cfg4 := ParseProductRoutingConfig(cfg4JSON, "any_sku")
if cfg4.RoutingMode != RoutingModeLewan || cfg4.LewanProductID != "uuid-999" {
t.Fatalf("legacy lewan format failed: %+v", cfg4)
}
}
func TestFulfillmentRouterResolution(t *testing.T) {
dlc := NewDLCProvider("https://bff.example", "dlc", nil)
lewan := NewLewanProvider(nil)
router := NewFulfillmentRouter(dlc, lewan)
// Case 1: 存量 DLC 商品
orderDLC := &model.FulfillmentOrder{
ProductSKU: "suit_alan_walker",
}
providers, _ := router.ResolveProviders(orderDLC)
if len(providers) != 1 || providers[0].Name() != ProviderDLC {
t.Fatalf("expected DLC provider for suit_alan_walker, got %d providers", len(providers))
}
// Case 2: 仅乐玩商品
orderLewan := &model.FulfillmentOrder{
ProductSKU: "custom_lewan_item",
MerchantProduct: &model.MerchantProduct{
FulfillmentConfig: `{"routing_mode":"lewan","lewan_product_id":"uuid-001"}`,
},
}
providers, _ = router.ResolveProviders(orderLewan)
if len(providers) != 1 || providers[0].Name() != ProviderLewan {
t.Fatalf("expected Lewan provider, got %d providers", len(providers))
}
// Case 3: 优先级路由 [lewan, dlc]
orderPriority := &model.FulfillmentOrder{
ProductSKU: "shared_item",
MerchantProduct: &model.MerchantProduct{
FulfillmentConfig: `{"routing_mode":"priority","priority":["lewan","dlc"],"lewan_product_id":"uuid-001","dlc_good_id":"dlc-001"}`,
},
}
providers, _ = router.ResolveProviders(orderPriority)
if len(providers) != 2 || providers[0].Name() != ProviderLewan || providers[1].Name() != ProviderDLC {
t.Fatalf("expected [lewan, dlc], got %v", providers)
}
// Case 4: 订单已锁定渠道(已在 result_data 中写明 provider=dlc)
orderLocked := &model.FulfillmentOrder{
ProductSKU: "shared_item",
MerchantProduct: &model.MerchantProduct{
FulfillmentConfig: `{"routing_mode":"priority","priority":["lewan","dlc"],"lewan_product_id":"uuid-001","dlc_good_id":"dlc-001"}`,
},
ResultData: `{"provider":"dlc"}`,
}
providers, _ = router.ResolveProviders(orderLocked)
if len(providers) != 1 || providers[0].Name() != ProviderDLC {
t.Fatalf("expected locked DLC provider, got %+v", providers)
}
// Case 5: 优先级 [dlc, lewan]
orderDLCFirst := &model.FulfillmentOrder{
ProductSKU: "shared_item",
MerchantProduct: &model.MerchantProduct{
FulfillmentConfig: `{"routing_mode":"priority","priority":["dlc","lewan"],"lewan_product_id":"uuid-001","dlc_good_id":"dlc-001"}`,
},
}
providers, _ = router.ResolveProviders(orderDLCFirst)
if len(providers) != 2 || providers[0].Name() != ProviderDLC || providers[1].Name() != ProviderLewan {
t.Fatalf("expected [dlc, lewan], got %v", providerNames(providers))
}
}
func providerNames(providers []FulfillmentProvider) []string {
names := make([]string, 0, len(providers))
for _, p := range providers {
names = append(names, p.Name())
}
return names
}