完善发货链接签名和作废恢复

This commit is contained in:
yml2213
2026-07-31 11:43:13 +08:00
parent 6ca226ce4b
commit e4c1306216
17 changed files with 527 additions and 109 deletions
+2
View File
@@ -14,6 +14,8 @@ LOG_FILE=logs/app.log
DELIVERY_BASE_URL= DELIVERY_BASE_URL=
DELIVERY_BFF_BASE_URL=https://www.jxya.top/bff-stg DELIVERY_BFF_BASE_URL=https://www.jxya.top/bff-stg
DELIVERY_CHANNEL=dlc DELIVERY_CHANNEL=dlc
DELIVERY_LINK_SECRET=change_me_for_delivery_link_sign
DELIVERY_LINK_TTL_MINUTES=120
# Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源 # Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源
POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine
+9 -2
View File
@@ -54,7 +54,14 @@ func main() {
callbackSvc := service.NewCallbackService(db, codec) callbackSvc := service.NewCallbackService(db, codec)
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
merchantSvc := service.NewMerchantService(db, codec, tenantSvc) merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
deliverySvc := service.NewDeliveryService(fulfillmentSvc, cfg.DeliveryBFFBaseURL, cfg.DeliveryChannel) deliverySvc := service.NewDeliveryService(
fulfillmentSvc,
cfg.DeliveryBFFBaseURL,
cfg.DeliveryChannel,
cfg.DeliveryBaseURL,
cfg.DeliveryLinkSecret,
cfg.DeliveryLinkTTLMinutes,
)
if err := authSvc.EnsureAdmin(); err != nil { if err := authSvc.EnsureAdmin(); err != nil {
log.Fatalf("ensure admin: %v", err) log.Fatalf("ensure admin: %v", err)
@@ -69,7 +76,7 @@ func main() {
User: handler.NewUserHandler(userSvc), User: handler.NewUserHandler(userSvc),
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc),
SourceOpen: handler.NewOpenHandler(fulfillmentSvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc),
JWT: jm, JWT: jm,
Tenant: tenantSvc, Tenant: tenantSvc,
OpenDB: db, OpenDB: db,
+19 -13
View File
@@ -32,6 +32,10 @@ type Config struct {
DeliveryBFFBaseURL string DeliveryBFFBaseURL string
// DeliveryChannel 上游测试发货渠道前缀,如 dlc。 // DeliveryChannel 上游测试发货渠道前缀,如 dlc。
DeliveryChannel string DeliveryChannel string
// DeliveryLinkSecret 发货链接签名密钥。
DeliveryLinkSecret string
// DeliveryLinkTTLMinutes 发货链接默认有效分钟数。
DeliveryLinkTTLMinutes int
} }
// Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高) // Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高)
@@ -41,19 +45,21 @@ func Load() *Config {
// OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启 // OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启
debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "") debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "")
return &Config{ return &Config{
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), 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"), DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"),
Mode: mode, Mode: mode,
DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), 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"), OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"),
OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"),
OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)),
OpenAPIDebug: debugOpen, OpenAPIDebug: debugOpen,
LogFile: getEnv("LOG_FILE", "logs/app.log"), LogFile: getEnv("LOG_FILE", "logs/app.log"),
DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""), DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""),
DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff-stg"), DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff-stg"),
DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"), DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"),
DeliveryLinkSecret: getEnv("DELIVERY_LINK_SECRET", getEnv("OPEN_API_SECRET", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"))),
DeliveryLinkTTLMinutes: getEnvInt("DELIVERY_LINK_TTL_MINUTES", 120),
} }
} }
@@ -0,0 +1,6 @@
-- 为发货链接增加过期时间字段,支持签名短链与手动失效。
ALTER TABLE fulfillment_orders
ADD COLUMN IF NOT EXISTS delivery_link_expires_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_delivery_link_expires_at
ON fulfillment_orders (delivery_link_expires_at);
@@ -0,0 +1,6 @@
-- 为发货链接增加作废时间字段,确保作废后不会自动恢复。
ALTER TABLE fulfillment_orders
ADD COLUMN IF NOT EXISTS delivery_link_revoked_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_delivery_link_revoked_at
ON fulfillment_orders (delivery_link_revoked_at);
+29 -3
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"strconv"
"strings" "strings"
"affiliate_dash/internal/pkg/response" "affiliate_dash/internal/pkg/response"
@@ -19,7 +20,7 @@ func NewDeliveryHandler(deliverySvc *service.DeliveryService) *DeliveryHandler {
} }
func (h *DeliveryHandler) GetOrder(c *gin.Context) { func (h *DeliveryHandler) GetOrder(c *gin.Context) {
order, err := h.deliverySvc.GetOrder(c.Param("order_no")) order, err := h.deliverySvc.GetOrder(c.Param("order_no"), parseDeliveryAuth(c))
if err != nil { if err != nil {
writeDeliveryError(c, err) writeDeliveryError(c, err)
return return
@@ -37,7 +38,7 @@ func (h *DeliveryHandler) Bind(c *gin.Context) {
response.BadRequest(c, "请输入玩家编号") response.BadRequest(c, "请输入玩家编号")
return return
} }
result, err := h.deliverySvc.Bind(c.Param("order_no"), req.GameAccount) result, err := h.deliverySvc.Bind(c.Param("order_no"), req.GameAccount, parseDeliveryAuth(c))
if err != nil { if err != nil {
writeDeliveryError(c, err) writeDeliveryError(c, err)
return return
@@ -56,7 +57,7 @@ func (h *DeliveryHandler) Submit(c *gin.Context) {
response.BadRequest(c, "玩家编号和绑定凭证不能为空") response.BadRequest(c, "玩家编号和绑定凭证不能为空")
return return
} }
result, err := h.deliverySvc.Submit(c.Param("order_no"), req.GameAccount, req.BindUUID) result, err := h.deliverySvc.Submit(c.Param("order_no"), req.GameAccount, req.BindUUID, parseDeliveryAuth(c))
if err != nil { if err != nil {
writeDeliveryError(c, err) writeDeliveryError(c, err)
return return
@@ -64,11 +65,36 @@ func (h *DeliveryHandler) Submit(c *gin.Context) {
response.OK(c, result) response.OK(c, result)
} }
func parseDeliveryAuth(c *gin.Context) service.DeliveryLinkAuth {
exp := int64(0)
if value := strings.TrimSpace(c.Query("exp")); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
exp = parsed
}
}
return service.DeliveryLinkAuth{
Exp: exp,
Sign: strings.TrimSpace(c.Query("sign")),
}
}
func writeDeliveryError(c *gin.Context, err error) { func writeDeliveryError(c *gin.Context, err error) {
type statusCoder interface {
HTTPStatus() int
}
if sc, ok := err.(statusCoder); ok {
status := sc.HTTPStatus()
response.Fail(c, status, status, strings.TrimSpace(err.Error()))
return
}
msg := strings.TrimSpace(err.Error()) msg := strings.TrimSpace(err.Error())
if msg == "订单不存在" { if msg == "订单不存在" {
response.NotFound(c, msg) response.NotFound(c, msg)
return return
} }
if msg == "链接参数缺失" || msg == "发货链接未生成" || msg == "发货链接已失效" || msg == "发货链接已过期" || msg == "发货链接签名无效" {
response.Forbidden(c, msg)
return
}
response.BadRequest(c, msg) response.BadRequest(c, msg)
} }
+29 -1
View File
@@ -16,13 +16,15 @@ type MerchantHandler struct {
merchantSvc *service.MerchantService merchantSvc *service.MerchantService
fulfillmentSvc *service.FulfillmentService fulfillmentSvc *service.FulfillmentService
callbackSvc *service.CallbackService callbackSvc *service.CallbackService
deliverySvc *service.DeliveryService
} }
func NewMerchantHandler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, callbackSvc *service.CallbackService) *MerchantHandler { func NewMerchantHandler(merchantSvc *service.MerchantService, fulfillmentSvc *service.FulfillmentService, callbackSvc *service.CallbackService, deliverySvc *service.DeliveryService) *MerchantHandler {
return &MerchantHandler{ return &MerchantHandler{
merchantSvc: merchantSvc, merchantSvc: merchantSvc,
fulfillmentSvc: fulfillmentSvc, fulfillmentSvc: fulfillmentSvc,
callbackSvc: callbackSvc, callbackSvc: callbackSvc,
deliverySvc: deliverySvc,
} }
} }
@@ -165,6 +167,32 @@ func (h *MerchantHandler) CreateTestOrder(c *gin.Context) {
}) })
} }
func (h *MerchantHandler) GetDeliveryLink(c *gin.Context) {
link, err := h.deliverySvc.GetOrCreateDeliveryLink(middleware.GetMerchantID(c), c.Param("order_no"))
if err != nil {
writeDeliveryError(c, err)
return
}
response.OK(c, link)
}
func (h *MerchantHandler) RevokeDeliveryLink(c *gin.Context) {
if err := h.deliverySvc.RevokeDeliveryLink(middleware.GetMerchantID(c), c.Param("order_no")); err != nil {
writeDeliveryError(c, err)
return
}
response.OK(c, nil)
}
func (h *MerchantHandler) RestoreDeliveryLink(c *gin.Context) {
link, err := h.deliverySvc.RestoreDeliveryLink(middleware.GetMerchantID(c), c.Param("order_no"))
if err != nil {
writeDeliveryError(c, err)
return
}
response.OK(c, link)
}
func (h *MerchantHandler) GetWallet(c *gin.Context) { func (h *MerchantHandler) GetWallet(c *gin.Context) {
wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c)) wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c))
if err != nil { if err != nil {
+25 -23
View File
@@ -196,29 +196,31 @@ type FulfillmentOrder struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MerchantID uint `gorm:"not null;index;uniqueIndex:idx_order_client_no" json:"merchant_id"` MerchantID uint `gorm:"not null;index;uniqueIndex:idx_order_client_no" json:"merchant_id"`
OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"` OrderNo string `gorm:"uniqueIndex;size:64;not null" json:"order_no"`
ClientOrderNo string `gorm:"size:96;not null;uniqueIndex:idx_order_client_no" json:"client_order_no"` ClientOrderNo string `gorm:"size:96;not null;uniqueIndex:idx_order_client_no" json:"client_order_no"`
MerchantProductID uint `gorm:"not null;index" json:"merchant_product_id"` MerchantProductID uint `gorm:"not null;index" json:"merchant_product_id"`
ProductSKU string `gorm:"size:96;not null" json:"product_sku"` ProductSKU string `gorm:"size:96;not null" json:"product_sku"`
ProductName string `gorm:"size:160;not null" json:"product_name"` ProductName string `gorm:"size:160;not null" json:"product_name"`
Quantity int64 `gorm:"not null;default:1" json:"quantity"` Quantity int64 `gorm:"not null;default:1" json:"quantity"`
BaseAmount int64 `gorm:"not null;default:0" json:"base_amount"` BaseAmount int64 `gorm:"not null;default:0" json:"base_amount"`
FeeType string `gorm:"size:16;not null;default:rate" json:"fee_type"` FeeType string `gorm:"size:16;not null;default:rate" json:"fee_type"`
FeeRateBP int64 `gorm:"not null;default:0" json:"fee_rate_bp"` FeeRateBP int64 `gorm:"not null;default:0" json:"fee_rate_bp"`
FeeFixedAmount int64 `gorm:"not null;default:0" json:"fee_fixed_amount"` FeeFixedAmount int64 `gorm:"not null;default:0" json:"fee_fixed_amount"`
ServiceFeeAmount int64 `gorm:"not null;default:0" json:"service_fee_amount"` ServiceFeeAmount int64 `gorm:"not null;default:0" json:"service_fee_amount"`
Amount int64 `gorm:"not null" json:"amount"` Amount int64 `gorm:"not null" json:"amount"`
Currency string `gorm:"size:12;not null;default:POINT" json:"currency"` Currency string `gorm:"size:12;not null;default:POINT" json:"currency"`
PaymentStatus string `gorm:"size:16;not null;default:pending;index" json:"payment_status"` PaymentStatus string `gorm:"size:16;not null;default:pending;index" json:"payment_status"`
FulfillmentStatus string `gorm:"size:16;not null;default:pending;index" json:"fulfillment_status"` FulfillmentStatus string `gorm:"size:16;not null;default:pending;index" json:"fulfillment_status"`
BuyerReference string `gorm:"size:128" json:"buyer_reference"` BuyerReference string `gorm:"size:128" json:"buyer_reference"`
RequestData string `gorm:"type:text" json:"request_data"` RequestData string `gorm:"type:text" json:"request_data"`
ResultData string `gorm:"type:text" json:"result_data"` ResultData string `gorm:"type:text" json:"result_data"`
ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"` ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"`
FailureReason string `gorm:"size:512" json:"failure_reason"` FailureReason string `gorm:"size:512" json:"failure_reason"`
CancelledAt *time.Time `json:"cancelled_at"` CancelledAt *time.Time `json:"cancelled_at"`
DeliveredAt *time.Time `json:"delivered_at"` DeliveredAt *time.Time `json:"delivered_at"`
DeliveryLinkExpiresAt *time.Time `json:"delivery_link_expires_at"`
DeliveryLinkRevokedAt *time.Time `json:"delivery_link_revoked_at"`
MerchantProduct *MerchantProduct `gorm:"foreignKey:MerchantProductID" json:"merchant_product,omitempty"` MerchantProduct *MerchantProduct `gorm:"foreignKey:MerchantProductID" json:"merchant_product,omitempty"`
} }
+3
View File
@@ -103,6 +103,9 @@ func Setup(h *Handlers) *gin.Engine {
merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct) merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct)
merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders) merchant.GET("/orders", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), h.Merchant.ListOrders)
merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder) merchant.POST("/orders/test", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.CreateTestOrder)
merchant.GET("/orders/:order_no/delivery-link", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.GetDeliveryLink)
merchant.POST("/orders/:order_no/delivery-link/revoke", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RevokeDeliveryLink)
merchant.POST("/orders/:order_no/delivery-link/restore", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureOrders), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.RestoreDeliveryLink)
merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet) merchant.GET("/wallet", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator, model.MemberRoleFinance), h.Merchant.GetWallet)
merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger) merchant.GET("/wallet/ledger", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.ListWalletLedger)
merchant.POST("/wallet/adjust", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet) merchant.POST("/wallet/adjust", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureWallet), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleFinance), h.Merchant.AdjustWallet)
+2 -1
View File
@@ -24,6 +24,7 @@ func TestSetupDoesNotPanic(t *testing.T) {
callbackSvc := service.NewCallbackService(db, codec) callbackSvc := service.NewCallbackService(db, codec)
fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc)
merchantSvc := service.NewMerchantService(db, codec, tenantSvc) merchantSvc := service.NewMerchantService(db, codec, tenantSvc)
deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0)
defer func() { defer func() {
if recovered := recover(); recovered != nil { if recovered := recover(); recovered != nil {
@@ -36,7 +37,7 @@ func TestSetupDoesNotPanic(t *testing.T) {
User: handler.NewUserHandler(userSvc), User: handler.NewUserHandler(userSvc),
Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc),
SourceOpen: handler.NewOpenHandler(fulfillmentSvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc),
Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc),
JWT: jwt.NewManager("test-jwt"), JWT: jwt.NewManager("test-jwt"),
Tenant: tenantSvc, Tenant: tenantSvc,
OpenDB: db, OpenDB: db,
+203 -20
View File
@@ -2,14 +2,20 @@ package service
import ( import (
"bytes" "bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
"affiliate_dash/internal/model"
) )
const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg" const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg"
@@ -18,10 +24,13 @@ type DeliveryService struct {
fulfillment *FulfillmentService fulfillment *FulfillmentService
bffBaseURL string bffBaseURL string
channel string channel string
linkBaseURL string
linkSecret string
linkTTL time.Duration
httpClient *http.Client httpClient *http.Client
} }
func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel string) *DeliveryService { func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, linkBaseURL, linkSecret string, linkTTLMinutes int) *DeliveryService {
bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/") bffBaseURL = strings.TrimRight(strings.TrimSpace(bffBaseURL), "/")
if bffBaseURL == "" { if bffBaseURL == "" {
bffBaseURL = defaultDeliveryBFFBaseURL bffBaseURL = defaultDeliveryBFFBaseURL
@@ -30,10 +39,21 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel str
if channel == "" { if channel == "" {
channel = "dlc" channel = "dlc"
} }
linkBaseURL = strings.TrimRight(strings.TrimSpace(linkBaseURL), "/")
linkSecret = strings.TrimSpace(linkSecret)
if linkSecret == "" {
linkSecret = "affiliate-dash-delivery-link-secret"
}
if linkTTLMinutes <= 0 {
linkTTLMinutes = 120
}
return &DeliveryService{ return &DeliveryService{
fulfillment: fulfillment, fulfillment: fulfillment,
bffBaseURL: bffBaseURL, bffBaseURL: bffBaseURL,
channel: channel, channel: channel,
linkBaseURL: linkBaseURL,
linkSecret: linkSecret,
linkTTL: time.Duration(linkTTLMinutes) * time.Minute,
httpClient: &http.Client{Timeout: 15 * time.Second}, httpClient: &http.Client{Timeout: 15 * time.Second},
} }
} }
@@ -75,6 +95,14 @@ type DeliveryBindResult struct {
QRURL string `json:"qr_url"` QRURL string `json:"qr_url"`
} }
type DeliveryLinkResult struct {
OrderNo string `json:"order_no"`
DeliveryURL string `json:"delivery_url"`
ExpiresAt time.Time `json:"expires_at"`
Exp int64 `json:"exp"`
Sign string `json:"sign"`
}
type DeliverySubmitResult struct { type DeliverySubmitResult struct {
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
Status string `json:"status"` Status string `json:"status"`
@@ -84,17 +112,39 @@ type DeliverySubmitResult struct {
UpstreamOrder interface{} `json:"upstream_order,omitempty"` UpstreamOrder interface{} `json:"upstream_order,omitempty"`
} }
func (s *DeliveryService) GetOrder(orderNo string) (*DeliveryOrderInfo, error) { type DeliveryLinkAuth struct {
info, _, _, err := s.prepareOrder(orderNo, false) Exp int64
Sign string
}
type deliveryHTTPError struct {
status int
message string
}
func (e deliveryHTTPError) Error() string {
return e.message
}
func (e deliveryHTTPError) HTTPStatus() int {
return e.status
}
func newDeliveryHTTPError(status int, message string) error {
return deliveryHTTPError{status: status, message: message}
}
func (s *DeliveryService) GetOrder(orderNo string, auth DeliveryLinkAuth) (*DeliveryOrderInfo, error) {
info, _, _, err := s.prepareOrder(orderNo, false, &auth)
return info, err return info, err
} }
func (s *DeliveryService) Bind(orderNo, gameAccount string) (*DeliveryBindResult, error) { func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) {
gameAccount = strings.TrimSpace(gameAccount) gameAccount = strings.TrimSpace(gameAccount)
if gameAccount == "" { if gameAccount == "" {
return nil, errors.New("请输入玩家编号") return nil, errors.New("请输入玩家编号")
} }
_, _, goodID, err := s.prepareOrder(orderNo, true) _, _, goodID, err := s.prepareOrder(orderNo, true, &auth)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -120,14 +170,14 @@ func (s *DeliveryService) Bind(orderNo, gameAccount string) (*DeliveryBindResult
}, nil }, nil
} }
func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string) (*DeliverySubmitResult, error) { func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string, auth DeliveryLinkAuth) (*DeliverySubmitResult, error) {
gameAccount = strings.TrimSpace(gameAccount) gameAccount = strings.TrimSpace(gameAccount)
bindUUID = strings.TrimSpace(bindUUID) bindUUID = strings.TrimSpace(bindUUID)
orderNo = strings.TrimSpace(orderNo) orderNo = strings.TrimSpace(orderNo)
if gameAccount == "" || bindUUID == "" { if gameAccount == "" || bindUUID == "" {
return nil, errors.New("玩家编号和绑定凭证不能为空") return nil, errors.New("玩家编号和绑定凭证不能为空")
} }
_, order, goodID, err := s.prepareOrder(orderNo, true) _, order, goodID, err := s.prepareOrder(orderNo, true, &auth)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -186,7 +236,90 @@ func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string) (*Delive
}, nil }, nil
} }
func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*DeliveryOrderInfo, *FulfillmentOrderSnapshot, string, error) { func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
now := time.Now().UTC()
if order.DeliveryLinkRevokedAt != nil {
return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
}
if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) {
return s.buildDeliveryLinkResult(order.OrderNo, order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)), nil
}
canShip, reason := CanFulfill(order)
if !canShip {
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
expiresAt := now.Add(s.linkTTL).UTC().Truncate(time.Second)
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_expires_at": expiresAt,
"delivery_link_revoked_at": nil,
}).Error; err != nil {
return nil, err
}
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt), nil
}
func (s *DeliveryService) RevokeDeliveryLink(merchantID uint, orderNo string) error {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return err
}
now := time.Now().UTC().Truncate(time.Second)
return s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_revoked_at": now,
}).Error
}
func (s *DeliveryService) RestoreDeliveryLink(merchantID uint, orderNo string) (*DeliveryLinkResult, error) {
orderNo = strings.TrimSpace(orderNo)
if orderNo == "" {
return nil, errors.New("订单号不能为空")
}
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
if err != nil {
return nil, err
}
canShip, reason := CanFulfill(order)
if !canShip {
if reason == "" {
reason = "订单暂不可发货"
}
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
}
expiresAt := time.Now().UTC().Add(s.linkTTL).Truncate(time.Second)
if order.DeliveryLinkExpiresAt != nil && expiresAt.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) {
expiresAt = expiresAt.Add(time.Second)
}
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
Where("id = ?", order.ID).
Updates(map[string]interface{}{
"delivery_link_expires_at": expiresAt,
"delivery_link_revoked_at": nil,
}).Error; err != nil {
return nil, err
}
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt), nil
}
func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool, auth *DeliveryLinkAuth) (*DeliveryOrderInfo, *model.FulfillmentOrder, string, error) {
orderNo = strings.TrimSpace(orderNo) orderNo = strings.TrimSpace(orderNo)
if orderNo == "" { if orderNo == "" {
return nil, nil, "", errors.New("订单号不能为空") return nil, nil, "", errors.New("订单号不能为空")
@@ -199,6 +332,11 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*De
if err != nil { if err != nil {
return nil, nil, "", err return nil, nil, "", err
} }
if auth != nil {
if err := s.authorizeDeliveryLink(order, *auth); err != nil {
return nil, nil, "", err
}
}
canShip, reason := CanFulfill(order) canShip, reason := CanFulfill(order)
if !openOrder.CanShip { if !openOrder.CanShip {
reason = openOrder.CannotShipReason reason = openOrder.CannotShipReason
@@ -219,7 +357,7 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*De
} }
} }
if requireCanShip && !canShip { if requireCanShip && !canShip {
return nil, nil, "", errors.New(reason) return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason)
} }
var good map[string]interface{} var good map[string]interface{}
if goodID != "" { if goodID != "" {
@@ -243,11 +381,62 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*De
PayScore: openOrder.PayScore, PayScore: openOrder.PayScore,
Good: good, Good: good,
} }
return info, &FulfillmentOrderSnapshot{ return info, order, goodID, nil
ID: order.ID, }
MerchantID: order.MerchantID,
OrderNo: order.OrderNo, func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error {
}, goodID, nil if order == nil {
return newDeliveryHTTPError(http.StatusNotFound, "订单不存在")
}
if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" {
return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失")
}
if order.DeliveryLinkRevokedAt != nil {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
}
if order.DeliveryLinkExpiresAt == nil {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成")
}
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
linkExpires := time.Unix(auth.Exp, 0).UTC()
if !linkExpires.Equal(expiresAt) {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效")
}
if time.Now().UTC().After(linkExpires) {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期")
}
expected := s.signDeliveryLink(order.OrderNo, auth.Exp)
if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) {
return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效")
}
return nil
}
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time) *DeliveryLinkResult {
expiresAt = expiresAt.UTC()
exp := expiresAt.Unix()
sign := s.signDeliveryLink(orderNo, exp)
return &DeliveryLinkResult{
OrderNo: orderNo,
DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign),
ExpiresAt: expiresAt,
Exp: exp,
Sign: sign,
}
}
func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign string) string {
path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign))
if s.linkBaseURL == "" {
return path
}
return s.linkBaseURL + path
}
func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string {
mac := hmac.New(sha256.New, []byte(s.linkSecret))
_, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10)))
return hex.EncodeToString(mac.Sum(nil))
} }
func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interface{}) *DeliveryProduct { func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interface{}) *DeliveryProduct {
@@ -266,12 +455,6 @@ func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interfa
return product return product
} }
type FulfillmentOrderSnapshot struct {
ID uint
MerchantID uint
OrderNo string
}
func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) { func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) {
var out struct { var out struct {
Good map[string]interface{} `json:"good"` Good map[string]interface{} `json:"good"`
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "delivery-link", 5000, 5, 100)
fulfillmentSvc := NewFulfillmentService(db, nil)
deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 60)
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 1,
ClientOrderNo: "delivery-link-001",
SKU: product.SKU,
})
if err != nil {
t.Fatalf("create order: %v", err)
}
link, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("get delivery link: %v", err)
}
if !strings.Contains(link.DeliveryURL, "exp=") || !strings.Contains(link.DeliveryURL, "sign=") {
t.Fatalf("delivery url should contain exp/sign, got %s", link.DeliveryURL)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err != nil {
t.Fatalf("authorize delivery link: %v", err)
}
if err := deliverySvc.RevokeDeliveryLink(merchantID, created.Order.OrderNo); err != nil {
t.Fatalf("revoke delivery link: %v", err)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
t.Fatalf("revoked link should be rejected")
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
t.Fatalf("expected 403 after revoke, got %v", err)
}
var order model.FulfillmentOrder
if err := db.Where("order_no = ?", created.Order.OrderNo).First(&order).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if order.DeliveryLinkExpiresAt == nil {
t.Fatalf("delivery_link_expires_at should be stored")
}
if order.DeliveryLinkRevokedAt == nil {
t.Fatalf("delivery_link_revoked_at should be stored")
}
restored, err := deliverySvc.RestoreDeliveryLink(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("restore delivery link: %v", err)
}
if restored.Exp == link.Exp || restored.Sign == link.Sign || restored.DeliveryURL == link.DeliveryURL {
t.Fatalf("restored link should rotate exp/sign, old=%+v restored=%+v", link, restored)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
t.Fatalf("old link should stay invalid after restore")
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
t.Fatalf("expected 403 for old link after restore, got %v", err)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: restored.Exp, Sign: restored.Sign}); err != nil {
t.Fatalf("restored link should be accepted: %v", err)
}
}
-3
View File
@@ -6,6 +6,3 @@
# 后端 API 代理目标(见 vite.config.ts # 后端 API 代理目标(见 vite.config.ts
VITE_API_PROXY_TARGET=http://localhost:8080 VITE_API_PROXY_TARGET=http://localhost:8080
# 用户侧发货页面基础地址;为空时按当前前端域名生成
VITE_DELIVERY_BASE_URL=
+26 -11
View File
@@ -8,6 +8,7 @@ import type {
CreateTestOrderResult, CreateTestOrderResult,
DeliveryBindResult, DeliveryBindResult,
DeliveryOrderInfo, DeliveryOrderInfo,
DeliveryLinkResult,
DeliverySubmitResult, DeliverySubmitResult,
FulfillmentOrder, FulfillmentOrder,
LoginResult, LoginResult,
@@ -68,6 +69,12 @@ export const merchantApi = {
note?: string note?: string
fulfillment_status?: 'pending' | 'failed' fulfillment_status?: 'pending' | 'failed'
}) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult), }) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult),
getDeliveryLink: (orderNo: string) =>
request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult),
revokeDeliveryLink: (orderNo: string) =>
request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/revoke`).then((r) => r.data.data),
restoreDeliveryLink: (orderNo: string) =>
request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/restore`).then((r) => r.data.data as DeliveryLinkResult),
wallet: () => wallet: () =>
request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount), request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount),
ledger: (params?: Record<string, unknown>) => ledger: (params?: Record<string, unknown>) =>
@@ -97,17 +104,25 @@ export const merchantApi = {
} }
export const deliveryApi = { export const deliveryApi = {
getOrder: (orderNo: string) => getOrder: (orderNo: string, auth?: { exp?: string; sign?: string }) =>
request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`).then((r) => r.data.data as DeliveryOrderInfo), request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`, { params: auth }).then((r) => r.data.data as DeliveryOrderInfo),
bind: (orderNo: string, gameAccount: string) => bind: (orderNo: string, gameAccount: string, auth?: { exp?: string; sign?: string }) =>
request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, { request.post(
game_account: gameAccount, `/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`,
}).then((r) => r.data.data as DeliveryBindResult), {
submit: (orderNo: string, gameAccount: string, bindUUID: string) => game_account: gameAccount,
request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`, { },
game_account: gameAccount, { params: auth },
bind_uuid: bindUUID, ).then((r) => r.data.data as DeliveryBindResult),
}).then((r) => r.data.data as DeliverySubmitResult), submit: (orderNo: string, gameAccount: string, bindUUID: string, auth?: { exp?: string; sign?: string }) =>
request.post(
`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`,
{
game_account: gameAccount,
bind_uuid: bindUUID,
},
{ params: auth },
).then((r) => r.data.data as DeliverySubmitResult),
} }
export const platformApi = { export const platformApi = {
+11 -5
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useParams } from 'react-router-dom' import { useParams, useSearchParams } from 'react-router-dom'
import { import {
Alert, Alert,
Button, Button,
@@ -35,6 +35,7 @@ const statusMap: Record<string, { color: string; text: string }> = {
export default function Delivery() { export default function Delivery() {
const { channel = 'dlc', orderNo = '' } = useParams() const { channel = 'dlc', orderNo = '' } = useParams()
const [searchParams] = useSearchParams()
const [form] = Form.useForm<{ game_account: string }>() const [form] = Form.useForm<{ game_account: string }>()
const [order, setOrder] = useState<DeliveryOrderInfo | null>(null) const [order, setOrder] = useState<DeliveryOrderInfo | null>(null)
const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null) const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null)
@@ -45,6 +46,11 @@ export default function Delivery() {
const [error, setError] = useState('') const [error, setError] = useState('')
const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo]) const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo])
const deliveryAuth = useMemo(() => {
const exp = searchParams.get('exp') || ''
const sign = searchParams.get('sign') || ''
return exp && sign ? { exp, sign } : undefined
}, [searchParams])
const gameAccount = Form.useWatch('game_account', form) const gameAccount = Form.useWatch('game_account', form)
useEffect(() => { useEffect(() => {
@@ -54,21 +60,21 @@ export default function Delivery() {
return return
} }
setLoading(true) setLoading(true)
deliveryApi.getOrder(decodedOrderNo) deliveryApi.getOrder(decodedOrderNo, deliveryAuth)
.then((data) => { .then((data) => {
setOrder(data) setOrder(data)
setError('') setError('')
}) })
.catch((e) => setError(e instanceof Error ? e.message : '订单加载失败')) .catch((e) => setError(e instanceof Error ? e.message : '订单加载失败'))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [decodedOrderNo]) }, [decodedOrderNo, deliveryAuth])
const bindAccount = async () => { const bindAccount = async () => {
const values = await form.validateFields() const values = await form.validateFields()
setBinding(true) setBinding(true)
setSubmitResult(null) setSubmitResult(null)
try { try {
const result = await deliveryApi.bind(decodedOrderNo, values.game_account) const result = await deliveryApi.bind(decodedOrderNo, values.game_account, deliveryAuth)
setBindResult(result) setBindResult(result)
message.success('绑定二维码已生成') message.success('绑定二维码已生成')
} catch (e) { } catch (e) {
@@ -86,7 +92,7 @@ export default function Delivery() {
} }
setSubmitting(true) setSubmitting(true)
try { try {
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid) const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth)
setSubmitResult(result) setSubmitResult(result)
setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev) setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev)
message.success(result.message || '已提交') message.success(result.message || '已提交')
+74 -27
View File
@@ -76,9 +76,6 @@ const eventOptions = [
{ value: 'order.cancelled', label: '订单取消' }, { value: 'order.cancelled', label: '订单取消' },
] ]
const deliveryBaseUrl = (import.meta.env.VITE_DELIVERY_BASE_URL || window.location.origin).replace(/\/+$/, '')
const deliveryChannel = 'dlc'
const testOrderStatusOptions = [ const testOrderStatusOptions = [
{ value: 'pending', label: '已支付,可发货' }, { value: 'pending', label: '已支付,可发货' },
{ value: 'failed', label: '发货失败,可重试' }, { value: 'failed', label: '发货失败,可重试' },
@@ -125,11 +122,26 @@ export default function MerchantCenter() {
})), [testOrderProducts]) })), [testOrderProducts])
const copyDeliveryLink = useCallback((orderNo: string) => { const copyDeliveryLink = useCallback((orderNo: string) => {
navigator.clipboard.writeText(buildDeliveryLink(orderNo)).then(() => message.success('发货链接已复制')) merchantApi.getDeliveryLink(orderNo)
.then((data) => navigator.clipboard.writeText(data.delivery_url).then(() => message.success('发货链接已复制')))
.catch((e) => message.error(e instanceof Error ? e.message : '复制失败'))
}, []) }, [])
const openDeliveryLink = useCallback((orderNo: string) => { const openDeliveryLink = useCallback((orderNo: string) => {
window.open(buildDeliveryLink(orderNo), '_blank', 'noopener,noreferrer') merchantApi.getDeliveryLink(orderNo)
.then((data) => window.open(data.delivery_url, '_blank', 'noopener,noreferrer'))
.catch((e) => message.error(e instanceof Error ? e.message : '打开失败'))
}, [])
const patchDeliveryLinkState = useCallback((orderNo: string, patch: Partial<FulfillmentOrder>) => {
setOrders((prev) => ({
...prev,
list: prev.list.map((item) => item.order_no === orderNo ? { ...item, ...patch } : item),
}))
setTestOrderResult((prev) => prev?.order.order_no === orderNo ? {
...prev,
order: { ...prev.order, ...patch },
} : prev)
}, []) }, [])
const loadCurrent = useCallback(async () => { const loadCurrent = useCallback(async () => {
@@ -149,6 +161,28 @@ export default function MerchantCenter() {
setOrders(data) setOrders(data)
}, [orders.page, orders.size]) }, [orders.page, orders.size])
const revokeDeliveryLink = useCallback((orderNo: string) => {
merchantApi.revokeDeliveryLink(orderNo)
.then(() => {
const revokedAt = new Date().toISOString()
message.success('发货链接已作废')
patchDeliveryLinkState(orderNo, { delivery_link_revoked_at: revokedAt })
})
.catch((e) => message.error(e instanceof Error ? e.message : '作废失败'))
}, [patchDeliveryLinkState])
const restoreDeliveryLink = useCallback((orderNo: string) => {
merchantApi.restoreDeliveryLink(orderNo)
.then((data) => {
message.success('发货链接已恢复')
patchDeliveryLinkState(orderNo, {
delivery_link_expires_at: data.expires_at,
delivery_link_revoked_at: null,
})
})
.catch((e) => message.error(e instanceof Error ? e.message : '恢复失败'))
}, [patchDeliveryLinkState])
const loadWallet = useCallback(async ( const loadWallet = useCallback(async (
page = ledger.page, page = ledger.page,
size = ledger.size, size = ledger.size,
@@ -435,15 +469,24 @@ export default function MerchantCenter() {
{ title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag }, { title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag },
{ title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag }, { title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag },
{ title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' }, { title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' },
{
title: '链接有效期',
dataIndex: 'delivery_link_expires_at',
width: 160,
render: (_, record) => record.delivery_link_revoked_at ? <Tag color="red"></Tag> : formatTime(record.delivery_link_expires_at),
},
{ title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, { title: '时间', dataIndex: 'created_at', width: 160, render: formatTime },
{ {
title: '发货链接', title: '发货链接',
key: 'delivery_link', key: 'delivery_link',
width: 150, width: 220,
render: (_, record) => ( render: (_, record) => record.delivery_link_revoked_at ? (
<Space size={4}> <Button type="link" size="small" onClick={() => restoreDeliveryLink(record.order_no)}></Button>
) : (
<Space size={4} wrap>
<Button type="link" size="small" onClick={() => copyDeliveryLink(record.order_no)}></Button> <Button type="link" size="small" onClick={() => copyDeliveryLink(record.order_no)}></Button>
<Button type="link" size="small" onClick={() => openDeliveryLink(record.order_no)}></Button> <Button type="link" size="small" onClick={() => openDeliveryLink(record.order_no)}></Button>
<Button type="link" danger size="small" onClick={() => revokeDeliveryLink(record.order_no)}></Button>
</Space> </Space>
), ),
}, },
@@ -552,7 +595,7 @@ export default function MerchantCenter() {
children: ( children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle"> <Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space style={{ width: '100%', justifyContent: 'space-between' }}> <Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary"> UID</Typography.Text> <Typography.Text type="secondary"> UID</Typography.Text>
{canManage && ( {canManage && (
<Space> <Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
@@ -567,7 +610,7 @@ export default function MerchantCenter() {
columns={orderColumns} columns={orderColumns}
dataSource={orders.list} dataSource={orders.list}
tableLayout="fixed" tableLayout="fixed"
scroll={{ x: 1450 }} scroll={{ x: 1500 }}
pagination={pageConfig(orders, loadOrders)} pagination={pageConfig(orders, loadOrders)}
/> />
</Space> </Space>
@@ -748,15 +791,22 @@ export default function MerchantCenter() {
footer={( footer={(
<Space> <Space>
<Button onClick={() => setTestOrderResult(null)}></Button> <Button onClick={() => setTestOrderResult(null)}></Button>
<Button {testOrderResult?.order.delivery_link_revoked_at ? (
icon={<CopyOutlined />} <Button onClick={() => restoreDeliveryLink(testOrderResult?.order.order_no || '')}></Button>
onClick={() => copyDeliveryLink(testOrderResult?.order.order_no || '')} ) : (
> <>
<Button
</Button> icon={<CopyOutlined />}
<Button type="primary" icon={<LinkOutlined />} onClick={() => openDeliveryLink(testOrderResult?.order.order_no || '')}> onClick={() => copyDeliveryLink(testOrderResult?.order.order_no || '')}
>
</Button>
</Button>
<Button type="primary" icon={<LinkOutlined />} onClick={() => openDeliveryLink(testOrderResult?.order.order_no || '')}>
</Button>
<Button danger onClick={() => revokeDeliveryLink(testOrderResult?.order.order_no || '')}></Button>
</>
)}
</Space> </Space>
)} )}
> >
@@ -773,10 +823,11 @@ export default function MerchantCenter() {
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'} {testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="发货链接"> <Descriptions.Item label="链接有效期">
<Typography.Link href={buildDeliveryLink(testOrderResult.order.order_no)} target="_blank" rel="noreferrer"> {testOrderResult.order.delivery_link_revoked_at ? <Tag color="red"></Tag> : formatTime(testOrderResult.order.delivery_link_expires_at)}
{buildDeliveryLink(testOrderResult.order.order_no)} </Descriptions.Item>
</Typography.Link> <Descriptions.Item label="链接状态">
<Typography.Text type="secondary"></Typography.Text>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
@@ -935,10 +986,6 @@ function productOptionLabel(item: MerchantProduct) {
return name === item.sku ? item.sku : `${name} / ${item.sku}` return name === item.sku ? item.sku : `${name} / ${item.sku}`
} }
function buildDeliveryLink(orderNo: string) {
return `${deliveryBaseUrl}/delivery/${deliveryChannel}/${encodeURIComponent(orderNo)}`
}
function paymentStatusTag(value: string) { function paymentStatusTag(value: string) {
const item = paymentStatusMap[value] || { color: 'default', text: value } const item = paymentStatusMap[value] || { color: 'default', text: value }
return <Tag color={item.color}>{item.text}</Tag> return <Tag color={item.color}>{item.text}</Tag>
+10
View File
@@ -116,6 +116,8 @@ export interface FulfillmentOrder {
created_at: string created_at: string
delivered_at?: string | null delivered_at?: string | null
cancelled_at?: string | null cancelled_at?: string | null
delivery_link_expires_at?: string | null
delivery_link_revoked_at?: string | null
} }
export interface CreateTestOrderResult { export interface CreateTestOrderResult {
@@ -164,6 +166,14 @@ export interface DeliverySubmitResult {
upstream_order?: unknown upstream_order?: unknown
} }
export interface DeliveryLinkResult {
order_no: string
delivery_url: string
expires_at: string
exp: number
sign: string
}
export interface ApiClient { export interface ApiClient {
id: number id: number
merchant_id: number merchant_id: number