在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。 Co-authored-by: Cursor <cursoragent@cursor.com>
399 lines
11 KiB
Go
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
|
|
}
|