删除钱包充值功能并消除重复入账风险
钱包充值为开发态测试功能(生产环境本就禁用),且支付回调存在重复入账风险:入账与标记 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,6 @@ function paymentBizTypeLabel(type: string) {
|
||||
admin_refund: '人工退款',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
wallet_recharge: '钱包充值',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const filters = reactive({
|
||||
|
||||
const payAmount = computed(() =>
|
||||
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)
|
||||
)
|
||||
const refundAmount = computed(() =>
|
||||
@@ -97,7 +97,6 @@ function paymentStatusLabel(status: string) {
|
||||
function bizTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
order_pay: '订单支付',
|
||||
wallet_recharge: '钱包充值',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
admin_refund: '人工退款',
|
||||
@@ -172,7 +171,6 @@ function jsonText(value: unknown) {
|
||||
<el-option label="仲裁退款" value="arbitration_refund" />
|
||||
<el-option label="人工退款" value="admin_refund" />
|
||||
<el-option label="取消退款" value="cancel_refund" />
|
||||
<el-option label="钱包充值" value="wallet_recharge" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
|
||||
@@ -2,8 +2,6 @@ import { apiClient } from '@/shared/api/client'
|
||||
|
||||
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
|
||||
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 {
|
||||
user_id: number
|
||||
@@ -41,26 +39,3 @@ export async function fetchWalletLedger(page = 1, pageSize = 20) {
|
||||
)
|
||||
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">
|
||||
import { readError } from '@/shared/utils/error'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
CircleCheck,
|
||||
Coin,
|
||||
Loading,
|
||||
Lock,
|
||||
Money,
|
||||
Refresh,
|
||||
Tickets,
|
||||
Wallet as WalletIcon,
|
||||
} from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import {
|
||||
fetchWalletBalance,
|
||||
fetchWalletLedger,
|
||||
queryWalletRechargePayment,
|
||||
startWalletRechargePayment,
|
||||
type WalletAccount,
|
||||
type WalletLedger,
|
||||
} from '@/features/wallet'
|
||||
import type { PaymentOrder } from '@/features/orders'
|
||||
import {
|
||||
balanceTypeLabel,
|
||||
ledgerDirectionLabel,
|
||||
walletStatusLabel,
|
||||
} from '@/shared/utils/statusLabels'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
import { formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
||||
import { formatCentWithSymbol } from '@/shared/utils/money'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
@@ -39,16 +31,6 @@ const ledger = ref<WalletLedger[]>([])
|
||||
const currentPage = ref(1)
|
||||
const currentPageSize = ref(20)
|
||||
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(() => {
|
||||
if (!account.value) {
|
||||
@@ -80,13 +62,6 @@ const walletMetrics = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(loadWallet)
|
||||
onBeforeUnmount(stopRechargePolling)
|
||||
|
||||
watch(rechargeDialogVisible, visible => {
|
||||
if (!visible) {
|
||||
stopRechargePolling()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadWallet() {
|
||||
loading.value = true
|
||||
@@ -116,114 +91,8 @@ function handleWithdraw() {
|
||||
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) {
|
||||
const map: Record<string, string> = {
|
||||
dev_recharge: '测试充值',
|
||||
channel_recharge: '渠道充值',
|
||||
order_pay: '订单支付',
|
||||
order_lock: '订单冻结',
|
||||
channel_order_lock: '支付冻结',
|
||||
@@ -302,40 +171,6 @@ function amountPrefix(direction: string) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="panel-title">
|
||||
<div class="panel-title-icon is-blue">
|
||||
@@ -398,44 +233,6 @@ function amountPrefix(direction: string) {
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -569,7 +366,6 @@ function amountPrefix(direction: string) {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.recharge-panel,
|
||||
.ledger-summary-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -612,11 +408,6 @@ function amountPrefix(direction: string) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.panel-title-icon.is-orange {
|
||||
background: #fff4ec;
|
||||
color: #ff6b00;
|
||||
}
|
||||
|
||||
.panel-title h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
@@ -629,101 +420,6 @@ function amountPrefix(direction: string) {
|
||||
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 {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e6eaf2;
|
||||
@@ -937,14 +633,6 @@ function amountPrefix(direction: string) {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.recharge-action-row :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recharge-action-row :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pay-qr-panel {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
|
||||
Reference in New Issue
Block a user