From 47b908a80fb9884da6cd5e9dfcb2d688c551fd85 Mon Sep 17 00:00:00 2001 From: Q3CC Date: Sun, 14 Jun 2026 11:13:58 +0800 Subject: [PATCH 1/5] fix: reset project completed status when order expires or refunds Issue: When all items are reserved but users don't complete payment, the project gets marked as completed even after orders expire and items are returned to stock. Changes: - Add resetProjectCompletedStatus in tasks.go to check and reset IsCompleted flag when expired orders return items - Add resetProjectCompletedStatusAfterRefund in service.go to handle refund scenarios - Reset IsCompleted when Redis has stock but project is marked as completed - Applied to both order expiration cleanup and refund success paths This ensures projects become available again after reserved items are released back to inventory. --- internal/apps/payment/service.go | 37 ++++++++++++++++++++++++++++++ internal/apps/payment/tasks.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go index 0758fc1d..1d101080 100644 --- a/internal/apps/payment/service.go +++ b/internal/apps/payment/service.go @@ -323,6 +323,8 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { return false, "update order status failed" } db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) + // 退款重试成功后检查并重置项目完成状态 + resetProjectCompletedStatusAfterRefund(ctx, order.ProjectID) return true, "refund retry ok" } return false, "refund retry failed" @@ -361,6 +363,8 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { updates["refunded_at"] = &tNow // 把 item 推回 Redis 队列恢复库存 db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) + // 退款成功后检查并重置项目完成状态 + resetProjectCompletedStatusAfterRefund(ctx, order.ProjectID) } else { updates["status"] = OrderStatusRefunding } @@ -396,5 +400,38 @@ func CallbackURLs() (notifyURL, returnURL string) { return callbackNotifyURL(), callbackReturnURL("") } +// resetProjectCompletedStatusAfterRefund 退款后检查并重置项目的已完成状态 +// 这个函数复用了 tasks.go 中的逻辑,当退款成功归还 item 后调用 +func resetProjectCompletedStatusAfterRefund(ctx context.Context, projectID string) { + var proj project.Project + if err := db.DB(ctx).Where("id = ?", projectID).First(&proj).Error; err != nil { + logger.ErrorF(ctx, "payment refund: failed to load project %s: %v", projectID, err) + return + } + + // 只处理已标记为完成的项目 + if !proj.IsCompleted { + return + } + + // 检查 Redis 是否有库存 + hasStock, err := proj.HasStock(ctx) + if err != nil { + logger.ErrorF(ctx, "payment refund: failed to check stock for project %s: %v", projectID, err) + return + } + + // 如果有库存但项目标记为已完成,则重置 + if hasStock { + if err := db.DB(ctx).Model(&project.Project{}). + Where("id = ? AND is_completed = ?", projectID, true). + Update("is_completed", false).Error; err != nil { + logger.ErrorF(ctx, "payment refund: failed to reset completed status for project %s: %v", projectID, err) + } else { + logger.InfoF(ctx, "payment refund: reset project %s completed status (has stock after refund)", projectID) + } + } +} + // ErrOrderNotFoundSentinel 外部判断 var ErrOrderNotFoundSentinel = errors.New(ErrOrderNotFound) diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go index 1b74cc33..1a7cc173 100644 --- a/internal/apps/payment/tasks.go +++ b/internal/apps/payment/tasks.go @@ -61,6 +61,7 @@ func HandleExpireStaleOrders(ctx context.Context, _ *asynq.Task) error { // expireOrder 通过 CAS 将单笔 PENDING 订单置为 FAILED,并把预占的 item 归还 Redis。 // 使用 CAS (WHERE status=PENDING) 保证幂等: // - 若 notify 回调恰好在此同时到达并推进了状态,CAS 得 0 行,安全跳过。 +// 修复:归还 item 后,如果项目之前被标记为已完成,现在有库存了,则重置 IsCompleted。 func expireOrder(ctx context.Context, order *PaymentOrder) { rows := db.DB(ctx).Model(&PaymentOrder{}). Where("out_trade_no = ? AND status = ?", order.OutTradeNo, OrderStatusPending). @@ -76,5 +77,43 @@ func expireOrder(ctx context.Context, order *PaymentOrder) { // 归还预占的 item,恢复项目库存 db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) logger.InfoF(ctx, "payment cleanup: returned item %d to project %s stock", order.ItemID, order.ProjectID) + + // 检查项目是否被错误标记为已完成,如果是则重置 + // 这种情况发生在:所有 item 被 LPOP 后某用户付款成功,项目被标记为 IsCompleted=true, + // 但随后其他未付款订单超时,item 被归还,此时应该重置 IsCompleted + resetProjectCompletedStatus(ctx, order.ProjectID) + logger.InfoF(ctx, "payment cleanup: order %s expired and marked as FAILED", order.OutTradeNo) } + +// resetProjectCompletedStatus 检查项目是否有库存但被标记为已完成,如果是则重置 IsCompleted +func resetProjectCompletedStatus(ctx context.Context, projectID string) { + var proj project.Project + if err := db.DB(ctx).Where("id = ?", projectID).First(&proj).Error; err != nil { + logger.ErrorF(ctx, "payment cleanup: failed to load project %s: %v", projectID, err) + return + } + + // 只处理已标记为完成的项目 + if !proj.IsCompleted { + return + } + + // 检查 Redis 是否有库存 + hasStock, err := proj.HasStock(ctx) + if err != nil { + logger.ErrorF(ctx, "payment cleanup: failed to check stock for project %s: %v", projectID, err) + return + } + + // 如果有库存但项目标记为已完成,则重置 + if hasStock { + if err := db.DB(ctx).Model(&project.Project{}). + Where("id = ? AND is_completed = ?", projectID, true). + Update("is_completed", false).Error; err != nil { + logger.ErrorF(ctx, "payment cleanup: failed to reset completed status for project %s: %v", projectID, err) + } else { + logger.InfoF(ctx, "payment cleanup: reset project %s completed status (has stock after order expiration)", projectID) + } + } +} From ce443c2a9997f74bdb04d70f07ad57e2f55014fc Mon Sep 17 00:00:00 2001 From: q3cc Date: Tue, 16 Jun 2026 16:23:53 +0800 Subject: [PATCH 2/5] refactor: merge duplicate reset completed status functions into Project method - Merge resetProjectCompletedStatusAfterRefund and resetProjectCompletedStatus into single method - Add Project.ResetCompletedStatusIfHasStock() in utils.go - Remove 65 lines of duplicate code across service.go and tasks.go - Maintain original code style without logging --- internal/apps/payment/service.go | 45 ++++++-------------------------- internal/apps/payment/tasks.go | 38 +++------------------------ internal/apps/project/utils.go | 24 +++++++++++++++++ 3 files changed, 35 insertions(+), 72 deletions(-) diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go index 1d101080..200efbd4 100644 --- a/internal/apps/payment/service.go +++ b/internal/apps/payment/service.go @@ -323,8 +323,10 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { return false, "update order status failed" } db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) - // 退款重试成功后检查并重置项目完成状态 - resetProjectCompletedStatusAfterRefund(ctx, order.ProjectID) + var proj project.Project + if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { + proj.ResetCompletedStatusIfHasStock(ctx) + } return true, "refund retry ok" } return false, "refund retry failed" @@ -363,8 +365,10 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { updates["refunded_at"] = &tNow // 把 item 推回 Redis 队列恢复库存 db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) - // 退款成功后检查并重置项目完成状态 - resetProjectCompletedStatusAfterRefund(ctx, order.ProjectID) + var proj project.Project + if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { + proj.ResetCompletedStatusIfHasStock(ctx) + } } else { updates["status"] = OrderStatusRefunding } @@ -400,38 +404,5 @@ func CallbackURLs() (notifyURL, returnURL string) { return callbackNotifyURL(), callbackReturnURL("") } -// resetProjectCompletedStatusAfterRefund 退款后检查并重置项目的已完成状态 -// 这个函数复用了 tasks.go 中的逻辑,当退款成功归还 item 后调用 -func resetProjectCompletedStatusAfterRefund(ctx context.Context, projectID string) { - var proj project.Project - if err := db.DB(ctx).Where("id = ?", projectID).First(&proj).Error; err != nil { - logger.ErrorF(ctx, "payment refund: failed to load project %s: %v", projectID, err) - return - } - - // 只处理已标记为完成的项目 - if !proj.IsCompleted { - return - } - - // 检查 Redis 是否有库存 - hasStock, err := proj.HasStock(ctx) - if err != nil { - logger.ErrorF(ctx, "payment refund: failed to check stock for project %s: %v", projectID, err) - return - } - - // 如果有库存但项目标记为已完成,则重置 - if hasStock { - if err := db.DB(ctx).Model(&project.Project{}). - Where("id = ? AND is_completed = ?", projectID, true). - Update("is_completed", false).Error; err != nil { - logger.ErrorF(ctx, "payment refund: failed to reset completed status for project %s: %v", projectID, err) - } else { - logger.InfoF(ctx, "payment refund: reset project %s completed status (has stock after refund)", projectID) - } - } -} - // ErrOrderNotFoundSentinel 外部判断 var ErrOrderNotFoundSentinel = errors.New(ErrOrderNotFound) diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go index 1a7cc173..b70e5e0c 100644 --- a/internal/apps/payment/tasks.go +++ b/internal/apps/payment/tasks.go @@ -78,42 +78,10 @@ func expireOrder(ctx context.Context, order *PaymentOrder) { db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) logger.InfoF(ctx, "payment cleanup: returned item %d to project %s stock", order.ItemID, order.ProjectID) - // 检查项目是否被错误标记为已完成,如果是则重置 - // 这种情况发生在:所有 item 被 LPOP 后某用户付款成功,项目被标记为 IsCompleted=true, - // 但随后其他未付款订单超时,item 被归还,此时应该重置 IsCompleted - resetProjectCompletedStatus(ctx, order.ProjectID) - - logger.InfoF(ctx, "payment cleanup: order %s expired and marked as FAILED", order.OutTradeNo) -} - -// resetProjectCompletedStatus 检查项目是否有库存但被标记为已完成,如果是则重置 IsCompleted -func resetProjectCompletedStatus(ctx context.Context, projectID string) { var proj project.Project - if err := db.DB(ctx).Where("id = ?", projectID).First(&proj).Error; err != nil { - logger.ErrorF(ctx, "payment cleanup: failed to load project %s: %v", projectID, err) - return + if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { + proj.ResetCompletedStatusIfHasStock(ctx) } - // 只处理已标记为完成的项目 - if !proj.IsCompleted { - return - } - - // 检查 Redis 是否有库存 - hasStock, err := proj.HasStock(ctx) - if err != nil { - logger.ErrorF(ctx, "payment cleanup: failed to check stock for project %s: %v", projectID, err) - return - } - - // 如果有库存但项目标记为已完成,则重置 - if hasStock { - if err := db.DB(ctx).Model(&project.Project{}). - Where("id = ? AND is_completed = ?", projectID, true). - Update("is_completed", false).Error; err != nil { - logger.ErrorF(ctx, "payment cleanup: failed to reset completed status for project %s: %v", projectID, err) - } else { - logger.InfoF(ctx, "payment cleanup: reset project %s completed status (has stock after order expiration)", projectID) - } - } + logger.InfoF(ctx, "payment cleanup: order %s expired and marked as FAILED", order.OutTradeNo) } diff --git a/internal/apps/project/utils.go b/internal/apps/project/utils.go index 319cb15d..91fbaf5c 100644 --- a/internal/apps/project/utils.go +++ b/internal/apps/project/utils.go @@ -205,3 +205,27 @@ func validateProjectPrice(ctx context.Context, price decimal.Decimal, dt Distrib } return nil } + +// ResetCompletedStatusIfHasStock 检查项目是否有库存但被标记为已完成,如果是则重置 IsCompleted 状态。 +// 适用场景: +// - 付费订单退款成功后,item 被归还到 Redis 队列,需检查并重置项目完成状态 +// - 付费订单超时过期后,预占的 item 被归还,需检查并重置项目完成状态 +func (p *Project) ResetCompletedStatusIfHasStock(ctx context.Context) { + // 只处理已标记为完成的项目 + if !p.IsCompleted { + return + } + + // 检查 Redis 是否有库存 + hasStock, err := p.HasStock(ctx) + if err != nil { + return + } + + // 如果有库存但项目标记为已完成,则重置 + if hasStock { + db.DB(ctx).Model(&Project{}). + Where("id = ? AND is_completed = ?", p.ID, true). + Update("is_completed", false) + } +} From c8b3902b0198f037e02789a786d2147b9dada79b Mon Sep 17 00:00:00 2001 From: Q3CC Date: Wed, 17 Jun 2026 10:35:33 +0800 Subject: [PATCH 3/5] refactor: move ResetCompletedStatusIfHasStock to models.go and add transaction - Move method from utils.go to models.go per maintainer feedback - Wrap logic in db.Transaction() for atomicity - Add error handling at all call sites (tasks.go and service.go) - Log errors when reset fails for better observability --- internal/apps/payment/service.go | 8 ++++++-- internal/apps/payment/tasks.go | 4 +++- internal/apps/project/models.go | 32 ++++++++++++++++++++++++++++++++ internal/apps/project/utils.go | 25 ------------------------- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go index 200efbd4..0c665704 100644 --- a/internal/apps/payment/service.go +++ b/internal/apps/payment/service.go @@ -325,7 +325,9 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) var proj project.Project if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { - proj.ResetCompletedStatusIfHasStock(ctx) + if err := proj.ResetCompletedStatusIfHasStock(ctx); err != nil { + logger.ErrorF(ctx, "payment refund retry: failed to reset completed status for project %s: %v", order.ProjectID, err) + } } return true, "refund retry ok" } @@ -367,7 +369,9 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) var proj project.Project if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { - proj.ResetCompletedStatusIfHasStock(ctx) + if err := proj.ResetCompletedStatusIfHasStock(ctx); err != nil { + logger.ErrorF(ctx, "payment refund: failed to reset completed status for project %s: %v", order.ProjectID, err) + } } } else { updates["status"] = OrderStatusRefunding diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go index b70e5e0c..1fccd862 100644 --- a/internal/apps/payment/tasks.go +++ b/internal/apps/payment/tasks.go @@ -80,7 +80,9 @@ func expireOrder(ctx context.Context, order *PaymentOrder) { var proj project.Project if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { - proj.ResetCompletedStatusIfHasStock(ctx) + if err := proj.ResetCompletedStatusIfHasStock(ctx); err != nil { + logger.ErrorF(ctx, "payment cleanup: failed to reset completed status for project %s: %v", order.ProjectID, err) + } } logger.InfoF(ctx, "payment cleanup: order %s expired and marked as FAILED", order.OutTradeNo) diff --git a/internal/apps/project/models.go b/internal/apps/project/models.go index 79576b24..44fea97c 100644 --- a/internal/apps/project/models.go +++ b/internal/apps/project/models.go @@ -529,6 +529,38 @@ func (p *Project) GetReceivedItem(ctx context.Context, userID uint64) (*ProjectI return item, nil } +// ResetCompletedStatusIfHasStock 检查项目是否有库存但被标记为已完成,如果是则重置 IsCompleted 状态。 +// 适用场景: +// - 付费订单退款成功后,item 被归还到 Redis 队列,需检查并重置项目完成状态 +// - 付费订单超时过期后,预占的 item 被归还,需检查并重置项目完成状态 +// +// 使用事务确保 Redis 库存检查和数据库状态更新的原子性。 +func (p *Project) ResetCompletedStatusIfHasStock(ctx context.Context) error { + // 只处理已标记为完成的项目 + if !p.IsCompleted { + return nil + } + + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + // 检查 Redis 是否有库存 + hasStock, err := p.HasStock(ctx) + if err != nil { + return err + } + + // 如果有库存但项目标记为已完成,则重置 + if hasStock { + if err := tx.Model(&Project{}). + Where("id = ? AND is_completed = ?", p.ID, true). + Update("is_completed", false).Error; err != nil { + return err + } + } + + return nil + }) +} + type ProjectReport struct { ID uint64 `json:"id" gorm:"primaryKey,autoIncrement"` ProjectID string `json:"project_id" gorm:"size:64;index;uniqueIndex:idx_project_reporter"` diff --git a/internal/apps/project/utils.go b/internal/apps/project/utils.go index 91fbaf5c..479526bf 100644 --- a/internal/apps/project/utils.go +++ b/internal/apps/project/utils.go @@ -169,7 +169,6 @@ func ListMyProjectsWithTags(ctx context.Context, creatorID uint64, offset, limit }, nil } -// maxProjectPrice 付费项目的最大单价上限 var maxProjectPrice = decimal.RequireFromString("99999999.99") // validateProjectPrice 校验 Price 字段合法性。 @@ -205,27 +204,3 @@ func validateProjectPrice(ctx context.Context, price decimal.Decimal, dt Distrib } return nil } - -// ResetCompletedStatusIfHasStock 检查项目是否有库存但被标记为已完成,如果是则重置 IsCompleted 状态。 -// 适用场景: -// - 付费订单退款成功后,item 被归还到 Redis 队列,需检查并重置项目完成状态 -// - 付费订单超时过期后,预占的 item 被归还,需检查并重置项目完成状态 -func (p *Project) ResetCompletedStatusIfHasStock(ctx context.Context) { - // 只处理已标记为完成的项目 - if !p.IsCompleted { - return - } - - // 检查 Redis 是否有库存 - hasStock, err := p.HasStock(ctx) - if err != nil { - return - } - - // 如果有库存但项目标记为已完成,则重置 - if hasStock { - db.DB(ctx).Model(&Project{}). - Where("id = ? AND is_completed = ?", p.ID, true). - Update("is_completed", false) - } -} From ce7b02c761eb5ac77d2849f106e091f54a0f5f8e Mon Sep 17 00:00:00 2001 From: Q3CC Date: Wed, 17 Jun 2026 10:37:32 +0800 Subject: [PATCH 4/5] fix: restore missing comment for maxProjectPrice --- internal/apps/project/utils.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/apps/project/utils.go b/internal/apps/project/utils.go index 479526bf..319cb15d 100644 --- a/internal/apps/project/utils.go +++ b/internal/apps/project/utils.go @@ -169,6 +169,7 @@ func ListMyProjectsWithTags(ctx context.Context, creatorID uint64, offset, limit }, nil } +// maxProjectPrice 付费项目的最大单价上限 var maxProjectPrice = decimal.RequireFromString("99999999.99") // validateProjectPrice 校验 Price 字段合法性。 From 142d440687b6673300f6f58c349357690abb8371 Mon Sep 17 00:00:00 2001 From: Q3CC <106431792+q3cc@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:23:19 +0800 Subject: [PATCH 5/5] fix: wrap returned item cleanup in transaction --- internal/apps/payment/service.go | 35 ++++++--------- internal/apps/payment/tasks.go | 42 ++++++++++-------- internal/apps/payment/utils.go | 73 ++++++++++++++++++++++++++++++++ internal/apps/project/models.go | 35 ++++++--------- 4 files changed, 124 insertions(+), 61 deletions(-) create mode 100644 internal/apps/payment/utils.go diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go index 0c665704..412b21b3 100644 --- a/internal/apps/payment/service.go +++ b/internal/apps/payment/service.go @@ -204,7 +204,7 @@ func InitiatePayment(ctx context.Context, p *project.Project, payer *oauth.User, return err } itemID = reservedItemID - logger.InfoF(ctx, "Reserved item %d for project %d and payer %d", itemID, p.ID, payer.ID) + logger.InfoF(ctx, "Reserved item %d for project %s and payer %d", itemID, p.ID, payer.ID) outTradeNo := genOutTradeNo() order := PaymentOrder{ @@ -315,19 +315,13 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { refundErr := doEpayRefund(ctx, cfg.ClientID, secret, order.TradeNo, moneyString(order.Amount)) if refundErr == nil { tNow := time.Now() - if updateErr := db.DB(ctx). - Model(&PaymentOrder{}). - Where("out_trade_no = ?", outTradeNo). - Updates(map[string]any{"status": OrderStatusRefunded, "refunded_at": &tNow}). - Error; updateErr != nil { + processed, updateErr := markOrderRefundedAndReturnItem(ctx, &order, map[string]any{"status": OrderStatusRefunded, "refunded_at": &tNow}, OrderStatusRefunding) + if updateErr != nil { + logger.ErrorF(ctx, "payment refund retry: failed to mark order %s refunded: %v", outTradeNo, updateErr) return false, "update order status failed" } - db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) - var proj project.Project - if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { - if err := proj.ResetCompletedStatusIfHasStock(ctx); err != nil { - logger.ErrorF(ctx, "payment refund retry: failed to reset completed status for project %s: %v", order.ProjectID, err) - } + if !processed { + return true, "idempotent" } return true, "refund retry ok" } @@ -365,19 +359,18 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { tNow := time.Now() updates["status"] = OrderStatusRefunded updates["refunded_at"] = &tNow - // 把 item 推回 Redis 队列恢复库存 - db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) - var proj project.Project - if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { - if err := proj.ResetCompletedStatusIfHasStock(ctx); err != nil { - logger.ErrorF(ctx, "payment refund: failed to reset completed status for project %s: %v", order.ProjectID, err) - } + if _, updateErr := markOrderRefundedAndReturnItem(ctx, &order, updates, OrderStatusPaid); updateErr != nil { + logger.ErrorF(ctx, "payment refund: failed to mark order %s refunded: %v", outTradeNo, updateErr) + return false, "update order status failed" } } else { updates["status"] = OrderStatusRefunding + if updateErr := db.DB(ctx).Model(&PaymentOrder{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, OrderStatusPaid).Updates(updates).Error; updateErr != nil { + logger.ErrorF(ctx, "payment refund: failed to mark order %s refunding: %v", outTradeNo, updateErr) + return false, "update order status failed" + } } - db.DB(ctx).Model(&PaymentOrder{}). - Where("out_trade_no = ?", outTradeNo).Updates(updates) // 让 epay 重试,再次进入时因状态非 PENDING 会回到 idempotent 分支 return false, fmt.Sprintf("fulfill failed: %v", err) } diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go index 1fccd862..bcca8bea 100644 --- a/internal/apps/payment/tasks.go +++ b/internal/apps/payment/tasks.go @@ -29,9 +29,9 @@ import ( "time" "github.com/hibiken/asynq" - "github.com/linux-do/cdk/internal/apps/project" "github.com/linux-do/cdk/internal/db" "github.com/linux-do/cdk/internal/logger" + "gorm.io/gorm" ) // HandleExpireStaleOrders 清理长时间未付款的 PENDING 订单。 @@ -61,29 +61,37 @@ func HandleExpireStaleOrders(ctx context.Context, _ *asynq.Task) error { // expireOrder 通过 CAS 将单笔 PENDING 订单置为 FAILED,并把预占的 item 归还 Redis。 // 使用 CAS (WHERE status=PENDING) 保证幂等: // - 若 notify 回调恰好在此同时到达并推进了状态,CAS 得 0 行,安全跳过。 +// // 修复:归还 item 后,如果项目之前被标记为已完成,现在有库存了,则重置 IsCompleted。 func expireOrder(ctx context.Context, order *PaymentOrder) { - rows := db.DB(ctx).Model(&PaymentOrder{}). - Where("out_trade_no = ? AND status = ?", order.OutTradeNo, OrderStatusPending). - Update("status", OrderStatusFailed). - RowsAffected + processed := false + if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&PaymentOrder{}). + Where("out_trade_no = ? AND status = ?", order.OutTradeNo, OrderStatusPending). + Update("status", OrderStatusFailed) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } - // 另一协程(notify 回调)已处理,跳过 - if rows == 0 { - logger.InfoF(ctx, "payment cleanup: order %s already processed, skipping", order.OutTradeNo) + if err := returnReservedItem(ctx, tx, order.ProjectID, order.ItemID); err != nil { + return err + } + + processed = true + return nil + }); err != nil { + logger.ErrorF(ctx, "payment cleanup: failed to expire order %s: %v", order.OutTradeNo, err) return } - // 归还预占的 item,恢复项目库存 - db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) - logger.InfoF(ctx, "payment cleanup: returned item %d to project %s stock", order.ItemID, order.ProjectID) - - var proj project.Project - if err := db.DB(ctx).Where("id = ?", order.ProjectID).First(&proj).Error; err == nil { - if err := proj.ResetCompletedStatusIfHasStock(ctx); err != nil { - logger.ErrorF(ctx, "payment cleanup: failed to reset completed status for project %s: %v", order.ProjectID, err) - } + if !processed { + logger.InfoF(ctx, "payment cleanup: order %s already processed, skipping", order.OutTradeNo) + return } + logger.InfoF(ctx, "payment cleanup: returned item %d to project %s stock", order.ItemID, order.ProjectID) logger.InfoF(ctx, "payment cleanup: order %s expired and marked as FAILED", order.OutTradeNo) } diff --git a/internal/apps/payment/utils.go b/internal/apps/payment/utils.go new file mode 100644 index 00000000..59e5c50d --- /dev/null +++ b/internal/apps/payment/utils.go @@ -0,0 +1,73 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "context" + "fmt" + + "github.com/linux-do/cdk/internal/apps/project" + "github.com/linux-do/cdk/internal/db" + "gorm.io/gorm" +) + +// returnReservedItem 把支付预占的 item 归还 Redis,并在同一个数据库事务中重置项目完成状态。 +// Redis RPush 或项目状态更新失败时返回 error,由调用方触发外层数据库事务回滚。 +func returnReservedItem(ctx context.Context, tx *gorm.DB, projectID string, itemID uint64) error { + var proj project.Project + if err := tx.Where("id = ?", projectID).First(&proj).Error; err != nil { + return fmt.Errorf("load project %s: %w", projectID, err) + } + if err := db.Redis.RPush(ctx, project.ProjectItemsKey(projectID), itemID).Err(); err != nil { + return fmt.Errorf("return item %d to project %s stock: %w", itemID, projectID, err) + } + if err := proj.ResetCompletedStatusIfHasStock(ctx, tx); err != nil { + return fmt.Errorf("reset completed status for project %s: %w", projectID, err) + } + return nil +} + +func markOrderRefundedAndReturnItem(ctx context.Context, order *PaymentOrder, updates map[string]any, expectedStatus OrderStatus) (bool, error) { + processed := false + err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&PaymentOrder{}). + Where("out_trade_no = ? AND status = ?", order.OutTradeNo, expectedStatus). + Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + + if err := returnReservedItem(ctx, tx, order.ProjectID, order.ItemID); err != nil { + return err + } + + processed = true + return nil + }) + return processed, err +} diff --git a/internal/apps/project/models.go b/internal/apps/project/models.go index 44fea97c..d4f730d6 100644 --- a/internal/apps/project/models.go +++ b/internal/apps/project/models.go @@ -529,36 +529,25 @@ func (p *Project) GetReceivedItem(ctx context.Context, userID uint64) (*ProjectI return item, nil } -// ResetCompletedStatusIfHasStock 检查项目是否有库存但被标记为已完成,如果是则重置 IsCompleted 状态。 +// ResetCompletedStatusIfHasStock 在调用方归还库存时重置项目完成状态。 // 适用场景: // - 付费订单退款成功后,item 被归还到 Redis 队列,需检查并重置项目完成状态 // - 付费订单超时过期后,预占的 item 被归还,需检查并重置项目完成状态 // -// 使用事务确保 Redis 库存检查和数据库状态更新的原子性。 -func (p *Project) ResetCompletedStatusIfHasStock(ctx context.Context) error { - // 只处理已标记为完成的项目 - if !p.IsCompleted { +// 调用方应在归还 item 的同一个数据库事务中传入 tx; +// 若 Redis RPush 或数据库更新失败,错误会返回给外层事务处理。 +func (p *Project) ResetCompletedStatusIfHasStock(ctx context.Context, tx *gorm.DB) error { + hasStock, err := p.HasStock(ctx) + if err != nil { + return err + } + if !hasStock { return nil } - return db.DB(ctx).Transaction(func(tx *gorm.DB) error { - // 检查 Redis 是否有库存 - hasStock, err := p.HasStock(ctx) - if err != nil { - return err - } - - // 如果有库存但项目标记为已完成,则重置 - if hasStock { - if err := tx.Model(&Project{}). - Where("id = ? AND is_completed = ?", p.ID, true). - Update("is_completed", false).Error; err != nil { - return err - } - } - - return nil - }) + return tx.Model(&Project{}). + Where("id = ? AND is_completed = ?", p.ID, true). + Update("is_completed", false).Error } type ProjectReport struct {