删除钱包充值功能并消除重复入账风险
钱包充值为开发态测试功能(生产环境本就禁用),且支付回调存在重复入账风险:入账与标记 paid 两步非原子、wallet_ledger 去重无唯一索引、幂等键使用了可变的 ProviderOrderID。直接删除该功能从根本上消除风险。 后端: - payment 移除 StartWalletRecharge/QueryWalletRecharge 及相关 handler/DTO/常量;confirmPaid 增加 OrderID 守卫;解除对 wallet 仓库的依赖 - wallet 移除 Recharge/ConfirmRechargeFromChannel 及相关定义 - 移除三条充值路由;adminfinance 财务统计口径只统计 order_pay - 清理充值相关测试用例 前端: - 移除充值 API、WalletView 充值面板/弹窗、admin 充值标签与筛选 - 保留钱包余额、流水、提现等核心能力 go build/vet 与 vue-tsc typecheck 均通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3ef7705776
commit
e8621728fd
@@ -216,7 +216,7 @@ func newFlowServices(db *gorm.DB) flowServices {
|
||||
return refund.Status, nil
|
||||
}),
|
||||
})
|
||||
paymentRepo = payment.NewRepository(db, configRepo, orderRepo, walletRepo)
|
||||
paymentRepo = payment.NewRepository(db, configRepo, orderRepo)
|
||||
disputeRepo := dispute.NewRepository(db, dispute.Dependencies{
|
||||
RefundStarter: dispute.RefundStarterFunc(func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) {
|
||||
refund, err := paymentRepo.StartRefund(ctx, orderID, refundAmountCent, bizType, remark)
|
||||
@@ -230,7 +230,7 @@ func newFlowServices(db *gorm.DB) flowServices {
|
||||
return flowServices{
|
||||
listing: listing.NewService(listingRepo, fixedConfig{"listing.review_required": "true"}),
|
||||
order: order.NewService(orderRepo),
|
||||
payment: payment.NewService(paymentRepo, "development"),
|
||||
payment: payment.NewService(paymentRepo),
|
||||
paymentConfig: paymentconfig.NewService(configRepo),
|
||||
paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db)),
|
||||
dispute: dispute.NewService(disputeRepo),
|
||||
|
||||
@@ -26,10 +26,10 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery) (*Financ
|
||||
db := r.db.WithContext(ctx)
|
||||
var payment paymentSummaryRow
|
||||
if err := db.Table("payment_orders").
|
||||
Select(`COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
|
||||
Select(`COALESCE(SUM(CASE WHEN biz_type IN ('order_pay') AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ('order_pay') AND status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN 1 ELSE 0 END), 0) AS successful_refund_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
|
||||
refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
|
||||
@@ -84,10 +84,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
|
||||
payments := make([]dailyPaymentRow, 0)
|
||||
if err := db.Table("payment_orders").
|
||||
Select(`DATE(created_at) AS date,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ('order_pay') AND status = 'paid' THEN amount_cent ELSE 0 END), 0) AS total_flow_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN amount_cent ELSE 0 END), 0) AS total_refund_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN amount_cent ELSE 0 END), 0) AS pending_refund_amount_cent,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ('order_pay', 'wallet_recharge') AND status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ('order_pay') AND status = 'paid' THEN 1 ELSE 0 END), 0) AS successful_pay_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunded' THEN 1 ELSE 0 END), 0) AS successful_refund_count,
|
||||
COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
|
||||
refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
|
||||
|
||||
@@ -34,21 +34,12 @@ func (r *Repository) updateChannelStatus(ctx context.Context, paymentID uint64,
|
||||
return r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", paymentID).Updates(updates).Error
|
||||
}
|
||||
func (r *Repository) confirmPaid(ctx context.Context, payment *model.PaymentOrder, status string, paidAt time.Time, raw map[string]string, source string) error {
|
||||
if payment.Status != "paid" {
|
||||
if payment.OrderID == 0 {
|
||||
if r.walletRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if err := r.walletRepo.ConfirmRechargeFromChannel(ctx, payment.UserID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo), payment.AmountCent); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if r.orderRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if err := r.orderRepo.ConfirmPaidFromChannel(ctx, payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil {
|
||||
return err
|
||||
}
|
||||
if payment.Status != "paid" && payment.OrderID != 0 {
|
||||
if r.orderRepo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
if err := r.orderRepo.ConfirmPaidFromChannel(ctx, payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
|
||||
@@ -11,12 +11,6 @@ type StartPaymentRequest struct {
|
||||
JSPayFlag string `json:"jspay_flag"`
|
||||
}
|
||||
|
||||
type WalletRechargePaymentRequest struct {
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PayWay string `json:"pay_way"`
|
||||
JSPayFlag string `json:"jspay_flag"`
|
||||
}
|
||||
|
||||
type PaymentDTO struct {
|
||||
ID uint64 `json:"id"`
|
||||
PaymentNo string `json:"payment_no"`
|
||||
|
||||
@@ -63,43 +63,6 @@ func (h *Handler) Query(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) WalletRecharge(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
var req WalletRechargePaymentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "充值金额不正确")
|
||||
return
|
||||
}
|
||||
item, err := h.service.StartWalletRecharge(c.Request.Context(), userID, req, c.ClientIP())
|
||||
if err != nil {
|
||||
writePaymentError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) WalletRechargeQuery(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
paymentID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.QueryWalletRecharge(c.Request.Context(), userID, paymentID)
|
||||
if err != nil {
|
||||
writePaymentError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) QueryRefundStatus(c *gin.Context) {
|
||||
orderID, ok := parseID(c)
|
||||
if !ok {
|
||||
@@ -248,8 +211,6 @@ func writePaymentError(c *gin.Context, err error) {
|
||||
response.Error(c, http.StatusConflict, "payment_cannot_start", "当前订单不能支付")
|
||||
case errors.Is(err, ErrRefundCannotStart):
|
||||
response.Error(c, http.StatusConflict, "refund_cannot_start", "当前订单不能退款")
|
||||
case errors.Is(err, ErrWalletRechargeDisabled):
|
||||
response.Error(c, http.StatusGone, "wallet_recharge_disabled", "钱包充值已关闭")
|
||||
case errors.Is(err, ErrPaymentVerifyFailed):
|
||||
response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败")
|
||||
case errors.Is(err, ErrPaymentNotFound), errors.Is(err, gorm.ErrRecordNotFound), order.IsNotFound(err):
|
||||
|
||||
@@ -103,87 +103,6 @@ func (r *Repository) Start(ctx context.Context, userID uint64, orderID uint64, r
|
||||
dto := toDTO(*latest)
|
||||
return &dto, nil
|
||||
}
|
||||
func (r *Repository) StartWalletRecharge(ctx context.Context, userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) {
|
||||
amountCent := req.AmountCent
|
||||
if userID == 0 || amountCent < moneyCent(MinWalletRechargeAmount) {
|
||||
return nil, ErrPaymentCannotStart
|
||||
}
|
||||
runtimeConfig, err := r.defaultRuntimeConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, ErrPaymentUnavailable
|
||||
}
|
||||
payment, err := r.createWalletRechargePayment(ctx, userID, amountCent, req, *runtimeConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if runtimeConfig.isMockMode() {
|
||||
if err := r.confirmPaid(ctx, payment, "2", time.Now(), map[string]string{
|
||||
"mock": "true",
|
||||
"third_order_id": payment.ThirdOrderID,
|
||||
"leshua_order_id": payment.ProviderOrderID,
|
||||
"status": "2",
|
||||
}, channelSourceMock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latest, err := r.findPaymentByID(ctx, payment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
||||
dto := toDTO(*latest)
|
||||
return &dto, nil
|
||||
}
|
||||
if runtimeConfig.Channel == nil {
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, "payment channel unavailable")
|
||||
return nil, ErrPaymentUnavailable
|
||||
}
|
||||
log.Printf("[payment] wallet recharge start user_id=%d payment_id=%d provider=%s amount_cent=%d third_order_id=%s",
|
||||
userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, payment.ThirdOrderID)
|
||||
resp, err := runtimeConfig.Channel.CreatePayment(ctx, channelCreatePaymentRequest{
|
||||
ThirdOrderID: payment.ThirdOrderID,
|
||||
AmountCent: payment.AmountCent,
|
||||
PayWay: payment.PayWay,
|
||||
JSPayFlag: payment.JSPayFlag,
|
||||
NotifyURL: runtimeConfig.NotifyURL,
|
||||
JumpURL: runtimeConfig.JumpURL,
|
||||
ClientIP: clientIP,
|
||||
Body: "钱包充值 " + payment.PaymentNo,
|
||||
Attach: payment.PaymentNo,
|
||||
})
|
||||
if err != nil {
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, nil, err.Error())
|
||||
log.Printf("[payment] wallet recharge request failed user_id=%d payment_id=%d provider=%s amount_cent=%d err=%v",
|
||||
userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, err)
|
||||
return nil, err
|
||||
}
|
||||
if !resp.OK {
|
||||
_ = r.markPaymentFailed(ctx, payment.ID, resp.Raw, resp.ErrorMessage)
|
||||
log.Printf("[payment] wallet recharge rejected user_id=%d payment_id=%d provider=%s amount_cent=%d code=%s message=%s",
|
||||
userID, payment.ID, runtimeConfig.Provider, payment.AmountCent, firstNonEmpty(resp.Raw["code"], resp.Raw["resp_code"], resp.Raw["result_code"]), resp.ErrorMessage)
|
||||
return nil, ErrPaymentUnavailable
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}).Where("id = ?", payment.ID).Updates(map[string]any{
|
||||
"status": "paying",
|
||||
"provider_order_id": resp.ProviderOrderID,
|
||||
"pay_way": firstNonEmpty(resp.PayWay, payment.PayWay),
|
||||
"td_code": resp.TDCode,
|
||||
"jspay_url": resp.JSPayURL,
|
||||
"jspay_info": resp.JSPayInfo,
|
||||
"raw_request": jsonMap(resp.RawRequest),
|
||||
"raw_response": jsonMap(withRawSource(resp.Raw, channelSourceCreate)),
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latest, err := r.findPaymentByID(ctx, payment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.recordConfigUsage(ctx, runtimeConfig, latest)
|
||||
log.Printf("[payment] wallet recharge result user_id=%d payment_id=%d provider=%s amount_cent=%d status=%s provider_order_id=%s",
|
||||
userID, latest.ID, runtimeConfig.Provider, latest.AmountCent, latest.Status, latest.ProviderOrderID)
|
||||
dto := toDTO(*latest)
|
||||
return &dto, nil
|
||||
}
|
||||
func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) {
|
||||
var paymentID uint64
|
||||
var orderRow model.RentalOrder
|
||||
@@ -304,32 +223,4 @@ func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRe
|
||||
}
|
||||
return payment, nil
|
||||
}
|
||||
func (r *Repository) createWalletRechargePayment(ctx context.Context, userID uint64, amountCent int64, req WalletRechargePaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, error) {
|
||||
paymentNo, err := newPaymentNo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payment := model.PaymentOrder{
|
||||
PaymentNo: paymentNo,
|
||||
OrderID: 0,
|
||||
OrderNo: paymentNo,
|
||||
UserID: userID,
|
||||
Provider: runtimeConfig.Provider,
|
||||
MerchantID: runtimeConfig.MerchantID,
|
||||
ThirdOrderID: paymentNo,
|
||||
ProviderOrderID: "",
|
||||
PayWay: firstNonEmpty(req.PayWay, runtimeConfig.PayWay, "ZFBZF"),
|
||||
JSPayFlag: firstNonEmpty(req.JSPayFlag, runtimeConfig.JSPayFlag, "2"),
|
||||
AmountCent: amountCent,
|
||||
BizType: "wallet_recharge",
|
||||
Status: "created",
|
||||
}
|
||||
if runtimeConfig.isMockMode() {
|
||||
payment.ProviderOrderID = "MOCK" + paymentNo
|
||||
payment.TDCode = "mock://payment/recharge/" + paymentNo
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&payment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &payment, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,39 +6,6 @@ import (
|
||||
"hfb_sys/backend/internal/model"
|
||||
)
|
||||
|
||||
func (r *Repository) QueryWalletRecharge(ctx context.Context, userID uint64, paymentID uint64) (*PaymentDTO, error) {
|
||||
var payment model.PaymentOrder
|
||||
if err := r.db.WithContext(ctx).Where("id = ? AND user_id = ? AND order_id = 0", paymentID, userID).First(&payment).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
runtimeConfig, err := r.runtimeConfigForPayment(ctx, &payment)
|
||||
if err != nil {
|
||||
return nil, ErrPaymentUnavailable
|
||||
}
|
||||
if payment.Status == "paid" || runtimeConfig.isMockMode() {
|
||||
dto := toDTO(payment)
|
||||
return &dto, nil
|
||||
}
|
||||
if runtimeConfig.Channel == nil {
|
||||
return nil, ErrPaymentUnavailable
|
||||
}
|
||||
resp, err := runtimeConfig.Channel.QueryPayment(ctx, payment.ThirdOrderID, payment.ProviderOrderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.applyChannelStatus(ctx, &payment, resp.Status, resp.PayTime, resp.Raw, channelSourceQuery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
latest, err := r.findPaymentByID(ctx, payment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dto := toDTO(*latest)
|
||||
return &dto, nil
|
||||
}
|
||||
func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
|
||||
var payment model.PaymentOrder
|
||||
if err := r.db.WithContext(ctx).Where("order_id = ? AND user_id = ? AND biz_type = ?", orderID, userID, "order_pay").Order("id DESC").First(&payment).Error; err != nil {
|
||||
|
||||
@@ -8,14 +8,12 @@ import (
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/internal/modules/order"
|
||||
"hfb_sys/backend/internal/modules/paymentconfig"
|
||||
"hfb_sys/backend/internal/modules/wallet"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
configRepo *paymentconfig.Repository
|
||||
orderRepo *order.Repository
|
||||
walletRepo *wallet.Repository
|
||||
}
|
||||
|
||||
type runtimePaymentConfig struct {
|
||||
@@ -46,12 +44,11 @@ var refundBizTypes = []string{
|
||||
"arbitration_refund",
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo *order.Repository, walletRepo *wallet.Repository) *Repository {
|
||||
func NewRepository(db *gorm.DB, configRepo *paymentconfig.Repository, orderRepo *order.Repository) *Repository {
|
||||
return &Repository{
|
||||
db: db,
|
||||
configRepo: configRepo,
|
||||
orderRepo: orderRepo,
|
||||
walletRepo: walletRepo,
|
||||
}
|
||||
}
|
||||
func (c runtimePaymentConfig) isMockMode() bool {
|
||||
|
||||
@@ -271,39 +271,6 @@ func TestRefundAmountMustNotExceedOriginal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWalletRechargeEnabled 测试钱包充值开关
|
||||
func TestWalletRechargeEnabledInDevelopment(t *testing.T) {
|
||||
svc := NewService(nil, "development")
|
||||
if !svc.walletRechargeEnabled {
|
||||
t.Fatal("wallet recharge should be enabled in development")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletRechargeDisabledInProduction(t *testing.T) {
|
||||
svc := NewService(nil, "production")
|
||||
if svc.walletRechargeEnabled {
|
||||
t.Fatal("wallet recharge should be disabled in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletRechargeEnabledInTestEnv(t *testing.T) {
|
||||
svc := NewService(nil, "test")
|
||||
if !svc.walletRechargeEnabled {
|
||||
t.Fatal("wallet recharge should be enabled in test env")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinWalletRechargeAmountIsReasonable 测试最小充值金额合理性
|
||||
func TestMinWalletRechargeAmountIsReasonable(t *testing.T) {
|
||||
if MinWalletRechargeAmount <= 0 {
|
||||
t.Fatal("MinWalletRechargeAmount should be positive")
|
||||
}
|
||||
|
||||
if MinWalletRechargeAmount > 1.0 {
|
||||
t.Fatalf("MinWalletRechargeAmount = %.2f, seems too high for minimum", MinWalletRechargeAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentNotifyResultStructure 测试支付回调结果结构
|
||||
func TestNotifyResultHasRequiredFields(t *testing.T) {
|
||||
result := NotifyResult{
|
||||
|
||||
@@ -146,13 +146,6 @@ func TestPaymentNoUniquenessAssumption(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinWalletRechargeAmount 测试最小充值金额常量
|
||||
func TestMinWalletRechargeAmountIsPositive(t *testing.T) {
|
||||
if MinWalletRechargeAmount <= 0 {
|
||||
t.Fatal("MinWalletRechargeAmount should be positive")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentDTOValidation 测试支付 DTO 基本结构
|
||||
func TestPaymentDTOHasRequiredFields(t *testing.T) {
|
||||
dto := PaymentDTO{
|
||||
@@ -293,7 +286,6 @@ func TestPaymentErrorsAreDefined(t *testing.T) {
|
||||
ErrPaymentVerifyFailed,
|
||||
ErrPaymentNotFound,
|
||||
ErrRefundCannotStart,
|
||||
ErrWalletRechargeDisabled,
|
||||
}
|
||||
|
||||
for i, err := range errors {
|
||||
|
||||
@@ -3,35 +3,23 @@ package payment
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrPaymentUnavailable = errors.New("payment unavailable")
|
||||
ErrPaymentCannotStart = errors.New("payment cannot start")
|
||||
ErrPaymentVerifyFailed = errors.New("payment verify failed")
|
||||
ErrPaymentNotFound = errors.New("payment not found")
|
||||
ErrRefundCannotStart = errors.New("refund cannot start")
|
||||
ErrWalletRechargeDisabled = errors.New("wallet recharge disabled")
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrPaymentUnavailable = errors.New("payment unavailable")
|
||||
ErrPaymentCannotStart = errors.New("payment cannot start")
|
||||
ErrPaymentVerifyFailed = errors.New("payment verify failed")
|
||||
ErrPaymentNotFound = errors.New("payment not found")
|
||||
ErrRefundCannotStart = errors.New("refund cannot start")
|
||||
)
|
||||
|
||||
const MinWalletRechargeAmount = 0.01
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
walletRechargeEnabled bool
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, appEnv ...string) *Service {
|
||||
env := "production"
|
||||
if len(appEnv) > 0 {
|
||||
env = strings.ToLower(strings.TrimSpace(appEnv[0]))
|
||||
}
|
||||
return &Service{
|
||||
repo: repo,
|
||||
walletRechargeEnabled: env != "production",
|
||||
}
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) {
|
||||
@@ -54,26 +42,6 @@ func (s *Service) Query(ctx context.Context, userID uint64, orderID uint64) (*Pa
|
||||
return s.repo.Query(ctx, userID, orderID)
|
||||
}
|
||||
|
||||
func (s *Service) StartWalletRecharge(ctx context.Context, userID uint64, req WalletRechargePaymentRequest, clientIP string) (*PaymentDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if !s.walletRechargeEnabled {
|
||||
return nil, ErrWalletRechargeDisabled
|
||||
}
|
||||
return s.repo.StartWalletRecharge(ctx, userID, req, clientIP)
|
||||
}
|
||||
|
||||
func (s *Service) QueryWalletRecharge(ctx context.Context, userID uint64, paymentID uint64) (*PaymentDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
if userID == 0 || paymentID == 0 {
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
return s.repo.QueryWalletRecharge(ctx, userID, paymentID)
|
||||
}
|
||||
|
||||
func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -71,21 +71,6 @@ func TestServiceStartRefundWithInvalidParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWalletRechargeDisabledInProduction(t *testing.T) {
|
||||
svc := NewService(&Repository{}, "production")
|
||||
_, err := svc.StartWalletRecharge(t.Context(), 1, WalletRechargePaymentRequest{AmountCent: 1000}, "127.0.0.1")
|
||||
if !errors.Is(err, ErrWalletRechargeDisabled) {
|
||||
t.Fatalf("StartWalletRecharge() error = %v, want ErrWalletRechargeDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWalletRechargeEnabledInDevelopment(t *testing.T) {
|
||||
svc := NewService(&Repository{}, "development")
|
||||
if svc.walletRechargeEnabled != true {
|
||||
t.Fatal("walletRechargeEnabled should be true in development")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundBizTypeConstants 测试退款业务类型常量
|
||||
func TestRefundBizTypesContainsExpectedValues(t *testing.T) {
|
||||
expected := []string{
|
||||
|
||||
@@ -9,10 +9,6 @@ type AccountDTO struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type RechargeRequest struct {
|
||||
AmountCent int64 `json:"amount_cent" binding:"required"`
|
||||
}
|
||||
|
||||
type WithdrawRequest struct {
|
||||
AmountCent int64 `json:"amount_cent" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -62,25 +62,6 @@ func (h *Handler) Ledger(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Recharge(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少用户上下文")
|
||||
return
|
||||
}
|
||||
var req RechargeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "充值金额不正确")
|
||||
return
|
||||
}
|
||||
account, err := h.service.Recharge(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
writeWalletError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, account)
|
||||
}
|
||||
|
||||
func (h *Handler) Withdraw(c *gin.Context) {
|
||||
userID, ok := currentUserID(c)
|
||||
if !ok {
|
||||
@@ -155,8 +136,6 @@ func writeWalletError(c *gin.Context, err error) {
|
||||
response.BadRequest(c, "充值金额不正确")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(c, 409, "insufficient_balance", "钱包余额不足")
|
||||
case errors.Is(err, ErrRechargeDisabled):
|
||||
response.Error(c, 410, "wallet_recharge_disabled", "钱包充值已关闭")
|
||||
case errors.Is(err, ErrFeaturePending):
|
||||
response.Error(c, 501, "feature_pending", "提现功能待开发")
|
||||
default:
|
||||
|
||||
@@ -64,59 +64,6 @@ func (r *Repository) Ledger(ctx context.Context, userID uint64, page, pageSize i
|
||||
return &PaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Recharge(ctx context.Context, userID uint64, amountCent int64) (*AccountDTO, error) {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
Direction: "in",
|
||||
AmountCent: amountCent,
|
||||
BalanceType: "available",
|
||||
BizType: "dev_recharge",
|
||||
BizNo: "DEV",
|
||||
Remark: "开发环境充值",
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Account(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *Repository) ConfirmRechargeFromChannel(ctx context.Context, userID uint64, bizNo string, amountCent int64) error {
|
||||
if userID == 0 || amountCent <= 0 || bizNo == "" {
|
||||
return ErrInvalidAmount
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := ensureAccount(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
var account model.WalletAccount
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ?", userID).
|
||||
First(&account).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var existing int64
|
||||
if err := tx.Model(&model.WalletLedger{}).
|
||||
Where("user_id = ? AND biz_type = ? AND biz_no = ?", userID, "channel_recharge", bizNo).
|
||||
Count(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil
|
||||
}
|
||||
return AppendEntries(tx, Entry{
|
||||
UserID: userID,
|
||||
Direction: "in",
|
||||
AmountCent: amountCent,
|
||||
BalanceType: "available",
|
||||
BizType: "channel_recharge",
|
||||
BizNo: bizNo,
|
||||
Remark: "渠道充值入账",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Withdraw 保留仓库能力;当前公开提现入口在 service 层标记为待开发,不会调用到这里。
|
||||
func (r *Repository) Withdraw(ctx context.Context, userID uint64, amountCent int64) (*AccountDTO, error) {
|
||||
if userID == 0 || amountCent <= 0 {
|
||||
|
||||
@@ -64,113 +64,6 @@ func TestRepositoryAccountCreatesAccountIfNotExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepositoryRechargeIncreasesAvailableBalance 测试充值增加可用余额
|
||||
func TestRepositoryRechargeIncreasesAvailableBalance(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := uint64(1002)
|
||||
|
||||
// 第一次充值
|
||||
account, err := repo.Recharge(ctx, userID, 10000)
|
||||
if err != nil {
|
||||
t.Fatalf("Recharge() error = %v", err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 10000 {
|
||||
t.Fatalf("第一次充值后余额 = %d, want 10000", account.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
// 第二次充值
|
||||
account, err = repo.Recharge(ctx, userID, 5000)
|
||||
if err != nil {
|
||||
t.Fatalf("Recharge() error = %v", err)
|
||||
}
|
||||
if account.AvailableBalanceCent != 15000 {
|
||||
t.Fatalf("第二次充值后余额 = %d, want 15000", account.AvailableBalanceCent)
|
||||
}
|
||||
|
||||
// 验证账本记录
|
||||
ledger, err := repo.Ledger(ctx, userID, 1, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Ledger() error = %v", err)
|
||||
}
|
||||
if ledger.Total != 2 {
|
||||
t.Fatalf("账本记录数 = %d, want 2", ledger.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepositoryConfirmRechargeFromChannelIsIdempotent 测试渠道充值幂等性
|
||||
func TestRepositoryConfirmRechargeFromChannelIsIdempotent(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
repo := NewRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := uint64(1003)
|
||||
bizNo := "PAY123456"
|
||||
amount := int64(10000)
|
||||
|
||||
// 第一次确认充值
|
||||
err := repo.ConfirmRechargeFromChannel(ctx, userID, bizNo, amount)
|
||||
if err != nil {
|
||||
t.Fatalf("第一次 ConfirmRechargeFromChannel() error = %v", err)
|
||||
}
|
||||
|
||||
account, _ := repo.Account(ctx, userID)
|
||||
if account.AvailableBalanceCent != amount {
|
||||
t.Fatalf("第一次充值后余额 = %d, want %d", account.AvailableBalanceCent, amount)
|
||||
}
|
||||
|
||||
// 第二次确认充值(相同 bizNo)应该幂等,不重复入账
|
||||
err = repo.ConfirmRechargeFromChannel(ctx, userID, bizNo, amount)
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 ConfirmRechargeFromChannel() error = %v", err)
|
||||
}
|
||||
|
||||
account, _ = repo.Account(ctx, userID)
|
||||
if account.AvailableBalanceCent != amount {
|
||||
t.Fatalf("第二次充值后余额 = %d, want %d(应保持不变)", account.AvailableBalanceCent, amount)
|
||||
}
|
||||
|
||||
// 验证只有一条账本记录
|
||||
ledger, _ := repo.Ledger(ctx, userID, 1, 10)
|
||||
if ledger.Total != 1 {
|
||||
t.Fatalf("账本记录数 = %d, want 1(幂等)", ledger.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepositoryConfirmRechargeFromChannelRejectsInvalidParams 测试参数验证
|
||||
func TestRepositoryConfirmRechargeFromChannelRejectsInvalidParams(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer cleanupTestDB(t, db)
|
||||
|
||||
repo := NewRepository(db)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
userID uint64
|
||||
bizNo string
|
||||
amount int64
|
||||
wantError error
|
||||
}{
|
||||
{"userID 为 0", 0, "BIZ123", 1000, ErrInvalidAmount},
|
||||
{"bizNo 为空", 1004, "", 1000, ErrInvalidAmount},
|
||||
{"amount 为 0", 1004, "BIZ123", 0, ErrInvalidAmount},
|
||||
{"amount 为负数", 1004, "BIZ123", -1000, ErrInvalidAmount},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := repo.ConfirmRechargeFromChannel(context.Background(), tc.userID, tc.bizNo, tc.amount)
|
||||
if err != tc.wantError {
|
||||
t.Fatalf("error = %v, want %v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppendEntriesUpdatesBalanceCorrectly 测试 AppendEntries 余额计算
|
||||
func TestAppendEntriesUpdatesBalanceCorrectly(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
@@ -10,12 +10,8 @@ var (
|
||||
ErrInvalidAmount = errors.New("invalid amount")
|
||||
ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
ErrFeaturePending = errors.New("feature pending")
|
||||
ErrRechargeDisabled = errors.New("wallet recharge disabled")
|
||||
)
|
||||
|
||||
// MinRechargeAmountCent 最小充值金额:1分
|
||||
const MinRechargeAmountCent = 1
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
@@ -38,13 +34,6 @@ func (s *Service) Ledger(ctx context.Context, userID uint64, page, pageSize int)
|
||||
return s.repo.Ledger(ctx, userID, page, pageSize)
|
||||
}
|
||||
|
||||
func (s *Service) Recharge(ctx context.Context, userID uint64, req RechargeRequest) (*AccountDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return nil, ErrRechargeDisabled
|
||||
}
|
||||
|
||||
func (s *Service) Withdraw(ctx context.Context, userID uint64, req WithdrawRequest) (*AccountDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
|
||||
@@ -25,15 +25,6 @@ func TestServiceLedgerWithNilRepo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRechargeIsDisabled(t *testing.T) {
|
||||
svc := &Service{repo: &Repository{}}
|
||||
ctx := context.Background()
|
||||
_, err := svc.Recharge(ctx, 1, RechargeRequest{AmountCent: 100})
|
||||
if !errors.Is(err, ErrRechargeDisabled) {
|
||||
t.Fatalf("Recharge() error = %v, want ErrRechargeDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWithdrawIsPending(t *testing.T) {
|
||||
svc := &Service{repo: &Repository{}}
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -186,9 +186,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
}
|
||||
|
||||
if deps.DB != nil {
|
||||
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo, walletRepo)
|
||||
paymentRepo = payment.NewRepository(deps.DB, paymentConfigRepo, orderRepo)
|
||||
}
|
||||
paymentService := payment.NewService(paymentRepo, cfg.AppEnv)
|
||||
paymentService := payment.NewService(paymentRepo)
|
||||
paymentHandler := payment.NewHandler(paymentService)
|
||||
var notificationRepo *notification.Repository
|
||||
if deps.DB != nil {
|
||||
@@ -363,9 +363,6 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
{
|
||||
walletRoutes.GET("/balance", walletHandler.Balance)
|
||||
walletRoutes.GET("/ledger", walletHandler.Ledger)
|
||||
walletRoutes.POST("/recharge", walletHandler.Recharge)
|
||||
walletRoutes.POST("/recharge/pay", paymentHandler.WalletRecharge)
|
||||
walletRoutes.POST("/recharge/pay/:id/query", paymentHandler.WalletRechargeQuery)
|
||||
walletRoutes.POST("/withdraw", walletHandler.Withdraw)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user