Files
affiliate_dash/backend/internal/service/delivery.go
T
2026-07-31 10:45:13 +08:00

490 lines
15 KiB
Go

package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg"
type DeliveryService struct {
fulfillment *FulfillmentService
bffBaseURL string
channel string
httpClient *http.Client
}
func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel string) *DeliveryService {
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
if bffBaseURL == "" {
bffBaseURL = defaultDeliveryBFFBaseURL
}
channel = strings.TrimSpace(channel)
if channel == "" {
channel = "dlc"
}
return &DeliveryService{
fulfillment: fulfillment,
bffBaseURL: bffBaseURL,
channel: channel,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (s *DeliveryService) SetHTTPClient(client *http.Client) {
if client != nil {
s.httpClient = client
}
}
type DeliveryOrderInfo struct {
OrderNo string `json:"order_no"`
Status string `json:"status"`
CanShip bool `json:"can_ship"`
CannotShipReason string `json:"cannot_ship_reason,omitempty"`
Product *DeliveryProduct `json:"product,omitempty"`
BuyerName string `json:"buyer_name"`
Amount int64 `json:"amount"`
CreatedAt time.Time `json:"created_at"`
ShippedAt *time.Time `json:"shipped_at"`
ShipFailReason string `json:"ship_fail_reason,omitempty"`
GameChannel string `json:"game_channel,omitempty"`
GameUID string `json:"game_uid,omitempty"`
RoleName string `json:"role_name,omitempty"`
PayScore int `json:"pay_score,omitempty"`
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"`
}
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:"upstream_order,omitempty"`
}
func (s *DeliveryService) GetOrder(orderNo string) (*DeliveryOrderInfo, error) {
info, _, _, err := s.prepareOrder(orderNo, false)
return info, err
}
func (s *DeliveryService) Bind(orderNo, gameAccount string) (*DeliveryBindResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
if gameAccount == "" {
return nil, errors.New("请输入玩家编号")
}
_, _, goodID, err := s.prepareOrder(orderNo, true)
if err != nil {
return nil, err
}
var out struct {
BindUUID string `json:"bind_uuid"`
URL string `json:"url"`
}
if err := s.signProxy("/public/games/bind-account", "POST", map[string]interface{}{
"game_account": gameAccount,
"gameAccount": gameAccount,
"goodId": goodID,
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
if out.BindUUID == "" || out.URL == "" {
return nil, errors.New("上游未返回绑定二维码")
}
return &DeliveryBindResult{
BindUUID: out.BindUUID,
BindURL: out.URL,
QRURL: "https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=" + url.QueryEscape(out.URL),
}, nil
}
func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) {
gameAccount = strings.TrimSpace(gameAccount)
bindUUID = strings.TrimSpace(bindUUID)
orderNo = strings.TrimSpace(orderNo)
if gameAccount == "" || bindUUID == "" {
return nil, errors.New("玩家编号和绑定凭证不能为空")
}
_, order, goodID, err := s.prepareOrder(orderNo, true)
if err != nil {
return nil, err
}
boundAccount, err := s.accountBound(bindUUID, goodID)
if err != nil {
return nil, err
}
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
return nil, errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
}
queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo)
if err != nil {
return nil, err
}
if err := s.patchOrderQueue(queueOrderID, gameAccount, bindUUID); err != nil {
return nil, err
}
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
if err != nil {
return nil, err
}
providerOrderNo := firstNonEmpty(
stringFromMap(upstreamOrder, "order_no"),
stringFromMap(upstreamOrder, "order_sn"),
stringFromMap(upstreamOrder, "_id"),
stringFromMap(upstreamOrder, "id"),
queueOrderID,
)
resultData := map[string]interface{}{
"source": "delivery_proxy",
"queue_order_id": queueOrderID,
"provider_order_no": providerOrderNo,
"game_uid": gameAccount,
"role_name": stringFromMap(boundAccount, "game_account_role_name"),
"game_channel": gameChannelText(boundAccount),
"upstream_order": upstreamOrder,
}
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
MerchantID: order.MerchantID,
APIClientID: 0,
OrderNo: order.OrderNo,
Status: "processing",
ProviderOrderNo: providerOrderNo,
ResultData: resultData,
})
if err != nil {
return nil, err
}
return &DeliverySubmitResult{
OrderNo: updated.OrderNo,
Status: updated.FulfillmentStatus,
Message: "已提交上游发货,等待发货结果回传",
ProviderOrderNo: providerOrderNo,
GameAccount: boundAccount,
UpstreamOrder: upstreamOrder,
}, nil
}
func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*DeliveryOrderInfo, *FulfillmentOrderSnapshot, 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
}
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, "", errors.New(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,
Good: good,
}
return info, &FulfillmentOrderSnapshot{
ID: order.ID,
MerchantID: order.MerchantID,
OrderNo: order.OrderNo,
}, goodID, nil
}
func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interface{}) *DeliveryProduct {
if openProduct == nil {
return nil
}
product := &DeliveryProduct{
Name: openProduct.Name,
SKU: openProduct.SKU,
Game: openProduct.Game,
}
if title := stringFromMap(good, "title"); title != "" {
product.Name = title
}
product.Image = stringFromMap(good, "image")
return product
}
type FulfillmentOrderSnapshot struct {
ID uint
MerchantID uint
OrderNo string
}
func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) {
var out struct {
Good map[string]interface{} `json:"good"`
}
if err := s.signProxy("/public/goods/detail", "POST", map[string]interface{}{
"good_id": goodID,
}, &out); err != nil {
return nil, err
}
return out.Good, nil
}
func (s *DeliveryService) 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 := s.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 (s *DeliveryService) createOrderQueue(goodID, orderNo string) (string, error) {
var out struct {
Orders []map[string]interface{} `json:"orders"`
}
if err := s.signProxy("/public/users/orders-queue", "POST", map[string]interface{}{
"good_id": goodID,
"quantity": 1,
"order_sn": orderNo,
}, &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 (s *DeliveryService) patchOrderQueue(orderID, gameAccount, bindUUID string) error {
var out map[string]interface{}
return s.signProxy("/public/users/orders-queue", "PATCH", map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"bind_uuid": bindUUID,
}, &out)
}
func (s *DeliveryService) createUpstreamOrder(orderID, gameAccount, goodID, orderNo string) (map[string]interface{}, error) {
var out struct {
Order map[string]interface{} `json:"order"`
}
if err := s.signProxy("/public/users/orders", "POST", map[string]interface{}{
"order_id": orderID,
"game_account": gameAccount,
"good_id": goodID,
"h5_prefix": s.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 (s *DeliveryService) 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, s.bffBaseURL+"/sign-proxy", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.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 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",
}[sku]
}
func isZeroCode(code interface{}) bool {
switch v := code.(type) {
case nil:
return true
case float64:
return v == 0
case string:
return v == "0"
default:
return false
}
}
func stringFromMap(m map[string]interface{}, key string) string {
if m == nil {
return ""
}
switch v := m[key].(type) {
case string:
return v
case float64:
return fmt.Sprintf("%.0f", v)
default:
if v != nil {
return fmt.Sprint(v)
}
return ""
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func gameChannelText(account map[string]interface{}) string {
area := stringFromMap(account, "game_account_area")
plat := stringFromMap(account, "game_account_plat")
if area == "" && plat == "" {
return ""
}
return strings.Trim(strings.Join([]string{area, plat}, "-"), "-")
}
func truncateDeliveryText(value string) string {
if len(value) <= 300 {
return value
}
return value[:300]
}