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 }