Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77377ed27b |
@@ -13,7 +13,6 @@ 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"
|
||||||
@@ -23,47 +22,6 @@ 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
|
||||||
@@ -89,8 +47,6 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -109,54 +65,17 @@ 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)
|
||||||
signValid := client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery))
|
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
|
||||||
|
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:
|
}
|
||||||
// Order already Finished — repeat callback is expected, ack with 200.
|
if req.TradeStatus != "TRADE_SUCCESS" {
|
||||||
|
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 {
|
||||||
// HIF-136 P03: persist gateway trade_no BEFORE flipping status so post-mortems can
|
return nil
|
||||||
// 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 {
|
||||||
@@ -167,7 +86,6 @@ 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
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommissionWithdrawLogic struct {
|
type CommissionWithdrawLogic struct {
|
||||||
@@ -30,7 +29,7 @@ func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
|
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
|
||||||
ctxUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Error("current user is not found in context")
|
logger.Error("current user is not found in context")
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
@@ -52,38 +51,28 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HIF-139: read commission and pending total directly from DB inside a
|
// Sum all pending (status=0) withdrawals to compute available balance.
|
||||||
// transaction, FOR UPDATE on the user row. The ctxUser snapshot may be
|
// Available = commission - pendingTotal; commission is only deducted on approval.
|
||||||
// served from cache and can be stale (the original bug allowed a user
|
var pendingTotal int64
|
||||||
// with cached commission=996900 to submit a withdrawal while DB said 0,
|
if err = l.svcCtx.DB.WithContext(l.ctx).
|
||||||
// which then failed admin approval with 20010). Approve already locks
|
Model(&user.Withdrawal{}).
|
||||||
// the user row this way; aligning submission closes the gap.
|
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending).
|
||||||
|
Select("COALESCE(SUM(amount), 0)").
|
||||||
|
Scan(&pendingTotal).Error; err != nil {
|
||||||
|
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", u.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Commission < req.Amount+pendingTotal {
|
||||||
|
logger.Errorf("User %d insufficient available commission: total=%d pending=%d requested=%d",
|
||||||
|
u.Id, u.Commission, pendingTotal, req.Amount)
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id)
|
||||||
|
}
|
||||||
|
|
||||||
var w user.Withdrawal
|
var w user.Withdrawal
|
||||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
var dbUser user.User
|
|
||||||
if txErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
Where("id = ?", ctxUser.Id).First(&dbUser).Error; txErr != nil {
|
|
||||||
l.Errorf("Failed to lock user %d for withdrawal: %v", ctxUser.Id, txErr)
|
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to lock user %d: %v", ctxUser.Id, txErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pendingTotal int64
|
|
||||||
if txErr := tx.Model(&user.Withdrawal{}).
|
|
||||||
Where("user_id = ? AND status = ?", ctxUser.Id, user.WithdrawalStatusPending).
|
|
||||||
Select("COALESCE(SUM(amount), 0)").
|
|
||||||
Scan(&pendingTotal).Error; txErr != nil {
|
|
||||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", ctxUser.Id, txErr)
|
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", ctxUser.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if dbUser.Commission < req.Amount+pendingTotal {
|
|
||||||
logger.Errorf("User %d insufficient available commission: db_commission=%d pending=%d requested=%d",
|
|
||||||
ctxUser.Id, dbUser.Commission, pendingTotal, req.Amount)
|
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", ctxUser.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
w = user.Withdrawal{
|
w = user.Withdrawal{
|
||||||
UserId: ctxUser.Id,
|
UserId: u.Id,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: user.WithdrawalStatusPending,
|
Status: user.WithdrawalStatusPending,
|
||||||
@@ -92,19 +81,16 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
Account: req.Account,
|
Account: req.Account,
|
||||||
QrCodeUrl: req.QrCodeUrl,
|
QrCodeUrl: req.QrCodeUrl,
|
||||||
}
|
}
|
||||||
if txErr := tx.Create(&w).Error; txErr != nil {
|
return tx.Create(&w).Error
|
||||||
l.Errorf("Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.WithdrawalLog{
|
return &types.WithdrawalLog{
|
||||||
Id: w.Id,
|
Id: w.Id,
|
||||||
UserId: ctxUser.Id,
|
UserId: u.Id,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: user.WithdrawalStatusPending,
|
Status: user.WithdrawalStatusPending,
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/DATA-DOG/go-sqlmock"
|
|
||||||
modeluser "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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCommissionWithdraw_StaleCacheRejected 覆盖 HIF-139 修复:
|
|
||||||
// ctxUser(来自 auth middleware 的 cache-aside FindOne)即使 Commission=996900,
|
|
||||||
// 只要事务内 FOR UPDATE 读出的 DB 真值 Commission < 申请金额 + pendingTotal,
|
|
||||||
// 申请就必须被拒(UserCommissionNotEnough),且不得 INSERT 任何 withdrawal。
|
|
||||||
func TestCommissionWithdraw_StaleCacheRejected(t *testing.T) {
|
|
||||||
const userID = int64(510)
|
|
||||||
|
|
||||||
db, mock, cleanup := newWithdrawTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
mock.ExpectBegin()
|
|
||||||
// FOR UPDATE 锁 user 行 → DB 真值 commission=0
|
|
||||||
mock.ExpectQuery("FROM `user`").
|
|
||||||
WithArgs(userID, 1).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(0)))
|
|
||||||
// pendingTotal=0
|
|
||||||
mock.ExpectQuery("FROM `withdrawals`").
|
|
||||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(0)))
|
|
||||||
// 校验失败 → ROLLBACK,不得 INSERT
|
|
||||||
mock.ExpectRollback()
|
|
||||||
|
|
||||||
// ctxUser 故意带一个虚高 commission,模拟陈旧 cache
|
|
||||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 996900})
|
|
||||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
|
||||||
Amount: 3000,
|
|
||||||
Method: modeluser.WithdrawalMethodBank,
|
|
||||||
Account: "222",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
|
||||||
}
|
|
||||||
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
|
|
||||||
t.Fatalf("CommissionWithdraw error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Fatalf("unmet sql expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCommissionWithdraw_HappyPath 覆盖申请成功路径:DB 真值充足 → INSERT withdrawal。
|
|
||||||
func TestCommissionWithdraw_HappyPath(t *testing.T) {
|
|
||||||
const userID = int64(72)
|
|
||||||
|
|
||||||
db, mock, cleanup := newWithdrawTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
mock.ExpectBegin()
|
|
||||||
mock.ExpectQuery("FROM `user`").
|
|
||||||
WithArgs(userID, 1).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(10000)))
|
|
||||||
mock.ExpectQuery("FROM `withdrawals`").
|
|
||||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(2000)))
|
|
||||||
// 10000 >= 3000 + 2000 → INSERT
|
|
||||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `withdrawals`")).
|
|
||||||
WillReturnResult(sqlmock.NewResult(99, 1))
|
|
||||||
mock.ExpectCommit()
|
|
||||||
|
|
||||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 10000})
|
|
||||||
resp, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
|
||||||
Amount: 3000,
|
|
||||||
Method: modeluser.WithdrawalMethodBank,
|
|
||||||
Account: "acc",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CommissionWithdraw unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if resp == nil || resp.Amount != 3000 || resp.Status != modeluser.WithdrawalStatusPending {
|
|
||||||
t.Fatalf("unexpected response: %+v", resp)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Fatalf("unmet sql expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCommissionWithdraw_PendingTotalExhausts 覆盖 pendingTotal 把可用额度吃光的场景:
|
|
||||||
// DB commission=5000、pending=4000、申请=2000 → 5000 < 6000 → 20010,不得 INSERT。
|
|
||||||
func TestCommissionWithdraw_PendingTotalExhausts(t *testing.T) {
|
|
||||||
const userID = int64(88)
|
|
||||||
|
|
||||||
db, mock, cleanup := newWithdrawTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
mock.ExpectBegin()
|
|
||||||
mock.ExpectQuery("FROM `user`").
|
|
||||||
WithArgs(userID, 1).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(5000)))
|
|
||||||
mock.ExpectQuery("FROM `withdrawals`").
|
|
||||||
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(4000)))
|
|
||||||
mock.ExpectRollback()
|
|
||||||
|
|
||||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 5000})
|
|
||||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
|
||||||
Amount: 2000,
|
|
||||||
Method: modeluser.WithdrawalMethodBank,
|
|
||||||
Account: "acc",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
|
||||||
}
|
|
||||||
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
|
|
||||||
t.Fatalf("error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Fatalf("unmet sql expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCommissionWithdraw_LockUserMissing 覆盖事务内 FOR UPDATE 找不到 user 的场景(user 已删/不存在)→ DatabaseQueryError。
|
|
||||||
func TestCommissionWithdraw_LockUserMissing(t *testing.T) {
|
|
||||||
const userID = int64(999999)
|
|
||||||
|
|
||||||
db, mock, cleanup := newWithdrawTestDB(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
mock.ExpectBegin()
|
|
||||||
mock.ExpectQuery("FROM `user`").
|
|
||||||
WithArgs(userID, 1).
|
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}))
|
|
||||||
mock.ExpectRollback()
|
|
||||||
|
|
||||||
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 999})
|
|
||||||
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
|
|
||||||
Amount: 100,
|
|
||||||
Method: modeluser.WithdrawalMethodBank,
|
|
||||||
Account: "acc",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("CommissionWithdraw expected error, got nil")
|
|
||||||
}
|
|
||||||
if !isWithdrawErrCode(err, xerr.DatabaseQueryError) {
|
|
||||||
t.Fatalf("error code = %v, want DatabaseQueryError; raw=%v", withdrawErrCodeOf(err), err)
|
|
||||||
}
|
|
||||||
if err := mock.ExpectationsWereMet(); err != nil {
|
|
||||||
t.Fatalf("unmet sql expectations: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newWithdrawTestDB(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 newTestCommissionWithdrawLogic(t *testing.T, db *gorm.DB, ctxUser *modeluser.User) *CommissionWithdrawLogic {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, ctxUser)
|
|
||||||
return &CommissionWithdrawLogic{
|
|
||||||
Logger: logger.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: &svc.ServiceContext{DB: db},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func withdrawErrCodeOf(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 isWithdrawErrCode(err error, code uint32) bool {
|
|
||||||
return withdrawErrCodeOf(err) == code
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user