删除钱包充值功能并消除重复入账风险

钱包充值为开发态测试功能(生产环境本就禁用),且支付回调存在重复入账风险:入账与标记 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:
yml2213
2026-06-14 02:45:46 +08:00
co-authored by Claude Opus 4.8
parent 3ef7705776
commit e8621728fd
23 changed files with 28 additions and 863 deletions
@@ -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{
-6
View File
@@ -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
}
-33
View File
@@ -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 {
+9 -41
View File
@@ -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{