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

钱包充值为开发态测试功能(生产环境本就禁用),且支付回调存在重复入账风险:入账与标记 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
+2 -2
View File
@@ -216,7 +216,7 @@ func newFlowServices(db *gorm.DB) flowServices {
return refund.Status, nil return refund.Status, nil
}), }),
}) })
paymentRepo = payment.NewRepository(db, configRepo, orderRepo, walletRepo) paymentRepo = payment.NewRepository(db, configRepo, orderRepo)
disputeRepo := dispute.NewRepository(db, dispute.Dependencies{ disputeRepo := dispute.NewRepository(db, dispute.Dependencies{
RefundStarter: dispute.RefundStarterFunc(func(ctx context.Context, orderID uint64, refundAmountCent int64, bizType string, remark string) (string, error) { 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) refund, err := paymentRepo.StartRefund(ctx, orderID, refundAmountCent, bizType, remark)
@@ -230,7 +230,7 @@ func newFlowServices(db *gorm.DB) flowServices {
return flowServices{ return flowServices{
listing: listing.NewService(listingRepo, fixedConfig{"listing.review_required": "true"}), listing: listing.NewService(listingRepo, fixedConfig{"listing.review_required": "true"}),
order: order.NewService(orderRepo), order: order.NewService(orderRepo),
payment: payment.NewService(paymentRepo, "development"), payment: payment.NewService(paymentRepo),
paymentConfig: paymentconfig.NewService(configRepo), paymentConfig: paymentconfig.NewService(configRepo),
paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db)), paymentAccount: paymentaccount.NewService(paymentaccount.NewRepository(db)),
dispute: dispute.NewService(disputeRepo), dispute: dispute.NewService(disputeRepo),
@@ -26,10 +26,10 @@ func (r *Repository) summary(ctx context.Context, query DashboardQuery) (*Financ
db := r.db.WithContext(ctx) db := r.db.WithContext(ctx)
var payment paymentSummaryRow var payment paymentSummaryRow
if err := db.Table("payment_orders"). 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 = '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 ? 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 = '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`, COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()).
@@ -84,10 +84,10 @@ func (r *Repository) dailyItems(ctx context.Context, query DashboardQuery) ([]Fi
payments := make([]dailyPaymentRow, 0) payments := make([]dailyPaymentRow, 0)
if err := db.Table("payment_orders"). if err := db.Table("payment_orders").
Select(`DATE(created_at) AS date, 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 = '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 ? 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 = '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`, COALESCE(SUM(CASE WHEN biz_type IN ? AND status = 'refunding' THEN 1 ELSE 0 END), 0) AS pending_refund_count`,
refundBizTypes(), refundBizTypes(), refundBizTypes(), refundBizTypes()). 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 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 { 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.Status != "paid" && payment.OrderID != 0 {
if payment.OrderID == 0 { if r.orderRepo == nil {
if r.walletRepo == nil { return ErrDependencyUnavailable
return ErrDependencyUnavailable }
} if err := r.orderRepo.ConfirmPaidFromChannel(ctx, payment.OrderID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo)); err != nil {
if err := r.walletRepo.ConfirmRechargeFromChannel(ctx, payment.UserID, firstNonEmpty(payment.ProviderOrderID, payment.PaymentNo), payment.AmountCent); err != nil { return err
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
}
} }
} }
updates := map[string]any{ updates := map[string]any{
-6
View File
@@ -11,12 +11,6 @@ type StartPaymentRequest struct {
JSPayFlag string `json:"jspay_flag"` 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 { type PaymentDTO struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
PaymentNo string `json:"payment_no"` PaymentNo string `json:"payment_no"`
@@ -63,43 +63,6 @@ func (h *Handler) Query(c *gin.Context) {
response.OK(c, item) 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) { func (h *Handler) QueryRefundStatus(c *gin.Context) {
orderID, ok := parseID(c) orderID, ok := parseID(c)
if !ok { if !ok {
@@ -248,8 +211,6 @@ func writePaymentError(c *gin.Context, err error) {
response.Error(c, http.StatusConflict, "payment_cannot_start", "当前订单不能支付") response.Error(c, http.StatusConflict, "payment_cannot_start", "当前订单不能支付")
case errors.Is(err, ErrRefundCannotStart): case errors.Is(err, ErrRefundCannotStart):
response.Error(c, http.StatusConflict, "refund_cannot_start", "当前订单不能退款") 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): case errors.Is(err, ErrPaymentVerifyFailed):
response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败") response.Error(c, http.StatusForbidden, "payment_verify_failed", "支付通知验签失败")
case errors.Is(err, ErrPaymentNotFound), errors.Is(err, gorm.ErrRecordNotFound), order.IsNotFound(err): 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) dto := toDTO(*latest)
return &dto, nil 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) { func (r *Repository) preparePayment(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, runtimeConfig runtimePaymentConfig) (*model.PaymentOrder, *model.RentalOrder, error) {
var paymentID uint64 var paymentID uint64
var orderRow model.RentalOrder var orderRow model.RentalOrder
@@ -304,32 +223,4 @@ func newOrderPayment(row model.RentalOrder, amountCent int64, req StartPaymentRe
} }
return payment, nil 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" "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) { func (r *Repository) Query(ctx context.Context, userID uint64, orderID uint64) (*PaymentDTO, error) {
var payment model.PaymentOrder 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 { 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/model"
"hfb_sys/backend/internal/modules/order" "hfb_sys/backend/internal/modules/order"
"hfb_sys/backend/internal/modules/paymentconfig" "hfb_sys/backend/internal/modules/paymentconfig"
"hfb_sys/backend/internal/modules/wallet"
) )
type Repository struct { type Repository struct {
db *gorm.DB db *gorm.DB
configRepo *paymentconfig.Repository configRepo *paymentconfig.Repository
orderRepo *order.Repository orderRepo *order.Repository
walletRepo *wallet.Repository
} }
type runtimePaymentConfig struct { type runtimePaymentConfig struct {
@@ -46,12 +44,11 @@ var refundBizTypes = []string{
"arbitration_refund", "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{ return &Repository{
db: db, db: db,
configRepo: configRepo, configRepo: configRepo,
orderRepo: orderRepo, orderRepo: orderRepo,
walletRepo: walletRepo,
} }
} }
func (c runtimePaymentConfig) isMockMode() bool { 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 测试支付回调结果结构 // TestPaymentNotifyResultStructure 测试支付回调结果结构
func TestNotifyResultHasRequiredFields(t *testing.T) { func TestNotifyResultHasRequiredFields(t *testing.T) {
result := NotifyResult{ 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 基本结构 // TestPaymentDTOValidation 测试支付 DTO 基本结构
func TestPaymentDTOHasRequiredFields(t *testing.T) { func TestPaymentDTOHasRequiredFields(t *testing.T) {
dto := PaymentDTO{ dto := PaymentDTO{
@@ -293,7 +286,6 @@ func TestPaymentErrorsAreDefined(t *testing.T) {
ErrPaymentVerifyFailed, ErrPaymentVerifyFailed,
ErrPaymentNotFound, ErrPaymentNotFound,
ErrRefundCannotStart, ErrRefundCannotStart,
ErrWalletRechargeDisabled,
} }
for i, err := range errors { for i, err := range errors {
+9 -41
View File
@@ -3,35 +3,23 @@ package payment
import ( import (
"context" "context"
"errors" "errors"
"strings"
) )
var ( var (
ErrDependencyUnavailable = errors.New("dependency unavailable") ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrPaymentUnavailable = errors.New("payment unavailable") ErrPaymentUnavailable = errors.New("payment unavailable")
ErrPaymentCannotStart = errors.New("payment cannot start") ErrPaymentCannotStart = errors.New("payment cannot start")
ErrPaymentVerifyFailed = errors.New("payment verify failed") ErrPaymentVerifyFailed = errors.New("payment verify failed")
ErrPaymentNotFound = errors.New("payment not found") ErrPaymentNotFound = errors.New("payment not found")
ErrRefundCannotStart = errors.New("refund cannot start") ErrRefundCannotStart = errors.New("refund cannot start")
ErrWalletRechargeDisabled = errors.New("wallet recharge disabled")
) )
const MinWalletRechargeAmount = 0.01
type Service struct { type Service struct {
repo *Repository repo *Repository
walletRechargeEnabled bool
} }
func NewService(repo *Repository, appEnv ...string) *Service { func NewService(repo *Repository) *Service {
env := "production" return &Service{repo: repo}
if len(appEnv) > 0 {
env = strings.ToLower(strings.TrimSpace(appEnv[0]))
}
return &Service{
repo: repo,
walletRechargeEnabled: env != "production",
}
} }
func (s *Service) Start(ctx context.Context, userID uint64, orderID uint64, req StartPaymentRequest, clientIP string) (*PaymentDTO, error) { 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) 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) { func (s *Service) HandleLeshuaNotify(ctx context.Context, params map[string]string, rawPayload string, contentType string) (*NotifyResult, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable 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 测试退款业务类型常量 // TestRefundBizTypeConstants 测试退款业务类型常量
func TestRefundBizTypesContainsExpectedValues(t *testing.T) { func TestRefundBizTypesContainsExpectedValues(t *testing.T) {
expected := []string{ expected := []string{
-4
View File
@@ -9,10 +9,6 @@ type AccountDTO struct {
Status string `json:"status"` Status string `json:"status"`
} }
type RechargeRequest struct {
AmountCent int64 `json:"amount_cent" binding:"required"`
}
type WithdrawRequest struct { type WithdrawRequest struct {
AmountCent int64 `json:"amount_cent" binding:"required"` AmountCent int64 `json:"amount_cent" binding:"required"`
} }
@@ -62,25 +62,6 @@ func (h *Handler) Ledger(c *gin.Context) {
response.OK(c, result) 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) { func (h *Handler) Withdraw(c *gin.Context) {
userID, ok := currentUserID(c) userID, ok := currentUserID(c)
if !ok { if !ok {
@@ -155,8 +136,6 @@ func writeWalletError(c *gin.Context, err error) {
response.BadRequest(c, "充值金额不正确") response.BadRequest(c, "充值金额不正确")
case errors.Is(err, ErrInsufficientBalance): case errors.Is(err, ErrInsufficientBalance):
response.Error(c, 409, "insufficient_balance", "钱包余额不足") response.Error(c, 409, "insufficient_balance", "钱包余额不足")
case errors.Is(err, ErrRechargeDisabled):
response.Error(c, 410, "wallet_recharge_disabled", "钱包充值已关闭")
case errors.Is(err, ErrFeaturePending): case errors.Is(err, ErrFeaturePending):
response.Error(c, 501, "feature_pending", "提现功能待开发") response.Error(c, 501, "feature_pending", "提现功能待开发")
default: 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 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 层标记为待开发,不会调用到这里。 // Withdraw 保留仓库能力;当前公开提现入口在 service 层标记为待开发,不会调用到这里。
func (r *Repository) Withdraw(ctx context.Context, userID uint64, amountCent int64) (*AccountDTO, error) { func (r *Repository) Withdraw(ctx context.Context, userID uint64, amountCent int64) (*AccountDTO, error) {
if userID == 0 || amountCent <= 0 { 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 余额计算 // TestAppendEntriesUpdatesBalanceCorrectly 测试 AppendEntries 余额计算
func TestAppendEntriesUpdatesBalanceCorrectly(t *testing.T) { func TestAppendEntriesUpdatesBalanceCorrectly(t *testing.T) {
db := setupTestDB(t) db := setupTestDB(t)
@@ -10,12 +10,8 @@ var (
ErrInvalidAmount = errors.New("invalid amount") ErrInvalidAmount = errors.New("invalid amount")
ErrInsufficientBalance = errors.New("insufficient balance") ErrInsufficientBalance = errors.New("insufficient balance")
ErrFeaturePending = errors.New("feature pending") ErrFeaturePending = errors.New("feature pending")
ErrRechargeDisabled = errors.New("wallet recharge disabled")
) )
// MinRechargeAmountCent 最小充值金额:1分
const MinRechargeAmountCent = 1
type Service struct { type Service struct {
repo *Repository 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) 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) { func (s *Service) Withdraw(ctx context.Context, userID uint64, req WithdrawRequest) (*AccountDTO, error) {
if s.repo == nil { if s.repo == nil {
return nil, ErrDependencyUnavailable 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) { func TestServiceWithdrawIsPending(t *testing.T) {
svc := &Service{repo: &Repository{}} svc := &Service{repo: &Repository{}}
ctx := context.Background() ctx := context.Background()
+2 -5
View File
@@ -186,9 +186,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
} }
if deps.DB != nil { 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) paymentHandler := payment.NewHandler(paymentService)
var notificationRepo *notification.Repository var notificationRepo *notification.Repository
if deps.DB != nil { 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("/balance", walletHandler.Balance)
walletRoutes.GET("/ledger", walletHandler.Ledger) 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) walletRoutes.POST("/withdraw", walletHandler.Withdraw)
} }
@@ -162,7 +162,6 @@ function paymentBizTypeLabel(type: string) {
admin_refund: '人工退款', admin_refund: '人工退款',
cancel_refund: '取消退款', cancel_refund: '取消退款',
admin_close_refund: '客服关闭退款', admin_close_refund: '客服关闭退款',
wallet_recharge: '钱包充值',
} }
return map[type] || type return map[type] || type
} }
@@ -26,7 +26,7 @@ const filters = reactive({
const payAmount = computed(() => const payAmount = computed(() =>
payments.value payments.value
.filter(item => item.biz_type === 'order_pay' || item.biz_type === 'wallet_recharge') .filter(item => item.biz_type === 'order_pay')
.reduce((sum, item) => sum + Number(item.amount_cent || 0), 0) .reduce((sum, item) => sum + Number(item.amount_cent || 0), 0)
) )
const refundAmount = computed(() => const refundAmount = computed(() =>
@@ -97,7 +97,6 @@ function paymentStatusLabel(status: string) {
function bizTypeLabel(type: string) { function bizTypeLabel(type: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
order_pay: '订单支付', order_pay: '订单支付',
wallet_recharge: '钱包充值',
cancel_refund: '取消退款', cancel_refund: '取消退款',
admin_close_refund: '客服关闭退款', admin_close_refund: '客服关闭退款',
admin_refund: '人工退款', admin_refund: '人工退款',
@@ -172,7 +171,6 @@ function jsonText(value: unknown) {
<el-option label="仲裁退款" value="arbitration_refund" /> <el-option label="仲裁退款" value="arbitration_refund" />
<el-option label="人工退款" value="admin_refund" /> <el-option label="人工退款" value="admin_refund" />
<el-option label="取消退款" value="cancel_refund" /> <el-option label="取消退款" value="cancel_refund" />
<el-option label="钱包充值" value="wallet_recharge" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
@@ -2,8 +2,6 @@ import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types' import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
import type { BalanceType, LedgerDirection, WalletStatus } from '@/shared/types/status' import type { BalanceType, LedgerDirection, WalletStatus } from '@/shared/types/status'
import type { PaymentOrder } from '@/features/orders/api/orders'
import { yuanToCent } from '@/shared/utils/money'
export interface WalletAccount { export interface WalletAccount {
user_id: number user_id: number
@@ -41,26 +39,3 @@ export async function fetchWalletLedger(page = 1, pageSize = 20) {
) )
return data.data return data.data
} }
export async function rechargeWallet(amountYuan: number) {
const amount_cent = yuanToCent(amountYuan)
const { data } = await apiClient.post<ApiResponse<WalletAccount>>('/wallet/recharge', {
amount_cent,
})
return data.data
}
export async function startWalletRechargePayment(amountYuan: number) {
const amount_cent = yuanToCent(amountYuan)
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>('/wallet/recharge/pay', {
amount_cent,
})
return data.data
}
export async function queryWalletRechargePayment(id: number) {
const { data } = await apiClient.post<ApiResponse<PaymentOrder>>(
`/wallet/recharge/pay/${id}/query`
)
return data.data
}
@@ -1,36 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { readError } from '@/shared/utils/error' import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { import {
CircleCheck, CircleCheck,
Coin,
Loading,
Lock, Lock,
Money, Money,
Refresh, Refresh,
Tickets, Tickets,
Wallet as WalletIcon, Wallet as WalletIcon,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { import {
fetchWalletBalance, fetchWalletBalance,
fetchWalletLedger, fetchWalletLedger,
queryWalletRechargePayment,
startWalletRechargePayment,
type WalletAccount, type WalletAccount,
type WalletLedger, type WalletLedger,
} from '@/features/wallet' } from '@/features/wallet'
import type { PaymentOrder } from '@/features/orders'
import { import {
balanceTypeLabel, balanceTypeLabel,
ledgerDirectionLabel, ledgerDirectionLabel,
walletStatusLabel, walletStatusLabel,
} from '@/shared/utils/statusLabels' } from '@/shared/utils/statusLabels'
import { formatDateTime } from '@/shared/utils/time' import { formatDateTime } from '@/shared/utils/time'
import { formatCentWithSymbol, formatMoney } from '@/shared/utils/money' import { formatCentWithSymbol } from '@/shared/utils/money'
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@@ -39,16 +31,6 @@ const ledger = ref<WalletLedger[]>([])
const currentPage = ref(1) const currentPage = ref(1)
const currentPageSize = ref(20) const currentPageSize = ref(20)
const total = ref(0) const total = ref(0)
const isDev = import.meta.env.DEV
const quickRechargeAmounts = [0.01, 1, 10, 100]
const devRechargeAmount = ref(0.01)
const devRecharging = ref(false)
const rechargeDialogVisible = ref(false)
const activeRechargePayment = ref<PaymentOrder | null>(null)
const rechargeQRCodeURL = ref('')
const rechargeQRGenerating = ref(false)
const checkingRecharge = ref(false)
let rechargePollingTimer: number | null = null
const walletMetrics = computed(() => { const walletMetrics = computed(() => {
if (!account.value) { if (!account.value) {
@@ -80,13 +62,6 @@ const walletMetrics = computed(() => {
}) })
onMounted(loadWallet) onMounted(loadWallet)
onBeforeUnmount(stopRechargePolling)
watch(rechargeDialogVisible, visible => {
if (!visible) {
stopRechargePolling()
}
})
async function loadWallet() { async function loadWallet() {
loading.value = true loading.value = true
@@ -116,114 +91,8 @@ function handleWithdraw() {
router.push('/wallet/withdrawal') router.push('/wallet/withdrawal')
} }
function selectDevRechargeAmount(amount: number) {
devRechargeAmount.value = amount
}
function rechargePayURL(payment = activeRechargePayment.value) {
return payment?.jspay_url || payment?.td_code || payment?.jspay_info || ''
}
async function renderRechargeQRCode(payment = activeRechargePayment.value) {
const payURL = rechargePayURL(payment)
rechargeQRCodeURL.value = ''
if (!payURL) return
rechargeQRGenerating.value = true
try {
rechargeQRCodeURL.value = await QRCode.toDataURL(payURL, {
width: 220,
margin: 1,
errorCorrectionLevel: 'M',
color: {
dark: '#111827',
light: '#ffffff',
},
})
} catch {
ElMessage.error('二维码生成失败')
} finally {
rechargeQRGenerating.value = false
}
}
async function handleDevRecharge() {
if (!isDev) return
if (devRechargeAmount.value <= 0) {
ElMessage.warning('请输入充值金额')
return
}
devRecharging.value = true
try {
const payment = await startWalletRechargePayment(devRechargeAmount.value)
activeRechargePayment.value = payment
rechargeDialogVisible.value = true
await renderRechargeQRCode(payment)
if (payment.paid) {
ElMessage.success('充值成功')
rechargeDialogVisible.value = false
await loadWallet()
} else {
startRechargePolling()
ElMessage.success('测试支付单已创建')
}
} catch (error) {
ElMessage.error(readError(error, '创建测试充值失败'))
} finally {
devRecharging.value = false
}
}
async function checkDevRechargeStatus(options: { silent?: boolean } = {}) {
if (!activeRechargePayment.value || checkingRecharge.value) return
const silent = options.silent === true
checkingRecharge.value = true
try {
const payment = await queryWalletRechargePayment(activeRechargePayment.value.id)
activeRechargePayment.value = payment
await renderRechargeQRCode(payment)
if (payment.paid) {
stopRechargePolling()
ElMessage.success('充值成功')
rechargeDialogVisible.value = false
await loadWallet()
} else {
if (!silent) {
ElMessage.info('支付暂未完成')
}
}
} catch (error) {
if (!silent) {
ElMessage.error(readError(error, '查询支付状态失败'))
}
} finally {
checkingRecharge.value = false
}
}
function startRechargePolling() {
stopRechargePolling()
rechargePollingTimer = window.setInterval(() => {
void checkDevRechargeStatus({ silent: true })
}, 2000)
}
function stopRechargePolling() {
if (!rechargePollingTimer) return
window.clearInterval(rechargePollingTimer)
rechargePollingTimer = null
}
function openRechargePayURL() {
const payURL = rechargePayURL()
if (!payURL) return
window.open(payURL, '_blank', 'noopener,noreferrer')
}
function walletBizTypeLabel(type: string) { function walletBizTypeLabel(type: string) {
const map: Record<string, string> = { const map: Record<string, string> = {
dev_recharge: '测试充值',
channel_recharge: '渠道充值',
order_pay: '订单支付', order_pay: '订单支付',
order_lock: '订单冻结', order_lock: '订单冻结',
channel_order_lock: '支付冻结', channel_order_lock: '支付冻结',
@@ -302,40 +171,6 @@ function amountPrefix(direction: string) {
</div> </div>
<div class="wallet-workspace"> <div class="wallet-workspace">
<section v-if="isDev" class="recharge-panel">
<div class="panel-title">
<div class="panel-title-icon is-orange">
<el-icon><Coin /></el-icon>
</div>
<div>
<h2>测试充值</h2>
<p>开发环境可见用于快速验证当前支付渠道配置</p>
</div>
</div>
<div class="quick-amounts">
<button
v-for="amount in quickRechargeAmounts"
:key="amount"
type="button"
:class="{ active: devRechargeAmount === amount }"
@click="selectDevRechargeAmount(amount)"
>
{{ formatMoney(amount) }}
</button>
</div>
<div class="recharge-action-row">
<el-input-number v-model="devRechargeAmount" :min="0.01" :precision="2" :step="1" />
<el-button
type="primary"
:icon="Coin"
:loading="devRecharging"
@click="handleDevRecharge"
>
创建测试支付
</el-button>
</div>
</section>
<section class="ledger-summary-card"> <section class="ledger-summary-card">
<div class="panel-title"> <div class="panel-title">
<div class="panel-title-icon is-blue"> <div class="panel-title-icon is-blue">
@@ -398,44 +233,6 @@ function amountPrefix(direction: string) {
/> />
</div> </div>
<el-dialog v-model="rechargeDialogVisible" title="测试充值支付" width="520px">
<div class="recharge-cashier">
<div class="cashier-amount">
<span>支付金额</span>
<strong>{{
activeRechargePayment
? formatCentWithSymbol(activeRechargePayment.amount_cent)
: `¥${formatMoney(devRechargeAmount)}`
}}</strong>
</div>
<div v-if="rechargePayURL()" class="cashier-qr">
<div class="qr-box">
<el-icon v-if="rechargeQRGenerating" class="qr-loading"><Loading /></el-icon>
<img v-else-if="rechargeQRCodeURL" :src="rechargeQRCodeURL" alt="测试充值支付二维码" />
<span v-else>支付链接已生成</span>
</div>
<el-button text type="primary" @click="openRechargePayURL">打开支付链接</el-button>
</div>
<el-alert
v-else
title="支付单已创建,但渠道未返回可展示的支付链接"
type="info"
:closable="false"
show-icon
/>
<div class="cashier-meta" v-if="activeRechargePayment">
<span>支付单号{{ activeRechargePayment.payment_no }}</span>
<span>渠道{{ activeRechargePayment.provider }}</span>
<span>状态{{ activeRechargePayment.status }}</span>
</div>
</div>
<template #footer>
<el-button @click="rechargeDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="checkingRecharge" @click="checkDevRechargeStatus"
>查询支付状态</el-button
>
</template>
</el-dialog>
</section> </section>
</template> </template>
@@ -569,7 +366,6 @@ function amountPrefix(direction: string) {
gap: 16px; gap: 16px;
} }
.recharge-panel,
.ledger-summary-card { .ledger-summary-card {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -612,11 +408,6 @@ function amountPrefix(direction: string) {
color: #2563eb; color: #2563eb;
} }
.panel-title-icon.is-orange {
background: #fff4ec;
color: #ff6b00;
}
.panel-title h2 { .panel-title h2 {
margin: 0; margin: 0;
color: #111827; color: #111827;
@@ -629,101 +420,6 @@ function amountPrefix(direction: string) {
font-size: 13px; font-size: 13px;
} }
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.quick-amounts button {
min-width: 92px;
height: 36px;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-weight: 600;
cursor: pointer;
}
.quick-amounts button.active,
.quick-amounts button:hover {
border-color: #ff8a3d;
background: #fff4ec;
color: #ea580c;
}
.recharge-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.recharge-cashier {
display: grid;
gap: 18px;
}
.cashier-amount {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #f8fafc;
}
.cashier-amount span,
.cashier-meta {
color: #64748b;
font-size: 13px;
}
.cashier-amount strong {
color: #111a44;
font-size: 24px;
}
.cashier-qr {
display: grid;
justify-items: center;
gap: 10px;
}
.qr-box {
display: grid;
width: 236px;
height: 236px;
place-items: center;
border: 1px solid #e6eaf2;
border-radius: 8px;
background: #ffffff;
}
.qr-box img {
width: 220px;
height: 220px;
}
.qr-loading {
color: #64748b;
font-size: 28px;
animation: spin 0.9s linear infinite;
}
.cashier-meta {
display: grid;
gap: 6px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.wallet-ledger-table { .wallet-ledger-table {
overflow-x: auto; overflow-x: auto;
border: 1px solid #e6eaf2; border: 1px solid #e6eaf2;
@@ -937,14 +633,6 @@ function amountPrefix(direction: string) {
min-height: auto; min-height: auto;
} }
.recharge-action-row :deep(.el-input-number) {
width: 100%;
}
.recharge-action-row :deep(.el-button) {
width: 100%;
}
.pay-qr-panel { .pay-qr-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
justify-items: center; justify-items: center;