功能:发货链接支持永久有效,默认 TTL=0
- DELIVERY_LINK_TTL_MINUTES 设为 0 或负数时链接永久有效(expires_at 存 NULL), 默认值由 120 改为 0,本地 .env 同步改为永久 - 新增 delivery_link_generated_at 列区分从未生成与永久链接,兼容历史数据 - 永久链接授权仅校验 HMAC 签名、不校验过期(exp=0),作废/恢复语义不变 - 前端链接有效期列对永久链接显示"永久有效"
This commit is contained in:
@@ -36,7 +36,7 @@ type Config struct {
|
||||
DeliveryChannel string
|
||||
// DeliveryLinkSecret 发货链接签名密钥。
|
||||
DeliveryLinkSecret string
|
||||
// DeliveryLinkTTLMinutes 发货链接默认有效分钟数。
|
||||
// DeliveryLinkTTLMinutes 发货链接有效分钟数;<=0 表示永久有效。
|
||||
DeliveryLinkTTLMinutes int
|
||||
// FulfillmentProcessingTimeoutMinutes 发货中订单超过该分钟数自动标记失败;<=0 表示关闭。
|
||||
FulfillmentProcessingTimeoutMinutes int
|
||||
@@ -78,7 +78,7 @@ func Load() *Config {
|
||||
DeliveryBFFBaseURL: getEnv("DELIVERY_BFF_BASE_URL", "https://www.jxya.top/bff"),
|
||||
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),
|
||||
DeliveryLinkTTLMinutes: getEnvInt("DELIVERY_LINK_TTL_MINUTES", 0),
|
||||
FulfillmentProcessingTimeoutMinutes: getEnvInt("FULFILLMENT_PROCESSING_TIMEOUT_MINUTES", 30),
|
||||
FulfillmentTimeoutScanIntervalSeconds: getEnvInt("FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS", 60),
|
||||
CallbackMaxAttempts: getEnvInt("CALLBACK_MAX_ATTEMPTS", 16),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 记录发货链接的生成时间,用于区分"从未生成"与"永久有效链接
|
||||
-- (永久链接 delivery_link_expires_at 为 NULL,需要该列避免与未生成状态混淆)。
|
||||
ALTER TABLE fulfillment_orders ADD COLUMN IF NOT EXISTS delivery_link_generated_at timestamptz;
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_delivery_link_generated_at
|
||||
ON fulfillment_orders (delivery_link_generated_at);
|
||||
@@ -242,6 +242,8 @@ type FulfillmentOrder struct {
|
||||
DeliveredAt *time.Time `json:"delivered_at"`
|
||||
DeliveryLinkExpiresAt *time.Time `json:"delivery_link_expires_at"`
|
||||
DeliveryLinkRevokedAt *time.Time `json:"delivery_link_revoked_at"`
|
||||
// DeliveryLinkGeneratedAt 记录发货链接的生成/恢复时间;expires_at 为 NULL 时表示永久有效。
|
||||
DeliveryLinkGeneratedAt *time.Time `json:"delivery_link_generated_at"`
|
||||
|
||||
MerchantProduct *MerchantProduct `gorm:"foreignKey:MerchantProductID" json:"merchant_product,omitempty"`
|
||||
}
|
||||
|
||||
@@ -73,8 +73,9 @@ func NewDeliveryService(fulfillment *FulfillmentService, bffBaseURL, channel, li
|
||||
if linkSecret == "" {
|
||||
linkSecret = "affiliate-dash-delivery-link-secret"
|
||||
}
|
||||
if linkTTLMinutes <= 0 {
|
||||
linkTTLMinutes = 120
|
||||
// linkTTLMinutes <= 0 表示链接永久有效(0 为默认值)。
|
||||
if linkTTLMinutes < 0 {
|
||||
linkTTLMinutes = 0
|
||||
}
|
||||
return &DeliveryService{
|
||||
fulfillment: fulfillment,
|
||||
@@ -133,11 +134,11 @@ type DeliveryBindResult struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
OrderNo string `json:"order_no"`
|
||||
DeliveryURL string `json:"delivery_url"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"` // null 表示永久有效
|
||||
Exp int64 `json:"exp"`
|
||||
Sign string `json:"sign"`
|
||||
}
|
||||
|
||||
type DeliverySubmitResult struct {
|
||||
@@ -627,7 +628,12 @@ func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requ
|
||||
}
|
||||
// 复用未过期链接:已交付/发货中订单也允许查看兑换结果或进度,仅停止发放新链接。
|
||||
if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) {
|
||||
return s.buildDeliveryLinkResult(order.OrderNo, order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second), requestBaseURL), nil
|
||||
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
|
||||
return s.buildDeliveryLinkResult(order.OrderNo, &expiresAt, requestBaseURL), nil
|
||||
}
|
||||
// 永久有效链接(已生成、未作废、无有效期)直接复用,不重复生成。
|
||||
if order.DeliveryLinkGeneratedAt != nil && order.DeliveryLinkExpiresAt == nil {
|
||||
return s.buildDeliveryLinkResult(order.OrderNo, nil, requestBaseURL), nil
|
||||
}
|
||||
// 仅待发货(含可重试的发货失败)订单能生成或续期新链接。
|
||||
canShip, reason := CanFulfill(order)
|
||||
@@ -637,12 +643,17 @@ func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requ
|
||||
}
|
||||
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
|
||||
}
|
||||
expiresAt := now.Add(s.linkTTL).UTC().Truncate(time.Second)
|
||||
var expiresAt *time.Time
|
||||
if s.linkTTL > 0 {
|
||||
value := now.Add(s.linkTTL).UTC().Truncate(time.Second)
|
||||
expiresAt = &value
|
||||
}
|
||||
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,
|
||||
"delivery_link_expires_at": expiresAt,
|
||||
"delivery_link_revoked_at": nil,
|
||||
"delivery_link_generated_at": now,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -689,22 +700,34 @@ func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orde
|
||||
}
|
||||
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)
|
||||
now := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if s.linkTTL > 0 {
|
||||
value := now.Add(s.linkTTL).Truncate(time.Second)
|
||||
if order.DeliveryLinkExpiresAt != nil && value.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) {
|
||||
value = value.Add(time.Second)
|
||||
}
|
||||
expiresAt = &value
|
||||
}
|
||||
if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.FulfillmentOrder{}).
|
||||
Where("id = ?", order.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"delivery_link_expires_at": expiresAt,
|
||||
"delivery_link_revoked_at": nil,
|
||||
"delivery_link_expires_at": expiresAt,
|
||||
"delivery_link_revoked_at": nil,
|
||||
"delivery_link_generated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
||||
"expires_at": timeutil.FormatAPITime(expiresAt),
|
||||
})
|
||||
metadata := map[string]interface{}{
|
||||
"generated_at": timeutil.FormatAPITime(now),
|
||||
}
|
||||
if expiresAt != nil {
|
||||
metadata["expires_at"] = timeutil.FormatAPITime(*expiresAt)
|
||||
} else {
|
||||
metadata["permanent"] = true
|
||||
}
|
||||
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, metadata)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, a
|
||||
if order == nil {
|
||||
return newDeliveryHTTPError(http.StatusNotFound, "订单不存在")
|
||||
}
|
||||
if auth.Exp <= 0 || strings.TrimSpace(auth.Sign) == "" {
|
||||
if strings.TrimSpace(auth.Sign) == "" {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失")
|
||||
}
|
||||
if order.DeliveryLinkRevokedAt != nil {
|
||||
@@ -28,9 +28,18 @@ func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, a
|
||||
if normalizeOrderStatus(order) == model.OrderStatusCancelled {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "订单已取消,发货链接不可用")
|
||||
}
|
||||
if order.DeliveryLinkExpiresAt == nil {
|
||||
// 从未生成过链接(兼容历史数据:本次改动前生成的有时效链接没有 generated_at 记录)。
|
||||
if order.DeliveryLinkGeneratedAt == nil && order.DeliveryLinkExpiresAt == nil {
|
||||
return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成")
|
||||
}
|
||||
// 永久有效链接(expires_at 为 NULL):仅校验签名(exp 固定为 0),不检查过期。
|
||||
if order.DeliveryLinkExpiresAt == nil {
|
||||
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
|
||||
}
|
||||
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
|
||||
linkExpires := time.Unix(auth.Exp, 0).UTC()
|
||||
if !linkExpires.Equal(expiresAt) {
|
||||
@@ -46,9 +55,12 @@ func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt time.Time, requestBaseURL string) *DeliveryLinkResult {
|
||||
expiresAt = expiresAt.UTC()
|
||||
exp := expiresAt.Unix()
|
||||
// buildDeliveryLinkResult 组装链接结果;expiresAt 为 nil 表示永久有效(exp 固定为 0)。
|
||||
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt *time.Time, requestBaseURL string) *DeliveryLinkResult {
|
||||
exp := int64(0)
|
||||
if expiresAt != nil {
|
||||
exp = expiresAt.UTC().Unix()
|
||||
}
|
||||
sign := s.signDeliveryLink(orderNo, exp)
|
||||
return &DeliveryLinkResult{
|
||||
OrderNo: orderNo,
|
||||
|
||||
@@ -179,6 +179,71 @@ func TestDeliveryLinkAfterDeliveredReusesButNeverGenerates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryLinkPermanentWhenTTLZero(t *testing.T) {
|
||||
db := newServiceTestDB(t)
|
||||
merchantID, product := seedFulfillmentMerchant(t, db, "delivery-permanent", 5000, 5, 100)
|
||||
fulfillmentSvc := NewFulfillmentService(db, nil)
|
||||
deliverySvc := NewDeliveryService(fulfillmentSvc, "https://bff.example", "dlc", "https://shop.example", "link-secret", 0)
|
||||
|
||||
created, err := fulfillmentSvc.CreateOrder(CreateFulfillmentOrderInput{
|
||||
MerchantID: merchantID,
|
||||
APIClientID: 1,
|
||||
ClientOrderNo: "delivery-permanent-001",
|
||||
SKU: product.SKU,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
link, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo, "https://shop.example")
|
||||
if err != nil {
|
||||
t.Fatalf("get delivery link: %v", err)
|
||||
}
|
||||
if link.ExpiresAt != nil || link.Exp != 0 {
|
||||
t.Fatalf("permanent link should have nil expires_at and exp=0, got %+v", link)
|
||||
}
|
||||
// 永久链接授权通过(不校验过期)。
|
||||
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err != nil {
|
||||
t.Fatalf("permanent link should authorize: %v", err)
|
||||
}
|
||||
// 数据库中 expires_at 为 NULL、generated_at 已记录。
|
||||
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("permanent link should store NULL expires_at, got %v", *order.DeliveryLinkExpiresAt)
|
||||
}
|
||||
if order.DeliveryLinkGeneratedAt == nil {
|
||||
t.Fatalf("permanent link should record generated_at")
|
||||
}
|
||||
// 再次获取直接复用,URL 与签名保持稳定。
|
||||
reused, err := deliverySvc.GetOrCreateDeliveryLink(merchantID, created.Order.OrderNo, "https://shop.example")
|
||||
if err != nil {
|
||||
t.Fatalf("reuse permanent link: %v", err)
|
||||
}
|
||||
if reused.DeliveryURL != link.DeliveryURL || reused.Exp != link.Exp {
|
||||
t.Fatalf("permanent link should be stable, got %+v", reused)
|
||||
}
|
||||
// 作废后永久链接立即失效。
|
||||
if err := deliverySvc.RevokeDeliveryLink(merchantID, 1, created.Order.OrderNo); err != nil {
|
||||
t.Fatalf("revoke: %v", err)
|
||||
}
|
||||
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: link.Exp, Sign: link.Sign}); err == nil {
|
||||
t.Fatalf("revoked permanent link should be rejected")
|
||||
}
|
||||
// 恢复后重新变为永久有效。
|
||||
restored, err := deliverySvc.RestoreDeliveryLink(merchantID, 1, created.Order.OrderNo, "https://shop.example")
|
||||
if err != nil {
|
||||
t.Fatalf("restore permanent link: %v", err)
|
||||
}
|
||||
if restored.ExpiresAt != nil {
|
||||
t.Fatalf("restored permanent link should keep nil expires_at, got %v", restored.ExpiresAt)
|
||||
}
|
||||
if _, err := deliverySvc.GetOrder(created.Order.OrderNo, DeliveryLinkAuth{Exp: restored.Exp, Sign: restored.Sign}); err != nil {
|
||||
t.Fatalf("restored permanent link should authorize: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryGoodIDIncludesMedalAndCoinProducts(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"honor_medal_x2": "682ef39ca8f40c4234c59f45",
|
||||
|
||||
Reference in New Issue
Block a user