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

This commit is contained in:
yml2213
2026-07-31 11:43:13 +08:00
parent 6ca226ce4b
commit e4c1306216
17 changed files with 527 additions and 109 deletions
+19 -13
View File
@@ -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),
}
}
@@ -0,0 +1,6 @@
-- 为发货链接增加过期时间字段,支持签名短链与手动失效。
ALTER TABLE fulfillment_orders
ADD COLUMN IF NOT EXISTS delivery_link_expires_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_delivery_link_expires_at
ON fulfillment_orders (delivery_link_expires_at);
@@ -0,0 +1,6 @@
-- 为发货链接增加作废时间字段,确保作废后不会自动恢复。
ALTER TABLE fulfillment_orders
ADD COLUMN IF NOT EXISTS delivery_link_revoked_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_delivery_link_revoked_at
ON fulfillment_orders (delivery_link_revoked_at);
+29 -3
View File
@@ -1,6 +1,7 @@
package handler
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)
}
+29 -1
View File
@@ -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 {
+25 -23
View File
@@ -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"`
}
+3
View File
@@ -103,6 +103,9 @@ func Setup(h *Handlers) *gin.Engine {
merchant.PATCH("/products/:id", middleware.RequireMerchantFeature(h.OpenDB, model.MerchantFeatureProducts), middleware.RequireMerchantRole(model.MemberRoleOwner, model.MemberRoleOperator), h.Merchant.UpdateProduct)
merchant.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)
+2 -1
View File
@@ -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,
+203 -20
View File
@@ -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"`
+73
View File
@@ -0,0 +1,73 @@
package service
import (
"strings"
"testing"
"affiliate_dash/internal/model"
)
func TestDeliveryLinkGenerateAuthorizeAndRevoke(t *testing.T) {
db := newServiceTestDB(t)
merchantID, product := seedFulfillmentMerchant(t, db, "delivery-link", 5000, 5, 100)
fulfillmentSvc := NewFulfillmentService(db, nil)
deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 60)
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
MerchantID: merchantID,
APIClientID: 1,
ClientOrderNo: "delivery-link-001",
SKU: product.SKU,
})
if err != nil {
t.Fatalf("create order: %v", err)
}
link, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("get delivery link: %v", err)
}
if !strings.Contains(link.DeliveryURL, "exp=") || !strings.Contains(link.DeliveryURL, "sign=") {
t.Fatalf("delivery url should contain exp/sign, got %s", link.DeliveryURL)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err != nil {
t.Fatalf("authorize delivery link: %v", err)
}
if err := deliverySvc.RevokeDeliveryLink(merchantID, created.Order.OrderNo); err != nil {
t.Fatalf("revoke delivery link: %v", err)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
t.Fatalf("revoked link should be rejected")
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
t.Fatalf("expected 403 after revoke, got %v", err)
}
var order model.FulfillmentOrder
if err := db.Where("order_no = ?", created.Order.OrderNo).First(&order).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if order.DeliveryLinkExpiresAt == nil {
t.Fatalf("delivery_link_expires_at should be stored")
}
if order.DeliveryLinkRevokedAt == nil {
t.Fatalf("delivery_link_revoked_at should be stored")
}
restored, err := deliverySvc.RestoreDeliveryLink(merchantID, created.Order.OrderNo)
if err != nil {
t.Fatalf("restore delivery link: %v", err)
}
if restored.Exp == link.Exp || restored.Sign == link.Sign || restored.DeliveryURL == link.DeliveryURL {
t.Fatalf("restored link should rotate exp/sign, old=%+v restored=%+v", link, restored)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
t.Fatalf("old link should stay invalid after restore")
} else if sc, ok := err.(interface{ HTTPStatus() int }); !ok || sc.HTTPStatus() != 403 {
t.Fatalf("expected 403 for old link after restore, got %v", err)
}
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: restored.Exp, Sign: restored.Sign}); err != nil {
t.Fatalf("restored link should be accepted: %v", err)
}
}