From 677bdbbc1ca3abb84c244837cb41c3ba72c33f7e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 31 Jul 2026 16:28:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=A2=E5=8D=95=E5=B1=A5?= =?UTF-8?q?=E7=BA=A6=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + README.md | 12 ++ backend/cmd/server/main.go | 5 + backend/internal/config/config.go | 36 ++-- .../006_order_request_fingerprint.sql | 5 + backend/internal/model/merchant.go | 1 + backend/internal/service/delivery.go | 2 +- backend/internal/service/fulfillment.go | 203 ++++++++++++++---- backend/internal/service/fulfillment_state.go | 131 +++++++++++ backend/internal/service/fulfillment_test.go | 136 ++++++++++++ docker-compose.yml | 7 + 11 files changed, 488 insertions(+), 52 deletions(-) create mode 100644 backend/internal/database/migrations/006_order_request_fingerprint.sql create mode 100644 backend/internal/service/fulfillment_state.go diff --git a/.env.example b/.env.example index 36f1247..51c1a15 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,8 @@ DELIVERY_BFF_BASE_URL=https://www.jxya.top/bff-stg DELIVERY_CHANNEL=dlc DELIVERY_LINK_SECRET=change_me_for_delivery_link_sign DELIVERY_LINK_TTL_MINUTES=120 +FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30 +FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60 # Docker 构建基础镜像;如镜像源不可用,可改为官方镜像或你的私有镜像源 POSTGRES_IMAGE=docker.m.daocloud.io/library/postgres:16-alpine diff --git a/README.md b/README.md index e21fadb..032561e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ make docker-down # 停止并移除 | `OPEN_SIGN_SKEW` | 签名时间戳偏差(秒) | `300` | | `OPEN_API_DEBUG` | 开放接口调试日志 | debug 模式默认开启 | | `LOG_FILE` | 日志文件路径 | `logs/app.log` | +| `FULFILLMENT_PROCESSING_TIMEOUT_MINUTES` | 履约中订单自动标记失败的超时分钟数,<=0 关闭 | `30` | +| `FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS` | 履约超时巡检间隔秒数 | `60` | ## 开放接口(皮肤源头对接) @@ -122,6 +124,16 @@ make docker-down # 停止并移除 源头侧 `ship_notify` 当前只使用 `success` / `failed`,速查见 [`docs/发货通知约定.md`](docs/发货通知约定.md),完整关系图见 [`docs/API对接关系.md`](docs/API对接关系.md)。两套 API 的签名算法不同,不能混用鉴权头或签名串。 +### 状态速查 + +| 内部支付状态 | 内部履约状态 | 源头侧状态 | 含义 | +|--------------|--------------|------------|------| +| `paid` | `pending` | `paid` | 已付款,待发货 | +| `paid` | `processing` | `delivering` | 已提交履约,等待结果 | +| `paid` | `succeeded` | `delivered` | 履约成功 | +| `paid` | `failed` | `ship_failed` | 履约失败,可重试 | +| `refunded` | `cancelled` | `cancelled` | 已取消并退款 | + 上游皮肤源头系统调用源头侧接口时,需携带签名头 `X-Api-Key`、`X-Timestamp`、`X-Nonce`、`X-Sign`。 ### 1. 查询订单(发货前置) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 609af12..c12d87b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -91,6 +91,11 @@ func main() { } go callbackSvc.Run(context.Background()) + go fulfillmentSvc.RunProcessingTimeoutMonitor( + context.Background(), + time.Duration(cfg.FulfillmentProcessingTimeoutMinutes)*time.Minute, + time.Duration(cfg.FulfillmentTimeoutScanIntervalSeconds)*time.Second, + ) r := router.Setup(h) addr := ":" + cfg.Port diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2678d70..406d9a5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -36,6 +36,10 @@ type Config struct { DeliveryLinkSecret string // DeliveryLinkTTLMinutes 发货链接默认有效分钟数。 DeliveryLinkTTLMinutes int + // FulfillmentProcessingTimeoutMinutes 履约中订单超过该分钟数自动标记失败;<=0 表示关闭。 + FulfillmentProcessingTimeoutMinutes int + // FulfillmentTimeoutScanIntervalSeconds 履约超时巡检间隔秒数。 + FulfillmentTimeoutScanIntervalSeconds int } // Load 加载配置:先尝试读取 .env,再读系统环境变量(已存在的系统环境变量优先级更高) @@ -45,21 +49,23 @@ 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&TimeZone=Asia/Shanghai"), - 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), + 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&TimeZone=Asia/Shanghai"), + 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), + FulfillmentProcessingTimeoutMinutes: getEnvInt("FULFILLMENT_PROCESSING_TIMEOUT_MINUTES", 30), + FulfillmentTimeoutScanIntervalSeconds: getEnvInt("FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS", 60), } } diff --git a/backend/internal/database/migrations/006_order_request_fingerprint.sql b/backend/internal/database/migrations/006_order_request_fingerprint.sql new file mode 100644 index 0000000..4866221 --- /dev/null +++ b/backend/internal/database/migrations/006_order_request_fingerprint.sql @@ -0,0 +1,5 @@ +ALTER TABLE fulfillment_orders + ADD COLUMN IF NOT EXISTS request_fingerprint VARCHAR(64) NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_fulfillment_orders_request_fingerprint + ON fulfillment_orders (request_fingerprint); diff --git a/backend/internal/model/merchant.go b/backend/internal/model/merchant.go index 7509a33..8b2ac19 100644 --- a/backend/internal/model/merchant.go +++ b/backend/internal/model/merchant.go @@ -213,6 +213,7 @@ type FulfillmentOrder struct { 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"` + RequestFingerprint string `gorm:"size:64;not null;default:'';index" json:"-"` 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"` diff --git a/backend/internal/service/delivery.go b/backend/internal/service/delivery.go index 70e5f77..133be09 100644 --- a/backend/internal/service/delivery.go +++ b/backend/internal/service/delivery.go @@ -345,7 +345,7 @@ func (s *DeliveryService) claimDeliverySubmission(merchantID, apiClientID uint, updated = order return nil } - if order.FulfillmentStatus != model.FulfillmentStatusPending && order.FulfillmentStatus != model.FulfillmentStatusFailed { + if err := validateFulfillmentTransition(&order, model.FulfillmentStatusProcessing, fulfillmentTransitionUpdate); err != nil { return newDeliveryHTTPError(http.StatusConflict, "订单暂不可发货") } raw, _ := json.Marshal(map[string]interface{}{ diff --git a/backend/internal/service/fulfillment.go b/backend/internal/service/fulfillment.go index 988a0d7..bca0cef 100644 --- a/backend/internal/service/fulfillment.go +++ b/backend/internal/service/fulfillment.go @@ -1,9 +1,11 @@ package service import ( + "context" "encoding/json" "errors" "fmt" + "log" "math" "strings" "time" @@ -76,12 +78,16 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat } requestData = string(raw) } + fingerprint := orderRequestFingerprint(in, requestData) result := &CreateFulfillmentOrderResult{} err := s.db.Transaction(func(tx *gorm.DB) error { var existing model.FulfillmentOrder err := tx.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error if err == nil { + if err := ensureSameIdempotentOrder(&existing, fingerprint); err != nil { + return err + } result.Order = &existing result.Idempotent = true return nil @@ -139,24 +145,25 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat } order := &model.FulfillmentOrder{ - MerchantID: in.MerchantID, - OrderNo: newFulfillmentOrderNo(), - ClientOrderNo: in.ClientOrderNo, - MerchantProductID: product.ID, - ProductSKU: product.SKU, - ProductName: fallbackName(product.DisplayName, product.Product.Name), - Quantity: in.Quantity, - BaseAmount: baseAmount, - FeeType: merchant.FeeType, - FeeRateBP: merchant.FeeRateBP, - FeeFixedAmount: merchant.FeeFixedAmount, - ServiceFeeAmount: serviceFee, - Amount: totalAmount, - Currency: product.Currency, - PaymentStatus: model.PaymentStatusPaid, - FulfillmentStatus: model.FulfillmentStatusPending, - BuyerReference: in.BuyerReference, - RequestData: requestData, + MerchantID: in.MerchantID, + OrderNo: newFulfillmentOrderNo(), + ClientOrderNo: in.ClientOrderNo, + MerchantProductID: product.ID, + ProductSKU: product.SKU, + ProductName: fallbackName(product.DisplayName, product.Product.Name), + Quantity: in.Quantity, + BaseAmount: baseAmount, + FeeType: merchant.FeeType, + FeeRateBP: merchant.FeeRateBP, + FeeFixedAmount: merchant.FeeFixedAmount, + ServiceFeeAmount: serviceFee, + Amount: totalAmount, + Currency: product.Currency, + PaymentStatus: model.PaymentStatusPaid, + FulfillmentStatus: model.FulfillmentStatusPending, + BuyerReference: in.BuyerReference, + RequestFingerprint: fingerprint, + RequestData: requestData, } if err := tx.Create(order).Error; err != nil { return err @@ -198,6 +205,9 @@ func (s *FulfillmentService) CreateOrder(in CreateFulfillmentOrderInput) (*Creat if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "UNIQUE") { var existing model.FulfillmentOrder if queryErr := s.db.Where("merchant_id = ? AND client_order_no = ?", in.MerchantID, in.ClientOrderNo).First(&existing).Error; queryErr == nil { + if sameErr := ensureSameIdempotentOrder(&existing, fingerprint); sameErr != nil { + return nil, sameErr + } return &CreateFulfillmentOrderResult{Order: &existing, Idempotent: true}, nil } } @@ -377,18 +387,12 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode } return err } - if order.FulfillmentStatus == model.FulfillmentStatusCancelled { - return errors.New("订单已取消,不能更新履约状态") - } - if order.PaymentStatus != model.PaymentStatusPaid { - return errors.New("订单未支付,不能履约") - } if order.FulfillmentStatus == model.FulfillmentStatusSucceeded && in.Status == model.FulfillmentStatusSucceeded { out = order return nil } - if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { - return errors.New("订单已履约成功,不能回退状态") + if err := validateFulfillmentTransition(&order, in.Status, fulfillmentTransitionUpdate); err != nil { + return err } now := time.Now() updates := map[string]interface{}{ @@ -427,6 +431,108 @@ func (s *FulfillmentService) UpdateFulfillment(in FulfillmentUpdateInput) (*mode return &out, nil } +func (s *FulfillmentService) MarkProcessingTimeouts(timeout time.Duration, limit int) (int, error) { + if timeout <= 0 { + return 0, nil + } + if limit <= 0 || limit > 100 { + limit = 50 + } + now := time.Now() + cutoff := now.Add(-timeout) + var ids []uint + if err := s.db.Model(&model.FulfillmentOrder{}). + Where("payment_status = ? AND fulfillment_status = ? AND updated_at < ?", model.PaymentStatusPaid, model.FulfillmentStatusProcessing, cutoff). + Order("updated_at ASC, id ASC"). + Limit(limit). + Pluck("id", &ids).Error; err != nil { + return 0, err + } + changed := 0 + for _, id := range ids { + updated, err := s.markProcessingTimeout(id, timeout, now) + if err != nil { + return changed, err + } + if updated { + changed++ + } + } + return changed, nil +} + +func (s *FulfillmentService) markProcessingTimeout(id uint, timeout time.Duration, now time.Time) (bool, error) { + returned := false + err := s.db.Transaction(func(tx *gorm.DB) error { + var order model.FulfillmentOrder + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&order, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if !processingTimedOut(&order, timeout, now) { + return nil + } + if err := validateFulfillmentTransition(&order, model.FulfillmentStatusFailed, fulfillmentTransitionTimeout); err != nil { + return nil + } + reason := fmt.Sprintf("履约超时:订单已处于 processing 超过 %d 分钟", int(timeout.Minutes())) + updates := map[string]interface{}{ + "fulfillment_status": model.FulfillmentStatusFailed, + "failure_reason": reason, + "result_data": buildProcessingTimeoutResultData(order.ResultData, timeout, now), + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + var out model.FulfillmentOrder + if err := tx.First(&out, order.ID).Error; err != nil { + return err + } + if err := writeAudit(tx, &order.MerchantID, nil, nil, "fulfillment.timeout", "fulfillment_order", order.OrderNo, map[string]interface{}{ + "from": order.FulfillmentStatus, + "to": model.FulfillmentStatusFailed, + "timeout_minutes": int(timeout.Minutes()), + "reason": reason, + }); err != nil { + return err + } + if s.callbacks != nil { + if err := s.callbacks.Enqueue(tx, order.MerchantID, "order.fulfillment.updated", orderCallbackData(&out)); err != nil { + return err + } + } + returned = true + return nil + }) + return returned, err +} + +func (s *FulfillmentService) RunProcessingTimeoutMonitor(ctx context.Context, timeout, interval time.Duration) { + if timeout <= 0 { + return + } + if interval <= 0 { + interval = time.Minute + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + changed, err := s.MarkProcessingTimeouts(timeout, 50) + if err != nil { + log.Printf("[fulfillment] timeout scan error: %v", err) + } else if changed > 0 { + log.Printf("[fulfillment] timeout scan marked failed count=%d", changed) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, reason string) (*model.FulfillmentOrder, error) { var out model.FulfillmentOrder err := s.db.Transaction(func(tx *gorm.DB) error { @@ -443,8 +549,8 @@ func (s *FulfillmentService) CancelOrder(merchantID, apiClientID uint, orderNo, out = order return nil } - if order.FulfillmentStatus == model.FulfillmentStatusProcessing || order.FulfillmentStatus == model.FulfillmentStatusSucceeded { - return errors.New("订单已进入履约流程,不能取消") + if err := canCancelOrder(&order); err != nil { + return err } now := time.Now() updates := map[string]interface{}{ @@ -1030,13 +1136,15 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe var message string switch in.ShipStatus { case "success": - if order.FulfillmentStatus != model.FulfillmentStatusPending && - order.FulfillmentStatus != model.FulfillmentStatusFailed && - order.FulfillmentStatus != model.FulfillmentStatusProcessing { - if err := writeShipNotifyRejectedAudit(tx, &order, in, "当前状态不允许标记发货成功"); err != nil { + if err := validateFulfillmentTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { + message := "当前状态不允许标记发货成功" + if order.PaymentStatus != model.PaymentStatusPaid { + message = "订单未支付,拒绝成功推送" + } + if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { return err } - rejectionErr = fmt.Errorf("当前状态 %s 不允许标记发货成功", order.FulfillmentStatus) + rejectionErr = err return nil } updates["delivered_at"] = shippedAt @@ -1046,11 +1154,17 @@ func (s *FulfillmentService) HandleShipNotify(in ShipNotifyInput) (*ShipNotifyRe } message = "发货成功,订单已交付" case "failed": - if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { - if err := writeShipNotifyRejectedAudit(tx, &order, in, "订单已交付,拒绝失败推送"); err != nil { + if err := validateFulfillmentTransition(&order, nextStatus, fulfillmentTransitionShipNotify); err != nil { + message := "当前状态不允许标记发货失败" + if order.FulfillmentStatus == model.FulfillmentStatusSucceeded { + message = "订单已交付,拒绝失败推送" + } else if order.PaymentStatus != model.PaymentStatusPaid { + message = "订单未支付,拒绝失败推送" + } + if err := writeShipNotifyRejectedAudit(tx, &order, in, message); err != nil { return err } - rejectionErr = errors.New("订单已交付,不能标记发货失败") + rejectionErr = err return nil } updates["failure_reason"] = in.FailReason @@ -1150,6 +1264,23 @@ func buildShipNotifyResultData(existing string, in ShipNotifyInput, shippedAt *t return string(raw) } +func buildProcessingTimeoutResultData(existing string, timeout time.Duration, now time.Time) string { + m := map[string]interface{}{} + if existing != "" && json.Valid([]byte(existing)) { + _ = json.Unmarshal([]byte(existing), &m) + } + m["timeout"] = true + m["timeout_minutes"] = int(timeout.Minutes()) + m["timeout_at"] = timeutil.FormatAPITime(now) + m["ship_status"] = "failed" + m["fail_reason"] = fmt.Sprintf("履约超时:订单已处于 processing 超过 %d 分钟", int(timeout.Minutes())) + raw, err := json.Marshal(m) + if err != nil { + return existing + } + return string(raw) +} + // extractGameFields 从 JSON 文本中还原游戏相关字段(仅填充当前为空的字段)。 func extractGameFields(raw string, out *OpenOrderQuery) { if raw == "" || !json.Valid([]byte(raw)) { diff --git a/backend/internal/service/fulfillment_state.go b/backend/internal/service/fulfillment_state.go new file mode 100644 index 0000000..021a71f --- /dev/null +++ b/backend/internal/service/fulfillment_state.go @@ -0,0 +1,131 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "time" + + "affiliate_dash/internal/model" +) + +type fulfillmentTransitionKind string + +const ( + fulfillmentTransitionUpdate fulfillmentTransitionKind = "update" + fulfillmentTransitionShipNotify fulfillmentTransitionKind = "ship_notify" + fulfillmentTransitionTimeout fulfillmentTransitionKind = "timeout" +) + +type orderRequestFingerprintPayload struct { + ClientOrderNo string `json:"client_order_no"` + SKU string `json:"sku"` + Quantity int64 `json:"quantity"` + BuyerReference string `json:"buyer_reference"` + RequestData string `json:"request_data"` +} + +func orderRequestFingerprint(in CreateFulfillmentOrderInput, requestData string) string { + raw, _ := json.Marshal(orderRequestFingerprintPayload{ + ClientOrderNo: in.ClientOrderNo, + SKU: in.SKU, + Quantity: in.Quantity, + BuyerReference: in.BuyerReference, + RequestData: requestData, + }) + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func storedOrderRequestFingerprint(order *model.FulfillmentOrder) string { + if order == nil { + return "" + } + if order.RequestFingerprint != "" { + return order.RequestFingerprint + } + return orderRequestFingerprint(CreateFulfillmentOrderInput{ + ClientOrderNo: order.ClientOrderNo, + SKU: order.ProductSKU, + Quantity: order.Quantity, + BuyerReference: order.BuyerReference, + }, order.RequestData) +} + +func ensureSameIdempotentOrder(existing *model.FulfillmentOrder, fingerprint string) error { + if existing == nil { + return errors.New("订单不存在") + } + if storedOrderRequestFingerprint(existing) != fingerprint { + return errors.New("client_order_no 已存在,但本次请求参数与原订单不一致") + } + return nil +} + +func validateFulfillmentTransition(order *model.FulfillmentOrder, next string, kind fulfillmentTransitionKind) error { + if order == nil { + return errors.New("订单不存在") + } + if order.PaymentStatus != model.PaymentStatusPaid { + return errors.New("订单未支付,不能履约") + } + if order.FulfillmentStatus == next { + return nil + } + switch order.FulfillmentStatus { + case model.FulfillmentStatusCancelled: + return errors.New("订单已取消,不能更新履约状态") + case model.FulfillmentStatusSucceeded: + return errors.New("订单已履约成功,不能回退状态") + } + switch next { + case model.FulfillmentStatusProcessing: + if order.FulfillmentStatus == model.FulfillmentStatusPending || order.FulfillmentStatus == model.FulfillmentStatusFailed { + return nil + } + case model.FulfillmentStatusSucceeded: + if order.FulfillmentStatus == model.FulfillmentStatusPending || + order.FulfillmentStatus == model.FulfillmentStatusFailed || + order.FulfillmentStatus == model.FulfillmentStatusProcessing { + return nil + } + case model.FulfillmentStatusFailed: + if kind == fulfillmentTransitionTimeout && order.FulfillmentStatus != model.FulfillmentStatusProcessing { + return errors.New("只有履约中的订单可以标记超时") + } + if order.FulfillmentStatus == model.FulfillmentStatusPending || + order.FulfillmentStatus == model.FulfillmentStatusFailed || + order.FulfillmentStatus == model.FulfillmentStatusProcessing { + return nil + } + } + return errors.New("订单当前状态不允许该履约变更") +} + +func canCancelOrder(order *model.FulfillmentOrder) error { + if order == nil { + return errors.New("订单不存在") + } + if order.FulfillmentStatus == model.FulfillmentStatusCancelled { + return nil + } + if order.PaymentStatus != model.PaymentStatusPaid { + return errors.New("订单未支付或已退款,不能取消") + } + switch order.FulfillmentStatus { + case model.FulfillmentStatusPending, model.FulfillmentStatusFailed: + return nil + case model.FulfillmentStatusProcessing, model.FulfillmentStatusSucceeded: + return errors.New("订单已进入履约流程,不能取消") + default: + return errors.New("订单当前状态不能取消") + } +} + +func processingTimedOut(order *model.FulfillmentOrder, timeout time.Duration, now time.Time) bool { + if order == nil || timeout <= 0 { + return false + } + return order.FulfillmentStatus == model.FulfillmentStatusProcessing && order.UpdatedAt.Before(now.Add(-timeout)) +} diff --git a/backend/internal/service/fulfillment_test.go b/backend/internal/service/fulfillment_test.go index 2b12cf9..6372906 100644 --- a/backend/internal/service/fulfillment_test.go +++ b/backend/internal/service/fulfillment_test.go @@ -3,6 +3,7 @@ package service import ( "strings" "testing" + "time" "affiliate_dash/internal/model" "affiliate_dash/internal/testdb" @@ -89,6 +90,9 @@ func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) { ClientOrderNo: "client-001", SKU: product.SKU, Quantity: 2, + RequestData: map[string]string{ + "account": "player-1", + }, }) if err != nil { t.Fatalf("idempotent create: %v", err) @@ -118,6 +122,53 @@ func TestFulfillmentCreateOrderDebitsWalletAndIsIdempotent(t *testing.T) { } } +func TestFulfillmentCreateOrderRejectsIdempotencyMismatch(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-idempotency-mismatch", 1000, 5, 200) + svc := NewFulfillmentService(db, nil) + + first, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 11, + ClientOrderNo: "client-mismatch", + SKU: product.SKU, + Quantity: 1, + BuyerReference: "buyer-a", + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + _, err = svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 11, + ClientOrderNo: "client-mismatch", + SKU: product.SKU, + Quantity: 2, + BuyerReference: "buyer-a", + }) + if err == nil || !strings.Contains(err.Error(), "请求参数与原订单不一致") { + t.Fatalf("expected idempotency mismatch, got %v", err) + } + + var wallet model.WalletAccount + if err := db.Where("merchant_id = ?", merchantID).First(&wallet).Error; err != nil { + t.Fatalf("query wallet: %v", err) + } + if wallet.AvailableBalance != 800 { + t.Fatalf("wallet should debit first order only, got %d", wallet.AvailableBalance) + } + var refreshed model.MerchantProduct + if err := db.First(&refreshed, product.ID).Error; err != nil { + t.Fatalf("query product: %v", err) + } + if refreshed.Stock != 4 { + t.Fatalf("stock should decrease once, got %d", refreshed.Stock) + } + if first.Order.RequestFingerprint == "" { + t.Fatalf("request fingerprint should be stored") + } +} + func TestDashboardScopesPlatformAndMerchantData(t *testing.T) { db := newServiceTestDB(t) merchantA, productA := seedFulfillmentMerchant(t, db, "dashboard-a", 1000, 5, 100) @@ -342,6 +393,91 @@ func TestFulfillmentStatusTransitions(t *testing.T) { } } +func TestMarkProcessingTimeoutsMarksStaleOrdersFailed(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout", 1000, -1, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 13, + ClientOrderNo: "client-timeout", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + processing, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: created.Order.OrderNo, + Status: model.FulfillmentStatusProcessing, + }) + if err != nil { + t.Fatalf("mark processing: %v", err) + } + if err := db.Model(&model.FulfillmentOrder{}). + Where("id = ?", processing.ID). + Update("updated_at", time.Now().Add(-time.Hour)).Error; err != nil { + t.Fatalf("age processing order: %v", err) + } + + changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10) + if err != nil { + t.Fatalf("mark timeouts: %v", err) + } + if changed != 1 { + t.Fatalf("expected one timed out order, got %d", changed) + } + var order model.FulfillmentOrder + if err := db.First(&order, processing.ID).Error; err != nil { + t.Fatalf("query order: %v", err) + } + if order.FulfillmentStatus != model.FulfillmentStatusFailed || !strings.Contains(order.FailureReason, "履约超时") { + t.Fatalf("expected failed timeout order, got %+v", order) + } + if !strings.Contains(order.ResultData, `"timeout":true`) { + t.Fatalf("timeout result_data should be recorded, got %s", order.ResultData) + } +} + +func TestMarkProcessingTimeoutsSkipsRecentOrders(t *testing.T) { + db := newServiceTestDB(t) + merchantID, product := seedFulfillmentMerchant(t, db, "merchant-timeout-skip", 1000, -1, 100) + svc := NewFulfillmentService(db, nil) + created, err := svc.CreateOrder(CreateFulfillmentOrderInput{ + MerchantID: merchantID, + APIClientID: 13, + ClientOrderNo: "client-timeout-skip", + SKU: product.SKU, + }) + if err != nil { + t.Fatalf("create order: %v", err) + } + processing, err := svc.UpdateFulfillment(FulfillmentUpdateInput{ + MerchantID: merchantID, + APIClientID: 13, + OrderNo: created.Order.OrderNo, + Status: model.FulfillmentStatusProcessing, + }) + if err != nil { + t.Fatalf("mark processing: %v", err) + } + changed, err := svc.MarkProcessingTimeouts(30*time.Minute, 10) + if err != nil { + t.Fatalf("mark timeouts: %v", err) + } + if changed != 0 { + t.Fatalf("recent processing order should not time out, got %d", changed) + } + var order model.FulfillmentOrder + if err := db.First(&order, processing.ID).Error; err != nil { + t.Fatalf("query order: %v", err) + } + if order.FulfillmentStatus != model.FulfillmentStatusProcessing { + t.Fatalf("expected processing order, got %+v", order) + } +} + func TestFulfillmentMerchantIsolation(t *testing.T) { db := newServiceTestDB(t) merchantA, productA := seedFulfillmentMerchant(t, db, "merchant-d", 1000, 1, 100) diff --git a/docker-compose.yml b/docker-compose.yml index 997dafb..d56672c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,13 @@ services: - OPEN_SIGN_SKEW=${OPEN_SIGN_SKEW:-300} - OPEN_API_DEBUG=${OPEN_API_DEBUG:-false} - LOG_FILE=${LOG_FILE:-logs/app.log} + - DELIVERY_BASE_URL=${DELIVERY_BASE_URL:-} + - DELIVERY_BFF_BASE_URL=${DELIVERY_BFF_BASE_URL:-https://www.jxya.top/bff-stg} + - DELIVERY_CHANNEL=${DELIVERY_CHANNEL:-dlc} + - DELIVERY_LINK_SECRET=${DELIVERY_LINK_SECRET:-} + - DELIVERY_LINK_TTL_MINUTES=${DELIVERY_LINK_TTL_MINUTES:-120} + - FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=${FULFILLMENT_PROCESSING_TIMEOUT_MINUTES:-30} + - FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=${FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS:-60} volumes: - backend-data:/data - ./logs:/app/logs