完善发货链接签名和作废恢复
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -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 || '已提交')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user