功能:发货链接支持永久有效,默认 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:
yml2213
2026-08-14 14:26:06 +08:00
parent 11bce9c87e
commit b76d8dc257
11 changed files with 147 additions and 32 deletions
+2 -2
View File
@@ -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);
+2
View File
@@ -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"`
}
+42 -19
View File
@@ -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
}
+17 -5
View File
@@ -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,
+65
View File
@@ -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",