Files
affiliate_dash/backend/internal/service/provider_dlc.go
T
yml2213andCursor f7f48d0feb 功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:48:03 +08:00

399 lines
11 KiB
Go

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
}