删除钱包充值功能并消除重复入账风险
钱包充值为开发态测试功能(生产环境本就禁用),且支付回调存在重复入账风险:入账与标记 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
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user