Compare commits

...

6 Commits

Author SHA1 Message Date
架构师 c2d0db5875 修复(#143): 邀请权益查询排序 inviteeAndInviterIds 消除 map 迭代序 flake
inviteeAndInviterSet 是 map,按 range 收集到 slice 后顺序不固定,
传给 fillGiftBenefits 的 IN (?,?) 参数随之乱序,导致 sqlmock 按位置
匹配的单测 TestQueryBenefitsKeepsDirectInviteeGift 偶现失败(本地 10
次复现约 2-4 次)。

在收集后追加 slices.Sort,让生产代码本身的下游 SQL 参数稳定;同步把
测试期望改为升序。50 次重复 + race 全绿。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 21:00:52 -07:00
shanshanzhong147 87ebfa1fac 修复(#137): DeferCloseOrder 关单前反查支付网关 (启用 confirmationPayment)
Build docker and publish / build (20.15.1) (push) Failing after 18m47s
Build docker and publish / build (20.15.1) (pull_request) Failing after 19m31s
Squash merge of fix/137-defer-close-反查网关 (1367c4f).

DeferCloseOrder 直接将 status=1 订单关单,会把已经在网关侧完成支付但
notify 静默失败的订单错误关闭。本次在关单前调用 EPay 的网关查询接口
(confirmationPayment) 拿到三态结果:

- Paid: 原子化把 status 1->2,写回 trade_no,再投递 asynq 走激活流程
- Unpaid: 继续原来的 close 事务,把 status 改为 cancelled
- Unknown / 网关失败: 保持 status=1,下一轮 DeferClose 再试

新增 closeOrderLogic_test.go (232 行),覆盖三态分支 + recoverPaidOrder
的并发幂等。单测全量 PASS, go build + go vet 均干净。E2E 验收因测试环境
访问受限暂未跑,QA 已在 issue 上注明阻塞原因 (qa_partial_blocked_on_e2e_access)。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 20:24:32 -07:00
shanshanzhong147 ac25eb4d91 修复(#140): 去掉 cancelWithdrawalLogic 在新流程下的重复退款
Build docker and publish / build (20.15.1) (push) Has been cancelled
Build docker and publish / build (20.15.1) (pull_request) Has been cancelled
Squash merge of fix/140-去掉撤销提现的重复退款 (86896cd).

HIF-22 引入 rejectWithdrawal 反查支付网关后,cancelWithdrawalLogic 仍在
事务体内调用 UpdateCommission(+amount),对已在 rejectWithdrawal 中退还
的金额做了二次退款。本次只保留 withdrawal.status -> Cancelled,与
rejectWithdrawal 行为对齐。

新增 cancelWithdrawalLogic_test.go 用 sqlmock 严格断言:撤销 happy path
只触发 BEGIN / SELECT FOR UPDATE / UPDATE withdrawals / COMMIT 四条 SQL,
对 user / system_logs 零读零写。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 20:23:45 -07:00
架构师 54379976ec 修复(#138): 补齐 errMsg 漏掉的错误码映射
Build docker and publish / build (20.15.1) (push) Failing after 20m57s
Build docker and publish / build (20.15.1) (pull_request) Failing after 19m44s
新增 UserCommissionNotEnough、SendSmsError、AreaCodeIsEmpty、
DeviceBindLimitExceeded、ExistAvailableTraffic 五个错误码的中文映射,
修复管理后台审批提现等接口业务校验失败时 msg 被误显为
"Internal Server Error" 的问题。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 22:29:36 -07:00
架构师 750b7be424 修复(#136): EPay notify 静默失败硬化 + 回写 trade_no
Build docker and publish / build (20.15.1) (push) Failing after 8m55s
Build docker and publish / build (20.15.1) (pull_request) Failing after 22m22s
P01 签名校验失败由 return nil 改为返回 xerr.SignatureInvalid,handler 回 400,EPay 网关重试;Debug 旁路保留
P02 订单不存在维持 error 返回,仅 status=5 Finished 保留 nil 作幂等短路
P03 支付完成路径写入 order.trade_no = req.TradeNo,再 UpdateOrderStatus 刷缓存
附加:TradeStatus != TRADE_SUCCESS 降为 INFO 日志 + metric epay_notify_trade_not_success
抽离纯决策函数 evaluateEPayNotify,新增单测覆盖 4 个核心分支 + 签名优先级回归 + SQL 写路径 + URL 解析(9/9 PASS,-race 干净)

仅 internal/logic/notify/ePayNotifyLogic.go 与对应单测,无其他文件变更。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 03:08:31 -07:00
架构师 aa11588c8f 修复(#129): 新注册无历史用户不再误命中沉默促销
Build docker and publish / build (20.15.1) (push) Failing after 22m17s
Build docker and publish / build (20.15.1) (pull_request) Failing after 22m28s
evaluateInactiveUserPromo 在 ErrRecordNotFound 时之前 return true,导致
新注册无任何订阅历史的用户被错误判定为"沉默用户"命中 inactive_user 规则。
改为 return false 保持判定语义一致:没有历史订阅 ≠ 沉默用户。

Squash from origin/fix/129-新注册误命中沉默促销 (77377ed)

Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 00:28:26 -07:00
13 changed files with 1131 additions and 71 deletions
+2
View File
@@ -2,6 +2,7 @@ package invite
import ( import (
"context" "context"
"slices"
modellog "github.com/perfect-panel/server/internal/model/log" modellog "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
@@ -113,6 +114,7 @@ func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation)
for userId := range inviteeAndInviterSet { for userId := range inviteeAndInviterSet {
inviteeAndInviterIds = append(inviteeAndInviterIds, userId) inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
} }
slices.Sort(inviteeAndInviterIds)
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil { if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
return nil, err return nil, err
+2 -2
View File
@@ -25,7 +25,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
WithArgs(33, int64(100), "family-order", 331, 332). WithArgs(33, int64(100), "family-order", 331, 332).
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"})) WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
mock.ExpectQuery("object_id IN"). mock.ExpectQuery("object_id IN").
WithArgs(34, int64(900), int64(200), int64(100), "family-order"). WithArgs(34, int64(100), int64(200), int64(900), "family-order").
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}). WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`)) AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
@@ -58,7 +58,7 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
WithArgs(33, int64(100), "direct-order", 331, 332). WithArgs(33, int64(100), "direct-order", 331, 332).
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"})) WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
mock.ExpectQuery("object_id IN"). mock.ExpectQuery("object_id IN").
WithArgs(34, int64(200), int64(100), "direct-order"). WithArgs(34, int64(100), int64(200), "direct-order").
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}). WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`)) AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
+1 -1
View File
@@ -175,7 +175,7 @@ func evaluateInactiveUserPromo(
Take(&lastSub).Error Take(&lastSub).Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return true, ruleExpiresAt, nil return false, time.Time{}, nil
} }
return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed") return false, time.Time{}, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query promo inactive user subscription failed")
} }
@@ -2,9 +2,13 @@ package common
import ( import (
"context" "context"
"regexp"
"testing" "testing"
"time" "time"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/mysql"
"github.com/perfect-panel/server/internal/model/promo" "github.com/perfect-panel/server/internal/model/promo"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"gorm.io/gorm" "gorm.io/gorm"
@@ -134,6 +138,42 @@ func TestEvaluatePromoNewUserRequiresFirstPurchase(t *testing.T) {
} }
} }
func TestEvaluatePromoRejectsInactiveRuleWhenUserHasNoSubscription(t *testing.T) {
db, mock, cleanup := newCommonPromoTestDB(t)
defer cleanup()
model := &fakePromoModel{rules: []*promo.RuleWithPrice{
{
Rule: promo.Rule{
Id: 9,
Name: "inactive",
Type: promo.RuleTypeInactiveUser,
Params: `{"inactive_months":3}`,
Enabled: true,
},
PromoPrice: 100,
},
}}
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `user_subscribe` WHERE user_id = ? ORDER BY CASE WHEN expire_time = ? THEN 0 ELSE 1 END, expire_time DESC LIMIT ?")).
WithArgs(int64(51640), time.UnixMilli(0), 1).
WillReturnError(gorm.ErrRecordNotFound)
got, err := EvaluatePromo(context.Background(), &svc.ServiceContext{DB: db, PromoModel: model}, 51640, 1, 30, true)
if err != nil {
t.Fatalf("EvaluatePromo returned error: %v", err)
}
if got.Eligible {
t.Fatal("new user without subscription history should not be eligible for inactive promo")
}
if got.RuleID != 0 || got.PromoPrice != 0 {
t.Fatalf("promo fields = (%d, %d), want zero values", got.RuleID, got.PromoPrice)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet db expectations: %v", err)
}
}
type fakePromoModel struct { type fakePromoModel struct {
rules []*promo.RuleWithPrice rules []*promo.RuleWithPrice
lastSubscribeID int64 lastSubscribeID int64
@@ -193,3 +233,22 @@ func (m *fakePromoModel) QueryUsageList(context.Context, promo.UsageFilter) (int
func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error { func (m *fakePromoModel) Transaction(context.Context, func(*gorm.DB) error) error {
return nil return nil
} }
func newCommonPromoTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
+89 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/model/payment"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
@@ -22,6 +23,47 @@ import (
queueType "github.com/perfect-panel/server/queue/types" queueType "github.com/perfect-panel/server/queue/types"
) )
// epayNotifyTradeNotSuccess identifies the "buyer paid but trade not in TRADE_SUCCESS
// terminal state" branch in metrics / log search. EPay is told 200 here so it will
// not retry; we still want it visible for monitoring (HIF-135 / HIF-136).
const epayNotifyTradeNotSuccess = "epay_notify_trade_not_success"
// epayNotifyDecision encodes the post-parse decision so the branching logic can be
// unit-tested without spinning up a real OrderModel / Redis / asynq stack.
type epayNotifyDecision int
const (
// epayNotifyDecisionRejectSign: signature is invalid and debug bypass is off.
// Caller MUST return an error so the handler responds non-200 and EPay retries.
epayNotifyDecisionRejectSign epayNotifyDecision = iota
// epayNotifyDecisionAckTradeNotSuccess: trade_status != TRADE_SUCCESS
// (user cancelled / failed at gateway). Acknowledge with 200 "success" and emit
// a metric-named log for monitoring.
epayNotifyDecisionAckTradeNotSuccess
// epayNotifyDecisionAckIdempotent: order already at Finished (status=5).
// Repeat callback — ack with 200 "success", do not re-enqueue activation.
epayNotifyDecisionAckIdempotent
// epayNotifyDecisionProcess: happy path. Write trade_no, flip status to Paid,
// enqueue activation task.
epayNotifyDecisionProcess
)
// evaluateEPayNotify is a pure decision function so each branch can be unit-tested
// without DB/Redis. Caller has already located the order; orderStatus is the
// current status from the DB row.
func evaluateEPayNotify(signValid, debugBypass bool, tradeStatus string, orderStatus uint8) epayNotifyDecision {
if !signValid && !debugBypass {
return epayNotifyDecisionRejectSign
}
if tradeStatus != "TRADE_SUCCESS" {
return epayNotifyDecisionAckTradeNotSuccess
}
if orderStatus == 5 {
return epayNotifyDecisionAckIdempotent
}
return epayNotifyDecisionProcess
}
type EPayNotifyLogic struct { type EPayNotifyLogic struct {
logger.Logger logger.Logger
ctx *gin.Context ctx *gin.Context
@@ -47,6 +89,8 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
} }
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo) orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo)
if err != nil { if err != nil {
// HIF-136 P02: order missing must propagate as error so EPay retries instead
// of being silently lost (was previously masked by a `return nil` further down).
l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo)) l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo) return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
} }
@@ -65,17 +109,54 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
} }
// Verify sign // Verify sign
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type) client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug { signValid := client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery))
l.Logger.Error("[EPayNotify] Verify sign failed")
decision := evaluateEPayNotify(signValid, l.svcCtx.Config.Debug, req.TradeStatus, orderInfo.Status)
switch decision {
case epayNotifyDecisionRejectSign:
// HIF-136 P01: previously `return nil` here let EPay see 200 and stop retrying;
// caller still left order at status=1, which DeferCloseOrder then closed.
// Return error so handler responds non-200 and EPay retries.
l.Logger.Error("[EPayNotify] Verify sign failed",
logger.Field("order_no", req.OutTradeNo),
logger.Field("trade_no", req.TradeNo),
logger.Field("raw_query", l.ctx.Request.URL.RawQuery),
)
return errors.Wrapf(xerr.NewErrCode(xerr.SignatureInvalid), "verify sign failed: %v", req.OutTradeNo)
case epayNotifyDecisionAckTradeNotSuccess:
// User cancelled / gateway-side failure: ack 200 to stop retries, but keep
// the path observable under metric name `epay_notify_trade_not_success`.
l.Logger.Info("[EPayNotify] Trade status not success",
logger.Field("order_no", req.OutTradeNo),
logger.Field("trade_status", req.TradeStatus),
logger.Field("metric", epayNotifyTradeNotSuccess),
)
return nil return nil
} case epayNotifyDecisionAckIdempotent:
if req.TradeStatus != "TRADE_SUCCESS" { // Order already Finished — repeat callback is expected, ack with 200.
l.Logger.Error("[EPayNotify] Trade status is not success", logger.Field("orderNo", req.OutTradeNo), logger.Field("tradeStatus", req.TradeStatus))
return nil return nil
case epayNotifyDecisionProcess:
// fall through
} }
if orderInfo.Status == 5 {
return nil // HIF-136 P03: persist gateway trade_no BEFORE flipping status so post-mortems can
// reverse-lookup EPay flows to ppanel orders. Raw gorm write is acceptable here —
// the subsequent UpdateOrderStatus will invalidate the cache key (keys are by
// order_no / id, both stable across this field write).
if req.TradeNo != "" {
if err := l.svcCtx.DB.WithContext(l.ctx).
Model(&order.Order{}).
Where("order_no = ?", req.OutTradeNo).
Update("trade_no", req.TradeNo).Error; err != nil {
l.Logger.Error("[EPayNotify] Update trade_no failed",
logger.Field("error", err.Error()),
logger.Field("order_no", req.OutTradeNo),
logger.Field("trade_no", req.TradeNo),
)
return errors.Wrapf(err, "update trade_no failed: %v", req.OutTradeNo)
} }
}
// Update order status // Update order status
err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2) err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2)
if err != nil { if err != nil {
@@ -86,6 +167,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
"[SubscriptionFlow] epay notify marked order as paid", "[SubscriptionFlow] epay notify marked order as paid",
append(commonLogic.OrderTraceFields(orderInfo), append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("payment_platform", data.Platform), logger.Field("payment_platform", data.Platform),
logger.Field("trade_no", req.TradeNo),
)..., )...,
) )
// Create activate order task // Create activate order task
@@ -0,0 +1,182 @@
package notify
import (
"context"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/perfect-panel/server/internal/model/order"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// TestEvaluateEPayNotify_RejectsInvalidSign covers HIF-136 P01: sign invalid +
// debug bypass off must return rejectSign so the handler responds non-200 and
// EPay retries (was previously a silent `return nil` → 200 → no retry).
func TestEvaluateEPayNotify_RejectsInvalidSign(t *testing.T) {
got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 1)
if got != epayNotifyDecisionRejectSign {
t.Fatalf("evaluateEPayNotify(invalid sign): got %v want %v", got, epayNotifyDecisionRejectSign)
}
}
// TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign documents the debug-mode
// escape hatch the issue calls out: production runs with Debug=false so this
// branch is never taken in prod, but local/dev should still flow through.
func TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign(t *testing.T) {
got := evaluateEPayNotify(false, true, "TRADE_SUCCESS", 1)
if got != epayNotifyDecisionProcess {
t.Fatalf("evaluateEPayNotify(invalid sign + debug): got %v want %v", got, epayNotifyDecisionProcess)
}
}
// TestEvaluateEPayNotify_TradeNotSuccess covers the user-cancelled / gateway-failure
// branch: behaviour unchanged (return nil → 200), but caller must now emit the
// `epay_notify_trade_not_success` metric-named log instead of an Error-level one.
func TestEvaluateEPayNotify_TradeNotSuccess(t *testing.T) {
tests := []string{"TRADE_FAILED", "WAIT_BUYER_PAY", ""}
for _, ts := range tests {
t.Run(ts, func(t *testing.T) {
got := evaluateEPayNotify(true, false, ts, 1)
if got != epayNotifyDecisionAckTradeNotSuccess {
t.Fatalf("evaluateEPayNotify(trade_status=%q): got %v want %v",
ts, got, epayNotifyDecisionAckTradeNotSuccess)
}
})
}
}
// TestEvaluateEPayNotify_IdempotentForFinishedOrder covers the only legitimate
// `return nil` short-circuit retained by HIF-136: duplicate callback for an
// already Finished (status=5) order is acknowledged with 200 instead of being
// re-processed.
func TestEvaluateEPayNotify_IdempotentForFinishedOrder(t *testing.T) {
got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", 5)
if got != epayNotifyDecisionAckIdempotent {
t.Fatalf("evaluateEPayNotify(status=5): got %v want %v", got, epayNotifyDecisionAckIdempotent)
}
}
// TestEvaluateEPayNotify_HappyPath covers the normal success branch: valid sign,
// trade_status=TRADE_SUCCESS, order not yet Finished → proceed to write trade_no
// + flip status + enqueue activation.
func TestEvaluateEPayNotify_HappyPath(t *testing.T) {
statuses := []uint8{1, 2, 3, 4} // anything but Finished(5)
for _, s := range statuses {
got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", s)
if got != epayNotifyDecisionProcess {
t.Fatalf("evaluateEPayNotify(status=%d): got %v want %v", s, got, epayNotifyDecisionProcess)
}
}
}
// TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency guards against a
// regression where a duplicate callback with a forged signature gets quietly
// acked because status==5 was checked before sign.
func TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency(t *testing.T) {
got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 5)
if got != epayNotifyDecisionRejectSign {
t.Fatalf("evaluateEPayNotify(invalid sign + status=5): got %v want %v",
got, epayNotifyDecisionRejectSign)
}
}
// TestUrlParamsToMap verifies the helper used to feed VerifySign — collapses
// each query parameter to its first value and treats absent params as missing.
func TestUrlParamsToMap(t *testing.T) {
got := urlParamsToMap("pid=1001&out_trade_no=ORD-1&trade_no=EPAY-9&sign=abc&trade_status=TRADE_SUCCESS")
want := map[string]string{
"pid": "1001",
"out_trade_no": "ORD-1",
"trade_no": "EPAY-9",
"sign": "abc",
"trade_status": "TRADE_SUCCESS",
}
if len(got) != len(want) {
t.Fatalf("urlParamsToMap len = %d want %d (got=%v)", len(got), len(want), got)
}
for k, v := range want {
if got[k] != v {
t.Fatalf("urlParamsToMap[%q] = %q want %q", k, got[k], v)
}
}
}
// TestEPayNotify_TradeNoRawWrite verifies HIF-136 P03 at the SQL boundary: the
// raw gorm write hits the `order` table with the exact `trade_no` from the
// EPay request, scoped by `order_no`, and surfaces driver errors back to the
// caller (so they propagate as non-200 and EPay retries).
func TestEPayNotify_TradeNoRawWrite(t *testing.T) {
db, mock, cleanup := newEPayNotifyTestDB(t)
defer cleanup()
const (
orderNo = "202606010001"
tradeNo = "EPAY-202606010001"
)
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order` SET `trade_no`").
WithArgs(tradeNo, sqlmock.AnyArg(), orderNo).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
err := db.WithContext(context.Background()).
Model(&order.Order{}).
Where("order_no = ?", orderNo).
Update("trade_no", tradeNo).Error
if err != nil {
t.Fatalf("raw trade_no update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestEPayNotify_TradeNoRawWritePropagatesError ensures a DB-side failure during
// the trade_no backfill is not swallowed — caller returns the error so EPay
// retries instead of silently flipping status without trade_no being written.
func TestEPayNotify_TradeNoRawWritePropagatesError(t *testing.T) {
db, mock, cleanup := newEPayNotifyTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order` SET `trade_no`").
WillReturnError(fmt.Errorf("deadlock detected"))
mock.ExpectRollback()
err := db.WithContext(context.Background()).
Model(&order.Order{}).
Where("order_no = ?", "ORD-2").
Update("trade_no", "EPAY-2").Error
if err == nil {
t.Fatalf("expected error from raw trade_no update, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newEPayNotifyTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
+262 -49
View File
@@ -5,9 +5,12 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/payment/epay"
"github.com/perfect-panel/server/pkg/payment/stripe" "github.com/perfect-panel/server/pkg/payment/stripe"
queueTypes "github.com/perfect-panel/server/queue/types"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/order"
@@ -18,19 +21,52 @@ import (
"github.com/perfect-panel/server/pkg/payment/alipay" "github.com/perfect-panel/server/pkg/payment/alipay"
) )
// GatewayPaymentStatus is the tri-state result of an external payment-gateway
// query. The third state (Unknown) is the whole point of HIF-137: when the
// gateway is unreachable or returns an inconclusive answer we must NOT collapse
// it to "unpaid" — that's exactly the bug that closes already-paid orders.
type GatewayPaymentStatus int
const (
// GatewayStatusUnknown — the gateway query itself failed (timeout, 5xx,
// network error, decode error) or the payment method has no gateway we can
// query. The caller must treat this as "do not close, retry later".
GatewayStatusUnknown GatewayPaymentStatus = iota
// GatewayStatusPaid — the gateway confirms the user has paid.
GatewayStatusPaid
// GatewayStatusUnpaid — the gateway confirms the order is unpaid /
// cancelled / closed on its side. Safe to close locally.
GatewayStatusUnpaid
)
type CloseOrderLogic struct { type CloseOrderLogic struct {
logger.Logger logger.Logger
ctx context.Context ctx context.Context
svcCtx *svc.ServiceContext svcCtx *svc.ServiceContext
// Test seams (injected via overrides on the returned struct). Production
// code never touches these — NewCloseOrderLogic wires the real impls.
gatewayQuery func(*order.Order) GatewayPaymentStatus
enqueueActivate func(context.Context, []byte) (string, error)
} }
// NewCloseOrderLogic Close order // NewCloseOrderLogic Close order
func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic { func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic {
return &CloseOrderLogic{ l := &CloseOrderLogic{
Logger: logger.WithContext(ctx), Logger: logger.WithContext(ctx),
ctx: ctx, ctx: ctx,
svcCtx: svcCtx, svcCtx: svcCtx,
} }
l.gatewayQuery = l.queryGatewayPaymentStatus
l.enqueueActivate = func(c context.Context, payload []byte) (string, error) {
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5))
info, err := svcCtx.Queue.EnqueueContext(c, task)
if err != nil {
return "", err
}
return info.ID, nil
}
return l
} }
func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error { func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
@@ -52,6 +88,31 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
return nil return nil
} }
// HIF-137: before closing a pending order, ask the gateway whether it was
// actually paid. Silent callback failures must not cause us to close a
// paid order. Three branches:
// - Paid → recover to status=2 + enqueue activation (do NOT close)
// - Unpaid → fall through to the normal close flow
// - Unknown → keep status=1 and let DeferCloseOrder retry on the next tick
switch l.gatewayQuery(orderInfo) {
case GatewayStatusPaid:
return l.recoverPaidOrder(orderInfo)
case GatewayStatusUnknown:
l.Errorw("[CloseOrder] gateway query inconclusive — keeping order open (metric=close_gateway_error_kept_open)",
logger.Field("metric", "close_gateway_error_kept_open"),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("method", orderInfo.Method),
logger.Field("trade_no", orderInfo.TradeNo),
)
return nil
}
l.Infow("[CloseOrder] gateway confirmed unpaid — proceeding with normal close (metric=close_normal)",
logger.Field("metric", "close_normal"),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("method", orderInfo.Method),
)
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId) sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId)
if err != nil { if err != nil {
l.Errorw("[CloseOrder] Find subscribe info failed", l.Errorw("[CloseOrder] Find subscribe info failed",
@@ -166,42 +227,117 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
return nil return nil
} }
// confirmationPayment Determine whether the payment is successful // recoverPaidOrder is the "gateway said paid" branch: flip the order to
// // status=2 (paid), persist the trade_no if we have one, and enqueue the same
//nolint:unused // activation task the notify handlers would have enqueued. We deliberately
func (l *CloseOrderLogic) confirmationPayment(order *order.Order) bool { // reuse ForthwithActivateOrder so the rest of the activation pipeline
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, order.PaymentId) // (idempotency, claim/release, commission, etc.) stays untouched.
if err != nil { func (l *CloseOrderLogic) recoverPaidOrder(orderInfo *order.Order) error {
l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", order.Method)) updates := map[string]any{"status": 2}
return false if orderInfo.TradeNo != "" {
} updates["trade_no"] = orderInfo.TradeNo
switch order.Method {
case AlipayF2f:
if l.queryAlipay(paymentConfig, order.TradeNo) {
return true
}
case StripeAlipay:
if l.queryStripe(paymentConfig, order.TradeNo) {
return true
}
case StripeWeChatPay:
if l.queryStripe(paymentConfig, order.TradeNo) {
return true
}
default:
l.Infow("[CloseOrder] Unsupported payment method", logger.Field("paymentMethod", order.Method))
}
return false
} }
// queryAlipay Query Alipay payment status // Race-safe: only flip 1→2. If another worker already moved the order
// // forward (e.g. a late notify finally landed), do nothing.
//nolint:unused result := l.svcCtx.DB.WithContext(l.ctx).
func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo string) bool { Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderInfo.OrderNo, 1).
Updates(updates)
if result.Error != nil {
l.Errorw("[CloseOrder] gateway-paid recovery update failed — keeping order open",
logger.Field("metric", "close_gateway_error_kept_open"),
logger.Field("error", result.Error.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
if result.RowsAffected == 0 {
l.Infow("[CloseOrder] gateway-paid recovery skipped — order already moved past status=1",
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
payload := queueTypes.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo}
bytes, err := json.Marshal(&payload)
if err != nil {
l.Errorw("[CloseOrder] marshal activation payload failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
taskID, err := l.enqueueActivate(l.ctx, bytes)
if err != nil {
// Order is already at status=2; if enqueue fails the stuck-order
// recovery sweeper will pick it up. Log loudly but don't error out.
l.Errorw("[CloseOrder] enqueue activation task failed after gateway-paid recovery",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
l.Infow("[CloseOrder] gateway-paid recovery succeeded — order promoted to paid + activation enqueued (metric=close_with_gateway_paid_recovered)",
logger.Field("metric", "close_with_gateway_paid_recovered"),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("method", orderInfo.Method),
logger.Field("trade_no", orderInfo.TradeNo),
logger.Field("queue_task_id", taskID),
)
return nil
}
// queryGatewayPaymentStatus dispatches to the right gateway client for the
// order's payment method and returns a tri-state result. Methods without an
// external gateway (Balance, unknown) return Unpaid — the historical close
// path is preserved for them.
func (l *CloseOrderLogic) queryGatewayPaymentStatus(orderInfo *order.Order) GatewayPaymentStatus {
switch orderInfo.Method {
case AlipayF2f:
return l.queryAlipay(orderInfo)
case StripeAlipay, StripeWeChatPay:
return l.queryStripe(orderInfo)
case Epay:
return l.queryEpay(orderInfo)
case Balance:
// Balance is settled in-process; no external gateway to ask. If status=1
// here, the balance deduction simply never completed — safe to close.
return GatewayStatusUnpaid
default:
l.Infow("[CloseOrder] no gateway query for method — falling back to close",
logger.Field("method", orderInfo.Method),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnpaid
}
}
// queryAlipay queries Alipay F2F and maps the response to a tri-state.
// "trade not exist" on Alipay's side is treated as Unpaid (the user never
// completed the QR-code scan), not Unknown.
func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStatus {
if orderInfo.TradeNo == "" {
// Alipay F2F creates the trade lazily — no trade_no means the user
// never scanned. Treat as definitively unpaid.
return GatewayStatusUnpaid
}
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed",
logger.Field("error", err.Error()),
logger.Field("paymentMark", orderInfo.Method),
)
return GatewayStatusUnknown
}
config := payment.AlipayF2FConfig{} config := payment.AlipayF2FConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config)) l.Errorw("[CloseOrder] Unmarshal Alipay config failed",
return false logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
} }
client := alipay.NewClient(alipay.Config{ client := alipay.NewClient(alipay.Config{
AppId: config.AppId, AppId: config.AppId,
@@ -209,35 +345,112 @@ func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo st
PublicKey: config.PublicKey, PublicKey: config.PublicKey,
InvoiceName: config.InvoiceName, InvoiceName: config.InvoiceName,
}) })
status, err := client.QueryTrade(l.ctx, TradeNo) if client == nil {
return GatewayStatusUnknown
}
status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo)
if err != nil { if err != nil {
l.Errorw("[CloseOrder] Query trade failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo)) l.Errorw("[CloseOrder] Alipay QueryTrade failed",
return false logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("tradeNo", orderInfo.TradeNo),
)
return GatewayStatusUnknown
} }
if status == alipay.Success || status == alipay.Finished { switch status {
return true case alipay.Success, alipay.Finished:
return GatewayStatusPaid
case alipay.Pending, alipay.Closed:
return GatewayStatusUnpaid
default:
// Unknown alipay status — be conservative.
return GatewayStatusUnknown
} }
return false
} }
// queryStripe Query Stripe payment status // queryStripe queries Stripe PaymentIntent and maps to a tri-state.
// // Any Stripe-side error (network, 5xx, decode) is Unknown — Stripe is the
//nolint:unused // gateway most prone to silent webhook drops in this codebase, so we must not
func (l *CloseOrderLogic) queryStripe(paymentConfig *payment.Payment, TradeNo string) bool { // downgrade query failures to "unpaid".
func (l *CloseOrderLogic) queryStripe(orderInfo *order.Order) GatewayPaymentStatus {
if orderInfo.TradeNo == "" {
// Stripe's PaymentIntent ID is written into trade_no at create time.
// Missing it means the intent was never persisted — treat as unpaid.
return GatewayStatusUnpaid
}
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed",
logger.Field("error", err.Error()),
logger.Field("paymentMark", orderInfo.Method),
)
return GatewayStatusUnknown
}
config := payment.StripeConfig{} config := payment.StripeConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil { if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config)) l.Errorw("[CloseOrder] Unmarshal Stripe config failed",
return false logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
} }
client := stripe.NewClient(stripe.Config{ client := stripe.NewClient(stripe.Config{
PublicKey: config.PublicKey, PublicKey: config.PublicKey,
SecretKey: config.SecretKey, SecretKey: config.SecretKey,
WebhookSecret: config.WebhookSecret, WebhookSecret: config.WebhookSecret,
}) })
status, err := client.QueryOrderStatus(TradeNo) paid, err := client.QueryOrderStatus(orderInfo.TradeNo)
if err != nil { if err != nil {
l.Errorw("[CloseOrder] Query order status failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo)) l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed",
return false logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("tradeNo", orderInfo.TradeNo),
)
return GatewayStatusUnknown
} }
return status if paid {
return GatewayStatusPaid
}
return GatewayStatusUnpaid
}
// queryEpay queries EPay using out_trade_no (EPay's order endpoint accepts
// out_trade_no even when we never recorded its internal trade_no, which is the
// common case for orders that lost their notify callback).
func (l *CloseOrderLogic) queryEpay(orderInfo *order.Order) GatewayPaymentStatus {
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed",
logger.Field("error", err.Error()),
logger.Field("paymentMark", orderInfo.Method),
)
return GatewayStatusUnknown
}
config := payment.EPayConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal EPay config failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
if config.Url == "" || config.Pid == "" || config.Key == "" {
l.Errorw("[CloseOrder] EPay config incomplete",
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
paid, err := client.QueryOrderStatus(orderInfo.OrderNo)
if err != nil {
l.Errorw("[CloseOrder] EPay QueryOrderStatus failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
if paid {
return GatewayStatusPaid
}
return GatewayStatusUnpaid
} }
@@ -0,0 +1,232 @@
package order
import (
"context"
"fmt"
"strings"
"sync/atomic"
"testing"
"github.com/DATA-DOG/go-sqlmock"
modelorder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// newCloseOrderTestDB wires gorm to go-sqlmock with a substring SQL matcher,
// matching the convention used elsewhere in this package.
func newCloseOrderTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() { _ = sqlDB.Close() }
}
func newCloseOrderLogicForTest(db *gorm.DB) *CloseOrderLogic {
ctx := context.Background()
return &CloseOrderLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
// TestRecoverPaidOrder_HappyPath covers HIF-137 P01 branch "gateway 已支付":
// gateway said the order was paid → we must flip status 1→2, persist
// trade_no, and enqueue exactly one ForthwithActivateOrder task. This is the
// most important regression to keep — silently dropping the enqueue here would
// re-create the bug we are fixing.
func TestRecoverPaidOrder_HappyPath(t *testing.T) {
db, mock, cleanup := newCloseOrderTestDB(t)
defer cleanup()
const (
orderNo = "ORD-PAID-1"
tradeNo = "ALIPAY-TRADE-9999"
)
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(2, tradeNo, sqlmock.AnyArg(), orderNo, 1).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
var (
enqueueCalls int32
capturedPayload []byte
)
logic := newCloseOrderLogicForTest(db)
logic.enqueueActivate = func(_ context.Context, payload []byte) (string, error) {
atomic.AddInt32(&enqueueCalls, 1)
capturedPayload = append([]byte(nil), payload...)
return "task-123", nil
}
err := logic.recoverPaidOrder(&modelorder.Order{
OrderNo: orderNo,
Method: AlipayF2f,
TradeNo: tradeNo,
Status: 1,
})
if err != nil {
t.Fatalf("recoverPaidOrder error: %v", err)
}
if got := atomic.LoadInt32(&enqueueCalls); got != 1 {
t.Fatalf("expected exactly one enqueue, got %d", got)
}
if !strings.Contains(string(capturedPayload), orderNo) {
t.Fatalf("activation payload missing order_no: %q", capturedPayload)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestRecoverPaidOrder_AlreadyAdvanced covers the race: the late-arriving
// gateway-notify already flipped the order past status=1 by the time the
// deferred close ran. UPDATE returns 0 rows; we must NOT double-enqueue.
func TestRecoverPaidOrder_AlreadyAdvanced(t *testing.T) {
db, mock, cleanup := newCloseOrderTestDB(t)
defer cleanup()
const orderNo = "ORD-RACE"
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
var enqueueCalls int32
logic := newCloseOrderLogicForTest(db)
logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
atomic.AddInt32(&enqueueCalls, 1)
return "should-not-fire", nil
}
err := logic.recoverPaidOrder(&modelorder.Order{
OrderNo: orderNo,
Method: Epay,
Status: 1,
})
if err != nil {
t.Fatalf("recoverPaidOrder error: %v", err)
}
if got := atomic.LoadInt32(&enqueueCalls); got != 0 {
t.Fatalf("expected no enqueue when 0 rows updated, got %d", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestRecoverPaidOrder_EnqueueFailureIsNotFatal covers the case where the DB
// move succeeded but the activation enqueue failed (Redis blip, etc). The
// order is already at status=2, so the stuck-order sweeper will pick it up —
// recoverPaidOrder must not return an error or revert the status.
func TestRecoverPaidOrder_EnqueueFailureIsNotFatal(t *testing.T) {
db, mock, cleanup := newCloseOrderTestDB(t)
defer cleanup()
const orderNo = "ORD-ENQ-FAIL"
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
logic := newCloseOrderLogicForTest(db)
logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
return "", fmt.Errorf("simulated redis outage")
}
if err := logic.recoverPaidOrder(&modelorder.Order{OrderNo: orderNo, Method: Epay}); err != nil {
t.Fatalf("recoverPaidOrder must swallow enqueue errors, got: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestQueryGatewayPaymentStatus_NonGatewayMethods checks the "fall-through"
// branches: methods that don't talk to an external gateway (Balance, empty
// string, anything unrecognised) must report Unpaid so the legacy close path
// is preserved. This is the regression hook for the issue's acceptance
// criterion #5 ("user-initiated cancellation can still close normally").
func TestQueryGatewayPaymentStatus_NonGatewayMethods(t *testing.T) {
logic := newCloseOrderLogicForTest(nil) // no DB needed for these branches
cases := []struct {
method string
want GatewayPaymentStatus
}{
{Balance, GatewayStatusUnpaid},
{"", GatewayStatusUnpaid},
{"future-method-we-dont-know", GatewayStatusUnpaid},
}
for _, tc := range cases {
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: tc.method})
if got != tc.want {
t.Fatalf("method=%q: got %v want %v", tc.method, got, tc.want)
}
}
}
// TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid: Alipay F2F creates
// the trade lazily — if we never recorded a trade_no, the user never scanned
// the QR code. We must report Unpaid (not Unknown) so the close proceeds.
// Without this short-circuit, every legitimately abandoned QR-code order
// would be "kept open" forever by the inconclusive-query branch.
func TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid(t *testing.T) {
logic := newCloseOrderLogicForTest(nil)
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: AlipayF2f, TradeNo: ""})
if got != GatewayStatusUnpaid {
t.Fatalf("Alipay F2F with empty trade_no should be Unpaid, got %v", got)
}
}
// TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid: same reasoning as
// the Alipay case — Stripe's PaymentIntent ID lives in trade_no; if it's
// missing the intent was never persisted.
func TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid(t *testing.T) {
logic := newCloseOrderLogicForTest(nil)
for _, method := range []string{StripeAlipay, StripeWeChatPay} {
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: method, TradeNo: ""})
if got != GatewayStatusUnpaid {
t.Fatalf("%s with empty trade_no should be Unpaid, got %v", method, got)
}
}
}
// TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring asserts that the
// default gatewayQuery is wired to the real implementation (not nil) by
// NewCloseOrderLogic. Without this guard a future refactor could silently
// drop the wiring and re-create HIF-135.
func TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring(t *testing.T) {
svcCtx := &svc.ServiceContext{}
l := NewCloseOrderLogic(context.Background(), svcCtx)
if l.gatewayQuery == nil {
t.Fatal("NewCloseOrderLogic must wire gatewayQuery; got nil")
}
if l.enqueueActivate == nil {
t.Fatal("NewCloseOrderLogic must wire enqueueActivate; got nil")
}
}
@@ -4,7 +4,6 @@ import (
"context" "context"
logicCommon "github.com/perfect-panel/server/internal/logic/common" logicCommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types" "github.com/perfect-panel/server/internal/types"
@@ -57,13 +56,10 @@ func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequ
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr) return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr)
} }
if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil { // Commission was NOT deducted at application time under the HIF-22
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr) // approval flow, so cancellation requires no refund — only a status
} // update. Refunding here would mint phantom commission and pollute
// reconciliation (mirrors rejectWithdrawal in admin/user/withdrawalCommon.go).
if txErr = logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawCancel, withdrawal.Amount, ""); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", txErr)
}
return nil return nil
}) })
@@ -0,0 +1,228 @@
package user
import (
"context"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// TestCancelWithdrawal_DoesNotRefundCommission 验证 HIF-140 修复:
// 用户撤销 pending 提现的事务体必须只更新 withdrawal.status
// 严禁触发 UpdateCommissioncommission 列读 / 写)或写 333/338 commission 日志。
//
// sqlmock 严格匹配期望 SQL:只允许出现 BEGIN / SELECT withdrawal FOR UPDATE /
// UPDATE withdrawal SET status / COMMIT,不允许出现 SELECT/UPDATE `user` 或
// INSERT system_logs。
func TestCancelWithdrawal_DoesNotRefundCommission(t *testing.T) {
const (
withdrawalID = int64(987654)
userID = int64(42)
amount = int64(2500)
)
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, userID, amount, usermodel.WithdrawalStatusPending))
mock.ExpectExec("UPDATE `withdrawals` SET").
WithArgs(usermodel.WithdrawalStatusCancelled, sqlmock.AnyArg(), withdrawalID, usermodel.WithdrawalStatusPending).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
logic := newTestCancelWithdrawalLogic(t, db, userID)
resp, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if err != nil {
t.Fatalf("CancelWithdrawal unexpected error: %v", err)
}
if resp == nil || resp.Id != withdrawalID {
t.Fatalf("CancelWithdrawal response = %+v, want id=%d", resp, withdrawalID)
}
if resp.Status != usermodel.WithdrawalStatusCancelled {
t.Fatalf("CancelWithdrawal status = %d, want %d", resp.Status, usermodel.WithdrawalStatusCancelled)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCancelWithdrawal_RejectsNonPending 覆盖:状态非 pending(已批准/拒绝/已撤销)
// 时撤销必须短路返回 WithdrawalStatusInvalid,且不得写任何状态或佣金。
func TestCancelWithdrawal_RejectsNonPending(t *testing.T) {
const (
withdrawalID = int64(55555)
userID = int64(42)
)
cases := []struct {
name string
status uint8
}{
{"already approved", usermodel.WithdrawalStatusApproved},
{"already rejected", usermodel.WithdrawalStatusRejected},
{"already cancelled", usermodel.WithdrawalStatusCancelled},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, userID, int64(2500), tc.status))
mock.ExpectRollback()
logic := newTestCancelWithdrawalLogic(t, db, userID)
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) {
t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
})
}
}
// TestCancelWithdrawal_RejectsOtherUserWithdrawal 覆盖:当前登录用户尝试撤销
// 不属于自己的 pending 提现,必须返回 PermissionDenied 且不得写库。
func TestCancelWithdrawal_RejectsOtherUserWithdrawal(t *testing.T) {
const (
withdrawalID = int64(33333)
ownerUserID = int64(99)
attackerID = int64(42)
)
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, ownerUserID, int64(2500), usermodel.WithdrawalStatusPending))
mock.ExpectRollback()
logic := newTestCancelWithdrawalLogic(t, db, attackerID)
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if !isCancelErrCode(err, xerr.PermissionDenied) {
t.Fatalf("CancelWithdrawal err = %v, want PermissionDenied", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE 模拟撤销与审批并发的场景:
// 第二个 cancel 在 LoadPendingWithdrawalForUpdate 取到行时,状态已被先到的 approve
// 改成 1FOR UPDATE 行锁让出后看到的最新状态),cancel 应当短路返回错误,
// 严禁继续往下写 status 或动 commission。
func TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE(t *testing.T) {
const (
withdrawalID = int64(77777)
userID = int64(42)
)
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, userID, int64(2500), usermodel.WithdrawalStatusApproved))
mock.ExpectRollback()
logic := newTestCancelWithdrawalLogic(t, db, userID)
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) {
t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid (loser of race)", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newCancelWithdrawalTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func newTestCancelWithdrawalLogic(t *testing.T, db *gorm.DB, userID int64) *CancelWithdrawalLogic {
t.Helper()
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
return &CancelWithdrawalLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{
DB: db,
UserModel: stubUserModelForCancel{},
},
}
}
// stubUserModelForCancel 是 user.Model 的零依赖替身,仅覆盖 CancelWithdrawal 必须
// 调用的 ClearUserCache。其它方法被调用会导致 nil-interface panic,正好可以暴露
// 测试边界外的意外依赖。
type stubUserModelForCancel struct {
usermodel.Model
}
func (stubUserModelForCancel) ClearUserCache(_ context.Context, _ ...*usermodel.User) error {
return nil
}
func cancelErrCodeOf(err error) uint32 {
if err == nil {
return 0
}
type coder interface {
GetErrCode() uint32
}
cause := errors.Cause(err)
if c, ok := cause.(coder); ok {
return c.GetErrCode()
}
return 0
}
func isCancelErrCode(err error, code uint32) bool {
return cancelErrCodeOf(err) == code
}
+22 -7
View File
@@ -2,6 +2,7 @@ package epay
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -88,28 +89,42 @@ func (c *Client) VerifySign(params map[string]string) bool {
return c.createSign(params) == params["sign"] return c.createSign(params) == params["sign"]
} }
func (c *Client) QueryOrderStatus(orderNo string) bool { // QueryOrderStatus returns (paid, err). A non-nil err means the query itself
// failed (network / 5xx / decode error) and the result is inconclusive — callers
// MUST NOT treat that as "unpaid". A nil err with paid=false means the gateway
// answered and reported the order is not paid.
func (c *Client) QueryOrderStatus(orderNo string) (bool, error) {
client := http.Client{ client := http.Client{
Timeout: 5 * time.Second, Timeout: 5 * time.Second,
} }
resp, err := client.Get(c.Url + "/api.php" + "?act=order" + "&pid=" + c.Pid + "&key=" + c.Key + "&out_trade_no=" + orderNo) resp, err := client.Get(c.Url + "/api.php" + "?act=order" + "&pid=" + c.Pid + "&key=" + c.Key + "&out_trade_no=" + orderNo)
if err != nil { if err != nil {
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error())) logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
return false return false, fmt.Errorf("epay query request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 500 {
err := fmt.Errorf("epay query upstream status %d", resp.StatusCode)
logger.Error("[Epay] QueryOrderStatus upstream 5xx", logger.Field("orderNo", orderNo), logger.Field("status", resp.StatusCode))
return false, err
}
value, err := io.ReadAll(resp.Body) value, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error())) logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
return false return false, fmt.Errorf("epay query read body failed: %w", err)
} }
var response queryOrderStatusResponse var response queryOrderStatusResponse
err = json.Unmarshal(value, &response) if err = json.Unmarshal(value, &response); err != nil {
if err != nil {
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error())) logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
return false return false, fmt.Errorf("epay query decode failed: %w", err)
} }
return response.Status == 1 // EPay API contract: code != 1 means the API itself errored (e.g. wrong key).
// Treat it as a query failure, not a definitive "unpaid", to avoid wrongly
// closing a paid order under a transient upstream config error.
if response.Code != 1 {
return false, fmt.Errorf("epay query api code=%d msg=%q", response.Code, response.Msg)
}
return response.Status == 1, nil
} }
// StructToMap converts a struct to map[string]string // StructToMap converts a struct to map[string]string
+6
View File
@@ -37,6 +37,7 @@ func init() {
TelegramNotBound: "Telegram not bound ", TelegramNotBound: "Telegram not bound ",
UserNotBindOauth: "User not bind oauth method", UserNotBindOauth: "User not bind oauth method",
InviteCodeError: "Invite code error", InviteCodeError: "Invite code error",
UserCommissionNotEnough: "佣金余额不足",
RegisterIPLimit: "Too many registrations", RegisterIPLimit: "Too many registrations",
EmailBindError: "Email already bound", EmailBindError: "Email already bound",
UserBindInviteCodeExist: "Invite code already bound", UserBindInviteCodeExist: "Invite code already bound",
@@ -82,6 +83,8 @@ func init() {
// System error // System error
DebugModeError: "Debug mode is enabled", DebugModeError: "Debug mode is enabled",
SendSmsError: "短信发送失败",
GetAuthenticatorError: "Unsupported login method", GetAuthenticatorError: "Unsupported login method",
AuthenticatorNotSupportedError: "The authenticator does not support this method", AuthenticatorNotSupportedError: "The authenticator does not support this method",
@@ -94,15 +97,18 @@ func init() {
TelephoneExist: "Telephone already exists", TelephoneExist: "Telephone already exists",
DeviceExist: "device exists", DeviceExist: "device exists",
PasswordIsEmpty: "password is empty", PasswordIsEmpty: "password is empty",
AreaCodeIsEmpty: "国家区号不能为空",
TelephoneError: "telephone number error", TelephoneError: "telephone number error",
DeviceNotExist: "Device does not exist", DeviceNotExist: "Device does not exist",
UseridNotMatch: "Userid not match", UseridNotMatch: "Userid not match",
DeviceBindLimitExceeded: "设备绑定数量已达上限",
// Order error // Order error
OrderNotExist: "Order does not exist", OrderNotExist: "Order does not exist",
PaymentMethodNotFound: "Payment method not found", PaymentMethodNotFound: "Payment method not found",
OrderStatusError: "Order status error", OrderStatusError: "Order status error",
InsufficientOfPeriod: "Insufficient number of period", InsufficientOfPeriod: "Insufficient number of period",
ExistAvailableTraffic: "存在可用流量",
OrderAlreadyRefunded: "Order already refunded", OrderAlreadyRefunded: "Order already refunded",
OrderRefundNoSubscription: "Refund target subscription not found", OrderRefundNoSubscription: "Refund target subscription not found",
OrderRefundCommissionMismatch: "Refund commission source not found", OrderRefundCommissionMismatch: "Refund commission source not found",
+45
View File
@@ -0,0 +1,45 @@
package xerr
import "testing"
// TestMapErrMsg_MissingCodesAreFilled 覆盖 HIF-138 中补齐的 5 个错误码:
// 这些码之前在 message 表中缺失,导致 MapErrMsg 回退到 "Internal Server Error"。
func TestMapErrMsg_MissingCodesAreFilled(t *testing.T) {
cases := []struct {
name string
code uint32
want string
}{
{"UserCommissionNotEnough", UserCommissionNotEnough, "佣金余额不足"},
{"SendSmsError", SendSmsError, "短信发送失败"},
{"AreaCodeIsEmpty", AreaCodeIsEmpty, "国家区号不能为空"},
{"DeviceBindLimitExceeded", DeviceBindLimitExceeded, "设备绑定数量已达上限"},
{"ExistAvailableTraffic", ExistAvailableTraffic, "存在可用流量"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := MapErrMsg(tc.code)
if got == "Internal Server Error" {
t.Fatalf("MapErrMsg(%d) fell back to Internal Server Error; expected %q", tc.code, tc.want)
}
if got != tc.want {
t.Errorf("MapErrMsg(%d) = %q, want %q", tc.code, got, tc.want)
}
if !IsCodeErr(tc.code) {
t.Errorf("IsCodeErr(%d) = false, want true", tc.code)
}
})
}
}
// TestMapErrMsg_UnknownCodeFallsBack 验证未定义码仍然安全回退,不 panic。
func TestMapErrMsg_UnknownCodeFallsBack(t *testing.T) {
const unknown uint32 = 99999
if got := MapErrMsg(unknown); got != "Internal Server Error" {
t.Errorf("MapErrMsg(%d) = %q, want %q", unknown, got, "Internal Server Error")
}
if IsCodeErr(unknown) {
t.Errorf("IsCodeErr(%d) = true, want false", unknown)
}
}