接入无感发货流程
This commit is contained in:
@@ -11,6 +11,9 @@ OPEN_API_SECRET=sk_source_dev_secret_change_me
|
||||
OPEN_SIGN_SKEW=300
|
||||
OPEN_API_DEBUG=false
|
||||
LOG_FILE=logs/app.log
|
||||
DELIVERY_BASE_URL=
|
||||
DELIVERY_BFF_BASE_URL=https://www.jxya.top/bff-stg
|
||||
DELIVERY_CHANNEL=dlc
|
||||
|
||||
# Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源
|
||||
POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine
|
||||
|
||||
@@ -54,6 +54,7 @@ func main() {
|
||||
callbackSvc := service.NewCallbackService(db, codec)
|
||||
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
|
||||
merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
|
||||
deliverySvc := service.NewDeliveryService(fulfillmentSvc, cfg.DeliveryBFFBaseURL, cfg.DeliveryChannel)
|
||||
|
||||
if err := authSvc.EnsureAdmin(); err != nil {
|
||||
log.Fatalf("ensure admin: %v", err)
|
||||
@@ -64,6 +65,7 @@ func main() {
|
||||
h := &router.Handlers{
|
||||
Auth: handler.NewAuthHandler(authSvc),
|
||||
Dashboard: handler.NewDashboardHandler(fulfillmentSvc),
|
||||
Delivery: handler.NewDeliveryHandler(deliverySvc),
|
||||
User: handler.NewUserHandler(userSvc),
|
||||
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc),
|
||||
SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
|
||||
|
||||
@@ -26,6 +26,12 @@ type Config struct {
|
||||
OpenAPIDebug bool
|
||||
// LogFile 日志文件路径;非空时同时写控制台与文件
|
||||
LogFile string
|
||||
// DeliveryBaseURL 用户侧发货页面基础地址;为空时前端可自行按当前域名拼接。
|
||||
DeliveryBaseURL string
|
||||
// DeliveryBFFBaseURL 上游测试发货 BFF 地址。
|
||||
DeliveryBFFBaseURL string
|
||||
// DeliveryChannel 上游测试发货渠道前缀,如 dlc。
|
||||
DeliveryChannel string
|
||||
}
|
||||
|
||||
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
|
||||
@@ -35,16 +41,19 @@ func Load() *Config {
|
||||
// OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启
|
||||
debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "")
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
|
||||
Mode: mode,
|
||||
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")),
|
||||
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
|
||||
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
|
||||
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
|
||||
OpenAPIDebug: debugOpen,
|
||||
LogFile: getEnv("LOG_FILE", "logs/app.log"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
|
||||
Mode: mode,
|
||||
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")),
|
||||
OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
|
||||
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
|
||||
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
|
||||
OpenAPIDebug: debugOpen,
|
||||
LogFile: getEnv("LOG_FILE", "logs/app.log"),
|
||||
DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""),
|
||||
DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff-stg"),
|
||||
DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/pkg/response"
|
||||
"affiliate_dash/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DeliveryHandler 提供用户侧公开发货页面使用的接口。
|
||||
type DeliveryHandler struct {
|
||||
deliverySvc *service.DeliveryService
|
||||
}
|
||||
|
||||
func NewDeliveryHandler(deliverySvc *service.DeliveryService) *DeliveryHandler {
|
||||
return &DeliveryHandler{deliverySvc: deliverySvc}
|
||||
}
|
||||
|
||||
func (h *DeliveryHandler) GetOrder(c *gin.Context) {
|
||||
order, err := h.deliverySvc.GetOrder(c.Param("order_no"))
|
||||
if err != nil {
|
||||
writeDeliveryError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, order)
|
||||
}
|
||||
|
||||
type deliveryBindReq struct {
|
||||
GameAccount string `json:"game_account" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *DeliveryHandler) Bind(c *gin.Context) {
|
||||
var req deliveryBindReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请输入玩家编号")
|
||||
return
|
||||
}
|
||||
result, err := h.deliverySvc.Bind(c.Param("order_no"), req.GameAccount)
|
||||
if err != nil {
|
||||
writeDeliveryError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
type deliverySubmitReq struct {
|
||||
GameAccount string `json:"game_account" binding:"required"`
|
||||
BindUUID string `json:"bind_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *DeliveryHandler) Submit(c *gin.Context) {
|
||||
var req deliverySubmitReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "玩家编号和绑定凭证不能为空")
|
||||
return
|
||||
}
|
||||
result, err := h.deliverySvc.Submit(c.Param("order_no"), req.GameAccount, req.BindUUID)
|
||||
if err != nil {
|
||||
writeDeliveryError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func writeDeliveryError(c *gin.Context, err error) {
|
||||
msg := strings.TrimSpace(err.Error())
|
||||
if msg == "订单不存在" {
|
||||
response.NotFound(c, msg)
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type Handlers struct {
|
||||
Auth *handler.AuthHandler
|
||||
Dashboard *handler.DashboardHandler
|
||||
Delivery *handler.DeliveryHandler
|
||||
User *handler.UserHandler
|
||||
Open *handler.OpenV1Handler
|
||||
SourceOpen *handler.OpenHandler
|
||||
@@ -48,6 +49,14 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
{
|
||||
api.POST("/auth/login", h.Auth.Login)
|
||||
|
||||
// 用户侧公开发货页接口:商户把发货链接给用户,用户只填写游戏 UID。
|
||||
delivery := api.Group("/delivery/v1")
|
||||
{
|
||||
delivery.GET("/orders/:order_no", h.Delivery.GetOrder)
|
||||
delivery.POST("/orders/:order_no/bind", h.Delivery.Bind)
|
||||
delivery.POST("/orders/:order_no/submit", h.Delivery.Submit)
|
||||
}
|
||||
|
||||
// 源头侧接口:上游发货平台查询订单并回传发货结果。
|
||||
sourceOpen := api.Group("/open/v1")
|
||||
sourceOpen.Use(middleware.SourceOpenAuth(middleware.SourceOpenAuthConfig{
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
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]
|
||||
}
|
||||
@@ -6,3 +6,6 @@
|
||||
|
||||
# 后端 API 代理目标(见 vite.config.ts)
|
||||
VITE_API_PROXY_TARGET=http://localhost:8080
|
||||
|
||||
# 用户侧发货页面基础地址;为空时按当前前端域名生成
|
||||
VITE_DELIVERY_BASE_URL=
|
||||
|
||||
@@ -7,6 +7,7 @@ import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import OpenApiDocs from './pages/OpenApiDocs'
|
||||
import ApiDebugger from './pages/ApiDebugger'
|
||||
import Delivery from './pages/Delivery'
|
||||
import MerchantCenter from './pages/MerchantCenter'
|
||||
import PlatformMerchants from './pages/PlatformMerchants'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -26,6 +27,7 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/delivery/:channel/:orderNo" element={<Delivery />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
CallbackCredential,
|
||||
CallbackSubscription,
|
||||
CreateTestOrderResult,
|
||||
DeliveryBindResult,
|
||||
DeliveryOrderInfo,
|
||||
DeliverySubmitResult,
|
||||
FulfillmentOrder,
|
||||
LoginResult,
|
||||
Merchant,
|
||||
@@ -93,6 +96,20 @@ export const merchantApi = {
|
||||
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
||||
}
|
||||
|
||||
export const deliveryApi = {
|
||||
getOrder: (orderNo: string) =>
|
||||
request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`).then((r) => r.data.data as DeliveryOrderInfo),
|
||||
bind: (orderNo: string, gameAccount: string) =>
|
||||
request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, {
|
||||
game_account: gameAccount,
|
||||
}).then((r) => r.data.data as DeliveryBindResult),
|
||||
submit: (orderNo: string, gameAccount: string, bindUUID: string) =>
|
||||
request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`, {
|
||||
game_account: gameAccount,
|
||||
bind_uuid: bindUUID,
|
||||
}).then((r) => r.data.data as DeliverySubmitResult),
|
||||
}
|
||||
|
||||
export const platformApi = {
|
||||
merchants: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Result,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
SendOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import { deliveryApi } from '../api'
|
||||
import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types'
|
||||
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
paid: { color: 'orange', text: '待发货' },
|
||||
delivering: { color: 'blue', text: '履约中' },
|
||||
delivered: { color: 'green', text: '已交付' },
|
||||
ship_failed: { color: 'red', text: '发货失败' },
|
||||
cancelled: { color: 'default', text: '已取消' },
|
||||
}
|
||||
|
||||
export default function Delivery() {
|
||||
const { channel = 'dlc', orderNo = '' } = useParams()
|
||||
const [form] = Form.useForm<{ game_account: string }>()
|
||||
const [order, setOrder] = useState<DeliveryOrderInfo | null>(null)
|
||||
const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null)
|
||||
const [submitResult, setSubmitResult] = useState<DeliverySubmitResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [binding, setBinding] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo])
|
||||
const gameAccount = Form.useWatch('game_account', form)
|
||||
|
||||
useEffect(() => {
|
||||
if (!decodedOrderNo) {
|
||||
setError('发货链接缺少订单号')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
deliveryApi.getOrder(decodedOrderNo)
|
||||
.then((data) => {
|
||||
setOrder(data)
|
||||
setError('')
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '订单加载失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [decodedOrderNo])
|
||||
|
||||
const bindAccount = async () => {
|
||||
const values = await form.validateFields()
|
||||
setBinding(true)
|
||||
setSubmitResult(null)
|
||||
try {
|
||||
const result = await deliveryApi.bind(decodedOrderNo, values.game_account)
|
||||
setBindResult(result)
|
||||
message.success('绑定二维码已生成')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '生成二维码失败')
|
||||
} finally {
|
||||
setBinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const submitDelivery = async () => {
|
||||
const values = await form.validateFields()
|
||||
if (!bindResult?.bind_uuid) {
|
||||
message.warning('请先生成绑定二维码')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid)
|
||||
setSubmitResult(result)
|
||||
setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev)
|
||||
message.success(result.message || '已提交')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '提交失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CenteredShell>
|
||||
<Spin size="large" />
|
||||
</CenteredShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<CenteredShell>
|
||||
<Result status="warning" title="订单不可发货" subTitle={error || '订单不存在'} />
|
||||
</CenteredShell>
|
||||
)
|
||||
}
|
||||
|
||||
const status = statusMap[order.status] || { color: 'default', text: order.status }
|
||||
const step = submitResult ? 2 : bindResult ? 1 : 0
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', background: '#f5f7fb', padding: '32px 16px' }}>
|
||||
<div style={{ maxWidth: 880, margin: '0 auto' }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<header>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||
游戏道具发货
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{channel.toUpperCase()} · {decodedOrderNo}
|
||||
</Typography.Text>
|
||||
</header>
|
||||
|
||||
<section style={panelStyle}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space align="start" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 8 }}>
|
||||
{order.product?.name || '待发货商品'}
|
||||
</Typography.Title>
|
||||
<Typography.Text code>{order.product?.sku || '-'}</Typography.Text>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.text}</Tag>
|
||||
</Space>
|
||||
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered>
|
||||
<Descriptions.Item label="买家">{order.buyer_name || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">{order.amount} 积分</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatTime(order.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道">{order.product?.game || channel.toUpperCase()}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{order.ship_fail_reason && (
|
||||
<Alert type="error" showIcon message={order.ship_fail_reason} />
|
||||
)}
|
||||
{!order.can_ship && (
|
||||
<Alert type="warning" showIcon message={order.cannot_ship_reason || '当前订单暂不可发货'} />
|
||||
)}
|
||||
</Space>
|
||||
</section>
|
||||
|
||||
{order.can_ship && (
|
||||
<section style={panelStyle}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Steps
|
||||
current={step}
|
||||
items={[
|
||||
{ title: '填写 UID', icon: <ClockCircleOutlined /> },
|
||||
{ title: '扫码绑定', icon: <QrcodeOutlined /> },
|
||||
{ title: '提交发货', icon: <CheckCircleOutlined /> },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{ game_account: order.game_uid || '' }}>
|
||||
<Form.Item
|
||||
name="game_account"
|
||||
label="游戏 UID"
|
||||
rules={[
|
||||
{ required: true, message: '请输入游戏 UID' },
|
||||
{ pattern: /^\d+$/, message: '游戏 UID 仅支持数字' },
|
||||
]}
|
||||
>
|
||||
<Input size="large" inputMode="numeric" placeholder="请输入游戏 UID" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<QrcodeOutlined />}
|
||||
loading={binding}
|
||||
onClick={bindAccount}
|
||||
>
|
||||
生成绑定二维码
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
disabled={!bindResult || !gameAccount}
|
||||
onClick={submitDelivery}
|
||||
>
|
||||
我已扫码,提交发货
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{bindResult && (
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Image
|
||||
src={bindResult.qr_url}
|
||||
width={220}
|
||||
height={220}
|
||||
alt="绑定二维码"
|
||||
style={{ background: '#fff', border: '1px solid #e5e7eb' }}
|
||||
/>
|
||||
<Space direction="vertical" size="small" style={{ flex: 1, minWidth: 260 }}>
|
||||
<Typography.Text strong>绑定凭证</Typography.Text>
|
||||
<Typography.Text code copyable={{ text: bindResult.bind_uuid }}>
|
||||
{bindResult.bind_uuid}
|
||||
</Typography.Text>
|
||||
<Typography.Link href={bindResult.bind_url} target="_blank" rel="noreferrer">
|
||||
打开绑定链接
|
||||
</Typography.Link>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitResult && (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={submitResult.message}
|
||||
description={submitResult.provider_order_no ? `上游单号:${submitResult.provider_order_no}` : undefined}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</section>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function CenteredShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: '#f5f7fb', padding: 24 }}>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
background: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
padding: 24,
|
||||
}
|
||||
@@ -76,7 +76,8 @@ const eventOptions = [
|
||||
{ value: 'order.cancelled', label: '订单取消' },
|
||||
]
|
||||
|
||||
const deliveryTestUrl = import.meta.env.VITE_DELIVERY_TEST_URL || 'https://www.jxya.top/test-delivery/dlc/'
|
||||
const deliveryBaseUrl = (import.meta.env.VITE_DELIVERY_BASE_URL || window.location.origin).replace(/\/+$/, '')
|
||||
const deliveryChannel = 'dlc'
|
||||
|
||||
const testOrderStatusOptions = [
|
||||
{ value: 'pending', label: '已支付,可发货' },
|
||||
@@ -123,6 +124,14 @@ export default function MerchantCenter() {
|
||||
label: productOptionLabel(item),
|
||||
})), [testOrderProducts])
|
||||
|
||||
const copyDeliveryLink = useCallback((orderNo: string) => {
|
||||
navigator.clipboard.writeText(buildDeliveryLink(orderNo)).then(() => message.success('发货链接已复制'))
|
||||
}, [])
|
||||
|
||||
const openDeliveryLink = useCallback((orderNo: string) => {
|
||||
window.open(buildDeliveryLink(orderNo), '_blank', 'noopener,noreferrer')
|
||||
}, [])
|
||||
|
||||
const loadCurrent = useCallback(async () => {
|
||||
const data = await merchantApi.current()
|
||||
setMerchant(data.merchant)
|
||||
@@ -427,6 +436,17 @@ export default function MerchantCenter() {
|
||||
{ title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag },
|
||||
{ title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 160, render: formatTime },
|
||||
{
|
||||
title: '发货链接',
|
||||
key: 'delivery_link',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" onClick={() => copyDeliveryLink(record.order_no)}>复制</Button>
|
||||
<Button type="link" size="small" onClick={() => openDeliveryLink(record.order_no)}>打开</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
|
||||
@@ -532,12 +552,9 @@ export default function MerchantCenter() {
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">订单号用于发货平台查询。</Typography.Text>
|
||||
<Typography.Text type="secondary">发货链接会自动携带订单号,用户只填写游戏 UID。</Typography.Text>
|
||||
{canManage && (
|
||||
<Space>
|
||||
<Button icon={<LinkOutlined />} href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||
发货测试页
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
|
||||
创建测试订单
|
||||
</Button>
|
||||
@@ -550,7 +567,7 @@ export default function MerchantCenter() {
|
||||
columns={orderColumns}
|
||||
dataSource={orders.list}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1300 }}
|
||||
scroll={{ x: 1450 }}
|
||||
pagination={pageConfig(orders, loadOrders)}
|
||||
/>
|
||||
</Space>
|
||||
@@ -733,12 +750,12 @@ export default function MerchantCenter() {
|
||||
<Button onClick={() => setTestOrderResult(null)}>关闭</Button>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => navigator.clipboard.writeText(testOrderResult?.order.order_no || '').then(() => message.success('已复制'))}
|
||||
onClick={() => copyDeliveryLink(testOrderResult?.order.order_no || '')}
|
||||
>
|
||||
复制订单号
|
||||
复制发货链接
|
||||
</Button>
|
||||
<Button type="primary" icon={<LinkOutlined />} href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||
发货测试页
|
||||
<Button type="primary" icon={<LinkOutlined />} onClick={() => openDeliveryLink(testOrderResult?.order.order_no || '')}>
|
||||
打开发货页
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
@@ -756,9 +773,9 @@ export default function MerchantCenter() {
|
||||
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="测试页">
|
||||
<Typography.Link href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||
{deliveryTestUrl}
|
||||
<Descriptions.Item label="发货链接">
|
||||
<Typography.Link href={buildDeliveryLink(testOrderResult.order.order_no)} target="_blank" rel="noreferrer">
|
||||
{buildDeliveryLink(testOrderResult.order.order_no)}
|
||||
</Typography.Link>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@@ -918,6 +935,10 @@ function productOptionLabel(item: MerchantProduct) {
|
||||
return name === item.sku ? item.sku : `${name} / ${item.sku}`
|
||||
}
|
||||
|
||||
function buildDeliveryLink(orderNo: string) {
|
||||
return `${deliveryBaseUrl}/delivery/${deliveryChannel}/${encodeURIComponent(orderNo)}`
|
||||
}
|
||||
|
||||
function paymentStatusTag(value: string) {
|
||||
const item = paymentStatusMap[value] || { color: 'default', text: value }
|
||||
return <Tag color={item.color}>{item.text}</Tag>
|
||||
|
||||
@@ -124,6 +124,46 @@ export interface CreateTestOrderResult {
|
||||
cannot_ship_reason?: string
|
||||
}
|
||||
|
||||
export interface DeliveryProduct {
|
||||
name: string
|
||||
sku: string
|
||||
game: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export interface DeliveryOrderInfo {
|
||||
order_no: string
|
||||
status: string
|
||||
can_ship: boolean
|
||||
cannot_ship_reason?: string
|
||||
product?: DeliveryProduct
|
||||
buyer_name: string
|
||||
amount: number
|
||||
created_at: string
|
||||
shipped_at?: string | null
|
||||
ship_fail_reason?: string
|
||||
game_channel?: string
|
||||
game_uid?: string
|
||||
role_name?: string
|
||||
pay_score?: number
|
||||
good?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface DeliveryBindResult {
|
||||
bind_uuid: string
|
||||
bind_url: string
|
||||
qr_url: string
|
||||
}
|
||||
|
||||
export interface DeliverySubmitResult {
|
||||
order_no: string
|
||||
status: string
|
||||
message: string
|
||||
provider_order_no?: string
|
||||
game_account?: Record<string, unknown>
|
||||
upstream_order?: unknown
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
id: number
|
||||
merchant_id: number
|
||||
|
||||
Reference in New Issue
Block a user