diff --git a/.env.example b/.env.example index 2d83b8c..ea4b749 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ LOG_FILE=logs/app.log DELIVERY_BASE_URL= DELIVERY_BFF_BASE_URL=https://www.jxya.top/bff-stg DELIVERY_CHANNEL=dlc +DELIVERY_LINK_SECRET=change_me_for_delivery_link_sign +DELIVERY_LINK_TTL_MINUTES=120 # Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源 POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2edc3a1..ff9011e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -54,7 +54,14 @@ func main() { callbackSvc := service.NewCallbackService(db, codec) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) merchantSvc := service.NewMerchantService(db, codec, tenantSvc) - deliverySvc := service.NewDeliveryService(fulfillmentSvc, cfg.DeliveryBFFBaseURL, cfg.DeliveryChannel) + deliverySvc := service.NewDeliveryService( + fulfillmentSvc, + cfg.DeliveryBFFBaseURL, + cfg.DeliveryChannel, + cfg.DeliveryBaseURL, + cfg.DeliveryLinkSecret, + cfg.DeliveryLinkTTLMinutes, + ) if err := authSvc.EnsureAdmin(); err != nil { log.Fatalf("ensure admin: %v", err) @@ -69,7 +76,7 @@ func main() { User: handler.NewUserHandler(userSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc), - Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), + Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc), JWT: jm, Tenant: tenantSvc, OpenDB: db, diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1162e02..85c8ee0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -32,6 +32,10 @@ type Config struct { DeliveryBFFBaseURL string // DeliveryChannel 上游测试发货渠道前缀,如 dlc。 DeliveryChannel string + // DeliveryLinkSecret 发货链接签名密钥。 + DeliveryLinkSecret string + // DeliveryLinkTTLMinutes 发货链接默认有效分钟数。 + DeliveryLinkTTLMinutes int } // Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高) @@ -41,19 +45,21 @@ func Load() *Config { // OPEN_API_DEBUG 优先;未设置时 debug 模式默认开启 debugOpen := getEnvBool("OPEN_API_DEBUG", mode == "debug" || mode == "") return &Config{ - Port: getEnv("PORT", "8080"), - JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), - DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"), - Mode: mode, - DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), - OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), - OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), - OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), - OpenAPIDebug: debugOpen, - LogFile: getEnv("LOG_FILE", "logs/app.log"), - DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""), - DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff-stg"), - DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"), + Port: getEnv("PORT", "8080"), + JWTSecret: getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://affiliate:affiliate_dev_password@127.0.0.1:5432/affiliate_dash?sslmode=disable"), + Mode: mode, + DataEncryptionKey: getEnv("DATA_ENCRYPTION_KEY", getEnv("JWT_SECRET", "affiliate-dash-dev-secret-change-me")), + OpenAPIKey: getEnv("OPEN_API_KEY", "sk_source_dev_key_change_me"), + OpenAPISecret: getEnv("OPEN_API_SECRET", "sk_source_dev_secret_change_me"), + OpenSignSkew: int64(getEnvInt("OPEN_SIGN_SKEW", 300)), + OpenAPIDebug: debugOpen, + LogFile: getEnv("LOG_FILE", "logs/app.log"), + DeliveryBaseURL: getEnv("DELIVERY_BASE_URL", ""), + DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff-stg"), + DeliveryChannel: getEnv("DELIVERY_CHANNEL", "dlc"), + 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), } } diff --git a/backend/internal/database/migrations/004_delivery_links.sql b/backend/internal/database/migrations/004_delivery_links.sql new file mode 100644 index 0000000..8e78d90 --- /dev/null +++ b/backend/internal/database/migrations/004_delivery_links.sql @@ -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); diff --git a/backend/internal/database/migrations/005_delivery_links_revoked.sql b/backend/internal/database/migrations/005_delivery_links_revoked.sql new file mode 100644 index 0000000..b624bc4 --- /dev/null +++ b/backend/internal/database/migrations/005_delivery_links_revoked.sql @@ -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); diff --git a/backend/internal/handler/delivery.go b/backend/internal/handler/delivery.go index ec90231..d4a5444 100644 --- a/backend/internal/handler/delivery.go +++ b/backend/internal/handler/delivery.go @@ -1,6 +1,7 @@ package handler import ( + "strconv" "strings" "affiliate_dash/internal/pkg/response" @@ -19,7 +20,7 @@ func NewDeliveryHandler(deliverySvc *service.DeliveryService) *DeliveryHandler { } 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 { writeDeliveryError(c, err) return @@ -37,7 +38,7 @@ func (h *DeliveryHandler) Bind(c *gin.Context) { response.BadRequest(c, "请输入玩家编号") 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 { writeDeliveryError(c, err) return @@ -56,7 +57,7 @@ func (h *DeliveryHandler) Submit(c *gin.Context) { response.BadRequest(c, "玩家编号和绑定凭证不能为空") 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 { writeDeliveryError(c, err) return @@ -64,11 +65,36 @@ func (h *DeliveryHandler) Submit(c *gin.Context) { 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) { + 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()) if msg == "订单不存在" { response.NotFound(c, msg) return } + if msg == "链接参数缺失" || msg == "发货链接未生成" || msg == "发货链接已失效" || msg == "发货链接已过期" || msg == "发货链接签名无效" { + response.Forbidden(c, msg) + return + } response.BadRequest(c, msg) } diff --git a/backend/internal/handler/merchant.go b/backend/internal/handler/merchant.go index a49b481..286b1cb 100644 --- a/backend/internal/handler/merchant.go +++ b/backend/internal/handler/merchant.go @@ -16,13 +16,15 @@ type MerchantHandler struct { merchantSvc *service.MerchantService fulfillmentSvc *service.FulfillmentService 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{ merchantSvc: merchantSvc, fulfillmentSvc: fulfillmentSvc, 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) { wallet, err := h.fulfillmentSvc.GetWallet(middleware.GetMerchantID(c)) if err != nil { diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index 2468f9b..7509a33 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -196,29 +196,31 @@ type FulfillmentOrder struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - 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"` - 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"` - ProductSKU string `gorm:"size:96;not null" json:"product_sku"` - ProductName string `gorm:"size:160;not null" json:"product_name"` - Quantity int64 `gorm:"not null;default:1" json:"quantity"` - BaseAmount int64 `gorm:"not null;default:0" json:"base_amount"` - FeeType string `gorm:"size:16;not null;default:rate" json:"fee_type"` - FeeRateBP int64 `gorm:"not null;default:0" json:"fee_rate_bp"` - FeeFixedAmount int64 `gorm:"not null;default:0" json:"fee_fixed_amount"` - ServiceFeeAmount int64 `gorm:"not null;default:0" json:"service_fee_amount"` - Amount int64 `gorm:"not null" json:"amount"` - Currency string `gorm:"size:12;not null;default:POINT" json:"currency"` - 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"` - BuyerReference string `gorm:"size:128" json:"buyer_reference"` - RequestData string `gorm:"type:text" json:"request_data"` - ResultData string `gorm:"type:text" json:"result_data"` - ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"` - FailureReason string `gorm:"size:512" json:"failure_reason"` - CancelledAt *time.Time `json:"cancelled_at"` - DeliveredAt *time.Time `json:"delivered_at"` + 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"` + 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"` + ProductSKU string `gorm:"size:96;not null" json:"product_sku"` + ProductName string `gorm:"size:160;not null" json:"product_name"` + Quantity int64 `gorm:"not null;default:1" json:"quantity"` + BaseAmount int64 `gorm:"not null;default:0" json:"base_amount"` + FeeType string `gorm:"size:16;not null;default:rate" json:"fee_type"` + FeeRateBP int64 `gorm:"not null;default:0" json:"fee_rate_bp"` + FeeFixedAmount int64 `gorm:"not null;default:0" json:"fee_fixed_amount"` + ServiceFeeAmount int64 `gorm:"not null;default:0" json:"service_fee_amount"` + Amount int64 `gorm:"not null" json:"amount"` + Currency string `gorm:"size:12;not null;default:POINT" json:"currency"` + 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"` + BuyerReference string `gorm:"size:128" json:"buyer_reference"` + RequestData string `gorm:"type:text" json:"request_data"` + ResultData string `gorm:"type:text" json:"result_data"` + ProviderOrderNo string `gorm:"size:96;index" json:"provider_order_no"` + FailureReason string `gorm:"size:512" json:"failure_reason"` + CancelledAt *time.Time `json:"cancelled_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"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a7028d3..9829275 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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.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.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/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) diff --git a/backend/internal/router/router_test.go b/backend/internal/router/router_test.go index dde9b44..292b57b 100644 --- a/backend/internal/router/router_test.go +++ b/backend/internal/router/router_test.go @@ -24,6 +24,7 @@ func TestSetupDoesNotPanic(t *testing.T) { callbackSvc := service.NewCallbackService(db, codec) fulfillmentSvc := service.NewFulfillmentService(db, callbackSvc) merchantSvc := service.NewMerchantService(db, codec, tenantSvc) + deliverySvc := service.NewDeliveryService(fulfillmentSvc, "", "", "", "", 0) defer func() { if recovered := recover(); recovered != nil { @@ -36,7 +37,7 @@ func TestSetupDoesNotPanic(t *testing.T) { User: handler.NewUserHandler(userSvc), Open: handler.NewOpenV1Handler(merchantSvc, fulfillmentSvc), SourceOpen: handler.NewOpenHandler(fulfillmentSvc), - Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc), + Merchant: handler.NewMerchantHandler(merchantSvc, fulfillmentSvc, callbackSvc, deliverySvc), JWT: jwt.NewManager("test-jwt"), Tenant: tenantSvc, OpenDB: db, diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 602fb4d..9914408 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -2,14 +2,20 @@ package service import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" + "strconv" "strings" "time" + + "affiliate_dash/internal/model" ) const defaultDeliveryBFFBaseURL = "https://www.jxya.top/bff-stg" @@ -18,10 +24,13 @@ type DeliveryService struct { fulfillment *FulfillmentService bffBaseURL string channel string + linkBaseURL string + linkSecret string + linkTTL time.Duration 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), "/") if bffBaseURL == "" { bffBaseURL = defaultDeliveryBFFBaseURL @@ -30,10 +39,21 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel str if channel == "" { 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{ fulfillment: fulfillment, bffBaseURL: bffBaseURL, channel: channel, + linkBaseURL: linkBaseURL, + linkSecret: linkSecret, + linkTTL: time.Duration(linkTTLMinutes) * time.Minute, httpClient: &http.Client{Timeout: 15 * time.Second}, } } @@ -75,6 +95,14 @@ type DeliveryBindResult struct { 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 { OrderNo string `json:"order_no"` Status string `json:"status"` @@ -84,17 +112,39 @@ type DeliverySubmitResult struct { UpstreamOrder interface{} `json:"upstream_order,omitempty"` } -func (s *DeliveryService) GetOrder(orderNo string) (*DeliveryOrderInfo, error) { - info, _, _, err := s.prepareOrder(orderNo, false) +type DeliveryLinkAuth struct { + 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 } -func (s *DeliveryService) Bind(orderNo, gameAccount string) (*DeliveryBindResult, error) { +func (s *DeliveryService) Bind(orderNo, gameAccount string, auth DeliveryLinkAuth) (*DeliveryBindResult, error) { gameAccount = strings.TrimSpace(gameAccount) if gameAccount == "" { return nil, errors.New("请输入玩家编号") } - _, _, goodID, err := s.prepareOrder(orderNo, true) + _, _, goodID, err := s.prepareOrder(orderNo, true, &auth) if err != nil { return nil, err } @@ -120,14 +170,14 @@ func (s *DeliveryService) Bind(orderNo, gameAccount string) (*DeliveryBindResult }, 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) bindUUID = strings.TrimSpace(bindUUID) orderNo = strings.TrimSpace(orderNo) if gameAccount == "" || bindUUID == "" { return nil, errors.New("玩家编号和绑定凭证不能为空") } - _, order, goodID, err := s.prepareOrder(orderNo, true) + _, order, goodID, err := s.prepareOrder(orderNo, true, &auth) if err != nil { return nil, err } @@ -186,7 +236,90 @@ func (s *DeliveryService) Submit(orderNo, gameAccount, bindUUID string) (*Delive }, 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) if orderNo == "" { return nil, nil, "", errors.New("订单号不能为空") @@ -199,6 +332,11 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*De if err != nil { return nil, nil, "", err } + if auth != nil { + if err := s.authorizeDeliveryLink(order, *auth); err != nil { + return nil, nil, "", err + } + } canShip, reason := CanFulfill(order) if !openOrder.CanShip { reason = openOrder.CannotShipReason @@ -219,7 +357,7 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*De } } if requireCanShip && !canShip { - return nil, nil, "", errors.New(reason) + return nil, nil, "", newDeliveryHTTPError(http.StatusBadRequest, reason) } var good map[string]interface{} if goodID != "" { @@ -243,11 +381,62 @@ func (s *DeliveryService) prepareOrder(orderNo string, requireCanShip bool) (*De PayScore: openOrder.PayScore, Good: good, } - return info, &FulfillmentOrderSnapshot{ - ID: order.ID, - MerchantID: order.MerchantID, - OrderNo: order.OrderNo, - }, goodID, nil + return info, order, goodID, nil +} + +func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error { + 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 { @@ -266,12 +455,6 @@ func buildDeliveryProduct(openProduct *OpenOrderProduct, good map[string]interfa return product } -type FulfillmentOrderSnapshot struct { - ID uint - MerchantID uint - OrderNo string -} - func (s *DeliveryService) goodsDetail(goodID string) (map[string]interface{}, error) { var out struct { Good map[string]interface{} `json:"good"` diff --git a/backend/internal/service/delivery_test.go b/backend/internal/service/delivery_test.go new file mode 100644 index 0000000..60df93f --- /dev/null +++ b/backend/internal/service/delivery_test.go @@ -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) + } +} diff --git a/frontend/.env.example b/frontend/.env.example index 440cd19..6bd612a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -6,6 +6,3 @@ # 后端 API 代理目标(见 vite.config.ts) VITE_API_PROXY_TARGET=http://localhost:8080 - -# 用户侧发货页面基础地址;为空时按当前前端域名生成 -VITE_DELIVERY_BASE_URL= diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 566e6cc..4bd1157 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -8,6 +8,7 @@ import type { CreateTestOrderResult, DeliveryBindResult, DeliveryOrderInfo, + DeliveryLinkResult, DeliverySubmitResult, FulfillmentOrder, LoginResult, @@ -68,6 +69,12 @@ export const merchantApi = { note?: string fulfillment_status?: 'pending' | 'failed' }) => 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: () => request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount), ledger: (params?: Record) => @@ -97,17 +104,25 @@ export const merchantApi = { } export const deliveryApi = { - getOrder: (orderNo: string) => - request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`).then((r) => r.data.data as DeliveryOrderInfo), - bind: (orderNo: string, gameAccount: string) => - request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, { - game_account: gameAccount, - }).then((r) => r.data.data as DeliveryBindResult), - submit: (orderNo: string, gameAccount: string, bindUUID: string) => - request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`, { - game_account: gameAccount, - bind_uuid: bindUUID, - }).then((r) => r.data.data as DeliverySubmitResult), + getOrder: (orderNo: string, auth?: { exp?: string; sign?: string }) => + request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`, { params: auth }).then((r) => r.data.data as DeliveryOrderInfo), + bind: (orderNo: string, gameAccount: string, auth?: { exp?: string; sign?: string }) => + request.post( + `/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, + { + game_account: gameAccount, + }, + { params: auth }, + ).then((r) => r.data.data as DeliveryBindResult), + 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 = { diff --git a/frontend/src/pages/Delivery.tsx b/frontend/src/pages/Delivery.tsx index c7fd884..911152e 100644 --- a/frontend/src/pages/Delivery.tsx +++ b/frontend/src/pages/Delivery.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' -import { useParams } from 'react-router-dom' +import { useParams, useSearchParams } from 'react-router-dom' import { Alert, Button, @@ -35,6 +35,7 @@ const statusMap: Record = { export default function Delivery() { const { channel = 'dlc', orderNo = '' } = useParams() + const [searchParams] = useSearchParams() const [form] = Form.useForm<{ game_account: string }>() const [order, setOrder] = useState(null) const [bindResult, setBindResult] = useState(null) @@ -45,6 +46,11 @@ export default function Delivery() { const [error, setError] = useState('') 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) useEffect(() => { @@ -54,21 +60,21 @@ export default function Delivery() { return } setLoading(true) - deliveryApi.getOrder(decodedOrderNo) + deliveryApi.getOrder(decodedOrderNo, deliveryAuth) .then((data) => { setOrder(data) setError('') }) .catch((e) => setError(e instanceof Error ? e.message : '订单加载失败')) .finally(() => setLoading(false)) - }, [decodedOrderNo]) + }, [decodedOrderNo, deliveryAuth]) const bindAccount = async () => { const values = await form.validateFields() setBinding(true) setSubmitResult(null) try { - const result = await deliveryApi.bind(decodedOrderNo, values.game_account) + const result = await deliveryApi.bind(decodedOrderNo, values.game_account, deliveryAuth) setBindResult(result) message.success('绑定二维码已生成') } catch (e) { @@ -86,7 +92,7 @@ export default function Delivery() { } setSubmitting(true) 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) setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev) message.success(result.message || '已提交') diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 6d3cc38..0ef766a 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -76,9 +76,6 @@ const eventOptions = [ { value: 'order.cancelled', label: '订单取消' }, ] -const deliveryBaseUrl = (import.meta.env.VITE_DELIVERY_BASE_URL || window.location.origin).replace(/\/+$/, '') -const deliveryChannel = 'dlc' - const testOrderStatusOptions = [ { value: 'pending', label: '已支付,可发货' }, { value: 'failed', label: '发货失败,可重试' }, @@ -125,11 +122,26 @@ export default function MerchantCenter() { })), [testOrderProducts]) 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) => { - 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) => { + 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 () => { @@ -149,6 +161,28 @@ export default function MerchantCenter() { setOrders(data) }, [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 ( page = ledger.page, size = ledger.size, @@ -435,15 +469,24 @@ export default function MerchantCenter() { { title: '支付', dataIndex: 'payment_status', width: 90, render: paymentStatusTag }, { title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag }, { 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 ? 已作废 : formatTime(record.delivery_link_expires_at), + }, { title: '时间', dataIndex: 'created_at', width: 160, render: formatTime }, { title: '发货链接', key: 'delivery_link', - width: 150, - render: (_, record) => ( - + width: 220, + render: (_, record) => record.delivery_link_revoked_at ? ( + + ) : ( + + ), }, @@ -552,7 +595,7 @@ export default function MerchantCenter() { children: ( - 发货链接会自动携带订单号,用户只填写游戏 UID。 + 发货链接由后端生成并带签名,用户只填写游戏 UID。 {canManage && ( - - + {testOrderResult?.order.delivery_link_revoked_at ? ( + + ) : ( + <> + + + + + )} )} > @@ -773,10 +823,11 @@ export default function MerchantCenter() { {testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'} - - - {buildDeliveryLink(testOrderResult.order.order_no)} - + + {testOrderResult.order.delivery_link_revoked_at ? 已作废 : formatTime(testOrderResult.order.delivery_link_expires_at)} + + + 由后端签名生成,可复制、打开或作废。 )} @@ -935,10 +986,6 @@ function productOptionLabel(item: MerchantProduct) { return name === item.sku ? item.sku : `${name} / ${item.sku}` } -function buildDeliveryLink(orderNo: string) { - return `${deliveryBaseUrl}/delivery/${deliveryChannel}/${encodeURIComponent(orderNo)}` -} - function paymentStatusTag(value: string) { const item = paymentStatusMap[value] || { color: 'default', text: value } return {item.text} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e082689..a81b975 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -116,6 +116,8 @@ export interface FulfillmentOrder { created_at: string delivered_at?: string | null cancelled_at?: string | null + delivery_link_expires_at?: string | null + delivery_link_revoked_at?: string | null } export interface CreateTestOrderResult { @@ -164,6 +166,14 @@ export interface DeliverySubmitResult { upstream_order?: unknown } +export interface DeliveryLinkResult { + order_no: string + delivery_url: string + expires_at: string + exp: number + sign: string +} + export interface ApiClient { id: number merchant_id: number