接入无感发货流程

This commit is contained in:
yml2213
2026-07-31 10:45:13 +08:00
parent f29f3eb841
commit 6ca226ce4b
12 changed files with 951 additions and 23 deletions
+3
View File
@@ -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
+2
View File
@@ -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),
+19 -10
View File
@@ -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"),
}
}
+74
View File
@@ -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)
}
+9
View File
@@ -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{
+489
View File
@@ -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]
}
+3
View File
@@ -6,3 +6,6 @@
# 后端 API 代理目标(见 vite.config.ts
VITE_API_PROXY_TARGET=http://localhost:8080
# 用户侧发货页面基础地址;为空时按当前前端域名生成
VITE_DELIVERY_BASE_URL=
+2
View File
@@ -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={
+17
View File
@@ -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>),
+259
View File
@@ -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,
}
+34 -13
View File
@@ -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>
+40
View File
@@ -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