diff --git a/.env.example b/.env.example
index af9449c..407ad20 100644
--- a/.env.example
+++ b/.env.example
@@ -27,7 +27,8 @@ DELIVERY_BASE_URL=
DELIVERY_BFF_BASE_URL=https://www.jxya.top/bff
DELIVERY_CHANNEL=dlc
DELIVERY_LINK_SECRET=change_me_for_delivery_link_sign
-DELIVERY_LINK_TTL_MINUTES=120
+# 发货链接有效期(分钟),0 或留空表示永久有效
+DELIVERY_LINK_TTL_MINUTES=0
FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30
FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60
diff --git a/.gitignore b/.gitignore
index f783724..53fa6a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ frontend/dist/
# reasonix 本地会话数据
.reasonix/
+backend/.gocache
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index e64ee2d..a737a77 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -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),
diff --git a/backend/internal/database/migrations/010_delivery_link_generated_at.sql b/backend/internal/database/migrations/010_delivery_link_generated_at.sql
new file mode 100644
index 0000000..be26d86
--- /dev/null
+++ b/backend/internal/database/migrations/010_delivery_link_generated_at.sql
@@ -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);
diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go
index 5770322..262a668 100644
--- a/backend/internal/model/merchant.go
+++ b/backend/internal/model/merchant.go
@@ -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"`
}
diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go
index cf9c5b0..a8c498c 100644
--- a/backend/internal/service/delivery.go
+++ b/backend/internal/service/delivery.go
@@ -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
}
diff --git a/backend/internal/service/delivery_link.go b/backend/internal/service/delivery_link.go
index 6d07be5..21e1d1f 100644
--- a/backend/internal/service/delivery_link.go
+++ b/backend/internal/service/delivery_link.go
@@ -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,
diff --git a/backend/internal/service/delivery_test.go b/backend/internal/service/delivery_test.go
index 1ea06ed..8619ad3 100644
--- a/backend/internal/service/delivery_test.go
+++ b/backend/internal/service/delivery_test.go
@@ -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",
diff --git a/docker-compose.yml b/docker-compose.yml
index d0e4eeb..b0d1799 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -53,7 +53,7 @@ services:
- DELIVERY_BFF_BASE_URL=${DELIVERY_BFF_BASE_URL:-https://www.jxya.top/bff}
- DELIVERY_CHANNEL=${DELIVERY_CHANNEL:-dlc}
- DELIVERY_LINK_SECRET=${DELIVERY_LINK_SECRET:-}
- - DELIVERY_LINK_TTL_MINUTES=${DELIVERY_LINK_TTL_MINUTES:-120}
+ - DELIVERY_LINK_TTL_MINUTES=${DELIVERY_LINK_TTL_MINUTES:-0}
- FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=${FULFILLMENT_PROCESSING_TIMEOUT_MINUTES:-30}
- FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=${FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS:-60}
- CALLBACK_MAX_ATTEMPTS=${CALLBACK_MAX_ATTEMPTS:-16}
diff --git a/frontend/src/pages/merchantCenterTabs.tsx b/frontend/src/pages/merchantCenterTabs.tsx
index 32fad9e..ad872b8 100644
--- a/frontend/src/pages/merchantCenterTabs.tsx
+++ b/frontend/src/pages/merchantCenterTabs.tsx
@@ -252,7 +252,11 @@ export function OrdersTab({
title: '链接有效期',
dataIndex: 'delivery_link_expires_at',
width: 140,
- render: (_, record) => record.delivery_link_revoked_at ? 已作废 : formatDateTime(record.delivery_link_expires_at),
+ render: (_, record) => {
+ if (record.delivery_link_revoked_at) return 已作废
+ if (!record.delivery_link_expires_at) return 永久有效
+ return formatDateTime(record.delivery_link_expires_at)
+ },
},
{ title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime },
{
@@ -271,8 +275,9 @@ export function OrdersTab({
if (record.delivery_link_revoked_at) {
return 已作废
}
- const expired = !record.delivery_link_expires_at
- || new Date(record.delivery_link_expires_at).getTime() < Date.now()
+ // expires_at 为空表示永久有效,不视为过期
+ const expired = !!record.delivery_link_expires_at
+ && new Date(record.delivery_link_expires_at).getTime() < Date.now()
if (expired) {
return 已过期
}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 6faec57..861dc57 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -143,6 +143,7 @@ export interface FulfillmentOrder {
cancelled_at?: string | null
delivery_link_expires_at?: string | null
delivery_link_revoked_at?: string | null
+ delivery_link_generated_at?: string | null
}
export interface CreateTestOrderResult {
@@ -201,7 +202,7 @@ export interface DeliverySubmitResult {
export interface DeliveryLinkResult {
order_no: string
delivery_url: string
- expires_at: string
+ expires_at: string | null // null 表示永久有效
exp: number
sign: string
}