From 5795b05a63f39349b862a31769b57c105fbe83dc Mon Sep 17 00:00:00 2001 From: huzhe01 <47627651+huzhe01@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:40:03 +0800 Subject: [PATCH] fix(payment): propagate paid status update errors --- internal/apps/payment/service.go | 28 ++++++++---- internal/apps/payment/service_test.go | 64 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 internal/apps/payment/service_test.go diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go index 6088772..376eae5 100644 --- a/internal/apps/payment/service.go +++ b/internal/apps/payment/service.go @@ -393,14 +393,12 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { // CAS: PENDING -> PAID now := time.Now() - rows := db.DB(ctx).Model(&PaymentOrder{}). - Where("out_trade_no = ? AND status = ?", outTradeNo, OrderStatusPending). - Updates(map[string]any{ - "status": OrderStatusPaid, - "trade_no": q["trade_no"], - "paid_at": &now, - }).RowsAffected - if rows == 0 { + updated, updateErr := markOrderPaid(db.DB(ctx), outTradeNo, q["trade_no"], now) + if updateErr != nil { + logger.ErrorF(ctx, "payment notify: failed to mark order %s paid: %v", outTradeNo, updateErr) + return false, "update order status failed" + } + if !updated { // 并发下另一个回调在处理,或订单已过期被扫描任务置 FAILED(itemID 已回滚),此时不再处理 // 仍返回 success 让对方停止重试,结果以订单最终状态为准 return true, "concurrent or non-pending" @@ -444,6 +442,20 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { return true, "ok" } +func markOrderPaid(tx *gorm.DB, outTradeNo, tradeNo string, paidAt time.Time) (bool, error) { + result := tx.Model(&PaymentOrder{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, OrderStatusPending). + Updates(map[string]any{ + "status": OrderStatusPaid, + "trade_no": tradeNo, + "paid_at": &paidAt, + }) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + // fulfillPaidOrder 在已确认付款的前提下执行发放事务,复用 project.FulfillForReceiver。 func fulfillPaidOrder(ctx context.Context, order *PaymentOrder) error { return db.DB(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/internal/apps/payment/service_test.go b/internal/apps/payment/service_test.go new file mode 100644 index 0000000..17d5db9 --- /dev/null +++ b/internal/apps/payment/service_test.go @@ -0,0 +1,64 @@ +/* + * 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 ( + "errors" + "testing" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func TestMarkOrderPaidReturnsUpdateError(t *testing.T) { + dialector := mysql.New(mysql.Config{ + DSN: "unused:unused@tcp(127.0.0.1:1)/unused", + SkipInitializeWithVersion: true, + }) + database, err := gorm.Open(dialector, &gorm.Config{ + DryRun: true, + DisableAutomaticPing: true, + SkipDefaultTransaction: true, + }) + if err != nil { + t.Fatalf("open dry-run database: %v", err) + } + + wantErr := errors.New("database write failed") + if err := database.Callback().Update().Before("gorm:update").Register("test:fail_order_update", func(tx *gorm.DB) { + tx.AddError(wantErr) + }); err != nil { + t.Fatalf("register update callback: %v", err) + } + + updated, err := markOrderPaid(database, "order-1", "trade-1", time.Now()) + if !errors.Is(err, wantErr) { + t.Fatalf("markOrderPaid error = %v, want %v", err, wantErr) + } + if updated { + t.Fatal("markOrderPaid reported an update after the database rejected it") + } +}