diff --git a/.env.example b/.env.example index 779129c..2d83b8c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 49827dd..2edc3a1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2612c8e..1162e02 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"), } } diff --git a/backend/internal/handler/delivery.go b/backend/internal/handler/delivery.go new file mode 100644 index 0000000..ec90231 --- /dev/null +++ b/backend/internal/handler/delivery.go @@ -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) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 0318ec3..a7028d3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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{ diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go new file mode 100644 index 0000000..602fb4d --- /dev/null +++ b/backend/internal/service/delivery.go @@ -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] +} diff --git a/frontend/.env.example b/frontend/.env.example index 6bd612a..440cd19 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -6,3 +6,6 @@ # 后端 API 代理目标(见 vite.config.ts) VITE_API_PROXY_TARGET=http://localhost:8080 + +# 用户侧发货页面基础地址;为空时按当前前端域名生成 +VITE_DELIVERY_BASE_URL= diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e77852e..af6b2d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( } /> + } /> 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) => request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult), diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx new file mode 100644 index 0000000..c7fd884 --- /dev/null +++ b/frontend/src/pages/Delivery.tsx @@ -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 = { + 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(null) + const [bindResult, setBindResult] = useState(null) + const [submitResult, setSubmitResult] = useState(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 ( + + + + ) + } + + if (error || !order) { + return ( + + + + ) + } + + const status = statusMap[order.status] || { color: 'default', text: order.status } + const step = submitResult ? 2 : bindResult ? 1 : 0 + + return ( +
+
+ +
+ + 游戏道具发货 + + + {channel.toUpperCase()} · {decodedOrderNo} + +
+ +
+ + +
+ + {order.product?.name || '待发货商品'} + + {order.product?.sku || '-'} +
+ {status.text} +
+ + + {order.buyer_name || '-'} + {order.amount} 积分 + {formatTime(order.created_at)} + {order.product?.game || channel.toUpperCase()} + + + {order.ship_fail_reason && ( + + )} + {!order.can_ship && ( + + )} +
+
+ + {order.can_ship && ( +
+ + }, + { title: '扫码绑定', icon: }, + { title: '提交发货', icon: }, + ]} + /> + +
+ + + +
+ + + + + + + {bindResult && ( +
+ 绑定二维码 + + 绑定凭证 + + {bindResult.bind_uuid} + + + 打开绑定链接 + + +
+ )} + + {submitResult && ( + + )} +
+
+ )} +
+
+
+ ) +} + +function CenteredShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +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, +} diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 08e48bb..6d3cc38 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -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) => ( + + + + + ), + }, ] const ledgerColumns: ColumnsType = [ @@ -532,12 +552,9 @@ export default function MerchantCenter() { children: ( - 订单号用于发货平台查询。 + 发货链接会自动携带订单号,用户只填写游戏 UID。 {canManage && ( - @@ -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)} /> @@ -733,12 +750,12 @@ export default function MerchantCenter() { - )} @@ -756,9 +773,9 @@ export default function MerchantCenter() { {testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'} - - - {deliveryTestUrl} + + + {buildDeliveryLink(testOrderResult.order.order_no)} @@ -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 {item.text} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1f1c6c4..e082689 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +} + +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 + upstream_order?: unknown +} + export interface ApiClient { id: number merchant_id: number