- 下单 data 传了 game_account 时,bind-result 返回 expected_game_account 与 mismatch - 绑定账号与预期不一致时 message 提示,配合 submit 已有的一致性校验提前发现 - 对接文档补充 mismatch 字段与说明
1125 lines
37 KiB
Go
1125 lines
37 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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"
|
|
}
|
|
if linkTTLMinutes <= 0 {
|
|
linkTTLMinutes = 120
|
|
}
|
|
return &DeliveryService{
|
|
fulfillment: fulfillment,
|
|
bffBaseURL: bffBaseURL,
|
|
channel: channel,
|
|
linkBaseURL: linkBaseURL,
|
|
linkSecret: linkSecret,
|
|
linkTTL: time.Duration(linkTTLMinutes) * time.Minute,
|
|
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"`
|
|
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"`
|
|
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
|
|
}
|
|
account, err := s.accountBound(bindUUID, goodID)
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
return s.bindGood(gameAccount, goodID)
|
|
}
|
|
|
|
func (s *DeliveryService) bindGood(gameAccount, goodID string) (*DeliveryBindResult, error) {
|
|
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, 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\"") {
|
|
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, "订单暂时正在发货中,请稍后查询")
|
|
}
|
|
boundAccount, err := s.accountBound(bindUUID, goodID)
|
|
if err != nil {
|
|
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindVerify, err.Error())
|
|
return nil, err
|
|
}
|
|
if boundGameAccount := stringFromMap(boundAccount, "game_account"); boundGameAccount != "" && boundGameAccount != gameAccount {
|
|
err := errors.New("玩家编号与绑定的游戏账号不一致,请重新绑定")
|
|
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStageBindMismatch, err.Error())
|
|
return nil, err
|
|
}
|
|
queueOrderID, err := s.createOrderQueue(goodID, order.OrderNo)
|
|
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); err != nil {
|
|
s.markDeliverySubmissionFailed(claimed, apiClientID, deliveryStagePatchQueue, err.Error())
|
|
return nil, err
|
|
}
|
|
upstreamOrder, err := s.createUpstreamOrder(queueOrderID, gameAccount, goodID, order.OrderNo)
|
|
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 = "发货已完成"
|
|
}
|
|
updated, err := s.fulfillment.UpdateFulfillment(FulfillmentUpdateInput{
|
|
MerchantID: order.MerchantID,
|
|
APIClientID: apiClientID,
|
|
OrderNo: order.OrderNo,
|
|
Status: nextStatus,
|
|
ProviderOrderNo: providerOrderNo,
|
|
ResultData: resultData,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &DeliverySubmitResult{
|
|
OrderNo: updated.OrderNo,
|
|
Status: normalizeOrderStatus(updated),
|
|
Message: message,
|
|
ProviderOrderNo: providerOrderNo,
|
|
GameAccount: boundAccount,
|
|
UpstreamOrder: 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"}).
|
|
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")
|
|
if 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.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
|
|
}
|
|
resultData := mergeResultData(latest.ResultData, map[string]interface{}{
|
|
"provider_order_stage": stage,
|
|
"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
|
|
}
|
|
goodID := ""
|
|
if 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"
|
|
} else if goodID == "" {
|
|
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
|
|
}
|
|
|
|
func buildExistingDeliverySubmitResult(order *model.FulfillmentOrder) *DeliverySubmitResult {
|
|
if order == nil {
|
|
return nil
|
|
}
|
|
switch normalizeOrderStatus(order) {
|
|
case model.OrderStatusDelivering, model.OrderStatusDelivered:
|
|
default:
|
|
return nil
|
|
}
|
|
var resultData map[string]interface{}
|
|
if json.Valid([]byte(order.ResultData)) {
|
|
_ = json.Unmarshal([]byte(order.ResultData), &resultData)
|
|
}
|
|
providerOrderNo := firstNonEmpty(
|
|
order.ProviderOrderNo,
|
|
stringFromMap(resultData, "provider_order_no"),
|
|
)
|
|
message := "订单暂时正在发货中,请稍后查询"
|
|
status := normalizeOrderStatus(order)
|
|
if status == model.OrderStatusDelivered {
|
|
message = "订单已交付"
|
|
}
|
|
return &DeliverySubmitResult{
|
|
OrderNo: order.OrderNo,
|
|
Status: status,
|
|
Message: message,
|
|
ProviderOrderNo: providerOrderNo,
|
|
UpstreamOrder: resultData,
|
|
}
|
|
}
|
|
|
|
func upstreamDeliverySucceeded(order map[string]interface{}) bool {
|
|
return strings.EqualFold(stringFromMap(order, "status"), "FINISHED") ||
|
|
strings.EqualFold(stringFromMap(order, "send_status"), "SUCCESS")
|
|
}
|
|
|
|
func deliverySubmissionInProgress(stage string) bool {
|
|
return stage == deliveryStageClaimed || stage == deliveryStageQueueCreated
|
|
}
|
|
|
|
func deliverySubmissionStale(order *model.FulfillmentOrder, now time.Time) bool {
|
|
if order == nil {
|
|
return false
|
|
}
|
|
startedAtRaw := resultDataString(order.ResultData, "submit_started_at")
|
|
if startedAtRaw != "" {
|
|
if startedAt, err := time.Parse(timeutil.APITimeLayout, startedAtRaw); err == nil {
|
|
return now.Sub(startedAt) >= deliverySubmissionStaleTimeout
|
|
}
|
|
}
|
|
return !order.UpdatedAt.IsZero() && now.Sub(order.UpdatedAt) >= deliverySubmissionStaleTimeout
|
|
}
|
|
|
|
func optionalUint(value uint) *uint {
|
|
if value == 0 {
|
|
return nil
|
|
}
|
|
out := value
|
|
return &out
|
|
}
|
|
|
|
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
|
|
}
|
|
now := time.Now().UTC()
|
|
if order.DeliveryLinkRevokedAt != nil {
|
|
return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
|
|
}
|
|
if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) {
|
|
return s.buildDeliveryLinkResult(order.OrderNo, order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second), requestBaseURL), nil
|
|
}
|
|
canShip, reason := CanFulfill(order)
|
|
if !canShip {
|
|
if reason == "" {
|
|
reason = "订单暂不可发货"
|
|
}
|
|
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
|
|
}
|
|
expiresAt := now.Add(s.linkTTL).UTC().Truncate(time.Second)
|
|
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,
|
|
}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
|
|
}
|
|
|
|
func (s *DeliveryService) RevokeDeliveryLink(merchantID 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.Model(&model.FulfillmentOrder{}).
|
|
Where("id = ?", order.ID).
|
|
Updates(map[string]interface{}{
|
|
"delivery_link_revoked_at": now,
|
|
}).Error
|
|
}
|
|
|
|
func (s *DeliveryService) RestoreDeliveryLink(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
|
|
}
|
|
canShip, reason := CanFulfill(order)
|
|
if !canShip {
|
|
if reason == "" {
|
|
reason = "订单暂不可发货"
|
|
}
|
|
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
|
|
}
|
|
expiresAt := time.Now().UTC().Add(s.linkTTL).Truncate(time.Second)
|
|
if order.DeliveryLinkExpiresAt != nil && expiresAt.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) {
|
|
expiresAt = expiresAt.Add(time.Second)
|
|
}
|
|
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,
|
|
}).Error; 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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error {
|
|
if order == nil {
|
|
return newDeliveryHTTPError(http.StatusNotFound, "订单不存在")
|
|
}
|
|
if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失")
|
|
}
|
|
if order.DeliveryLinkRevokedAt != nil {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
|
|
}
|
|
if order.DeliveryLinkExpiresAt == nil {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成")
|
|
}
|
|
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
|
|
linkExpires := time.Unix(auth.Exp, 0).UTC()
|
|
if !linkExpires.Equal(expiresAt) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效")
|
|
}
|
|
if time.Now().UTC().After(linkExpires) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期")
|
|
}
|
|
expected := s.signDeliveryLink(order.OrderNo, auth.Exp)
|
|
if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time, requestBaseURL string) *DeliveryLinkResult {
|
|
expiresAt = expiresAt.UTC()
|
|
exp := expiresAt.Unix()
|
|
sign := s.signDeliveryLink(orderNo, exp)
|
|
return &DeliveryLinkResult{
|
|
OrderNo: orderNo,
|
|
DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign, requestBaseURL),
|
|
ExpiresAt: expiresAt,
|
|
Exp: exp,
|
|
Sign: sign,
|
|
}
|
|
}
|
|
|
|
func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign, requestBaseURL string) string {
|
|
path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign))
|
|
base := strings.TrimRight(s.linkBaseURL, "/")
|
|
if base == "" {
|
|
base = strings.TrimRight(requestBaseURL, "/")
|
|
}
|
|
if base == "" {
|
|
return path
|
|
}
|
|
return base + path
|
|
}
|
|
|
|
func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string {
|
|
mac := hmac.New(sha256.New, []byte(s.linkSecret))
|
|
_, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10)))
|
|
return hex.EncodeToString(mac.Sum(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
|
|
}
|
|
|
|
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",
|
|
"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]
|
|
}
|
|
|
|
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]
|
|
}
|