Files
junhong_cmp_fiber/internal/service/agent_recharge/service.go
huang 89f9875a97 feat: 新增代理预充值模块(DTO、Service、Handler、路由)
- agent_recharge_dto.go: 创建/列表/详情请求响应 DTO
- service.go: 权限验证(代理只能充自己店铺)、金额范围校验、查询 active 配置、创建订单、线下充值确认(乐观锁+审计日志)、回调幂等处理
- agent_recharge.go Handler: Create/List/Get/OfflinePay 共 4 个方法
- agent_recharge.go 路由: 注册到 /api/admin/agent-recharges/*,路由层拦截企业账号

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:29:42 +08:00

507 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package agent_recharge 提供代理预充值的业务逻辑服务
// 包含充值订单创建、线下确认、支付回调处理、列表查询等功能
package agent_recharge
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// AuditServiceInterface 审计日志服务接口
type AuditServiceInterface interface {
LogOperation(ctx context.Context, log *model.AccountOperationLog)
}
// WechatConfigServiceInterface 支付配置服务接口
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
// Service 代理预充值业务服务
// 负责代理钱包充值订单的创建、线下确认、回调处理等业务逻辑
type Service struct {
db *gorm.DB
agentRechargeStore *postgres.AgentRechargeStore
agentWalletStore *postgres.AgentWalletStore
agentWalletTxStore *postgres.AgentWalletTransactionStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
wechatConfigService WechatConfigServiceInterface
auditService AuditServiceInterface
redis *redis.Client
logger *zap.Logger
}
// New 创建代理预充值服务实例
func New(
db *gorm.DB,
agentRechargeStore *postgres.AgentRechargeStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTxStore *postgres.AgentWalletTransactionStore,
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
wechatConfigService WechatConfigServiceInterface,
auditService AuditServiceInterface,
rdb *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
agentRechargeStore: agentRechargeStore,
agentWalletStore: agentWalletStore,
agentWalletTxStore: agentWalletTxStore,
shopStore: shopStore,
accountStore: accountStore,
wechatConfigService: wechatConfigService,
auditService: auditService,
redis: rdb,
logger: logger,
}
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
userShopID := middleware.GetShopIDFromContext(ctx)
// 代理只能充自己店铺
if userType == constants.UserTypeAgent && req.ShopID != userShopID {
return nil, errors.New(errors.CodeForbidden, "代理只能为自己的店铺充值")
}
// 线下充值仅平台可用
if req.PaymentMethod == "offline" && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
}
if req.Amount < constants.AgentRechargeMinAmount || req.Amount > constants.AgentRechargeMaxAmount {
return nil, errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
// 查找目标店铺的主钱包
wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺主钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主钱包失败")
}
// 查询店铺名称
shop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
}
// 在线支付需要查询生效的支付配置
var paymentConfigID *uint
var paymentChannel string
if req.PaymentMethod == "wechat" {
activeConfig, cfgErr := s.wechatConfigService.GetActiveConfig(ctx)
if cfgErr != nil || activeConfig == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前无可用的支付配置,请联系管理员")
}
paymentConfigID = &activeConfig.ID
paymentChannel = activeConfig.ProviderType
} else {
paymentChannel = "offline"
}
rechargeNo := s.generateRechargeNo()
record := &model.AgentRechargeRecord{
UserID: userID,
AgentWalletID: wallet.ID,
ShopID: req.ShopID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentMethod: req.PaymentMethod,
PaymentChannel: &paymentChannel,
PaymentConfigID: paymentConfigID,
Status: constants.RechargeStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentRechargeStore.Create(ctx, record); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
s.logger.Info("创建代理充值订单成功",
zap.Uint("recharge_id", record.ID),
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Uint("shop_id", req.ShopID),
zap.Uint("user_id", userID),
)
return toResponse(record, shop.ShopName), nil
}
// OfflinePay 线下充值确认
// POST /api/admin/agent-recharges/:id/offline-pay
func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOfflinePayRequest) (*dto.AgentRechargeResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
// 仅平台账号可操作
if userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "仅平台管理员可确认线下充值")
}
// 验证操作密码
account, err := s.accountStore.GetByID(ctx, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询操作人账号失败")
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.OperationPassword)); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "操作密码错误")
}
record, err := s.agentRechargeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
if record.PaymentMethod != "offline" {
return nil, errors.New(errors.CodeInvalidParam, "该订单非线下充值,不支持此操作")
}
if record.Status != constants.RechargeStatusPending {
return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付")
}
// 查询钱包(事务内需要用到 version
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新充值记录状态
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]interface{}{
"status": constants.RechargeStatusCompleted,
"paid_at": now,
"completed_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidParam, "充值记录状态已变更")
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
}
// 创建钱包交易记录
remark := "线下充值确认"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
})
if err != nil {
return nil, err
}
// 异步记录审计日志
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
OperatorID: userID,
OperatorType: userType,
OperationType: "offline_recharge_confirm",
OperationDesc: fmt.Sprintf("确认线下充值,充值单号: %s金额: %d分", record.RechargeNo, record.Amount),
RequestID: middleware.GetRequestIDFromContext(ctx),
IPAddress: middleware.GetIPFromContext(ctx),
UserAgent: middleware.GetUserAgentFromContext(ctx),
})
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
shopName := ""
if shop != nil {
shopName = shop.ShopName
}
// 更新本地对象以反映最新状态
record.Status = constants.RechargeStatusCompleted
record.PaidAt = &now
record.CompletedAt = &now
return toResponse(record, shopName), nil
}
// HandlePaymentCallback 处理支付回调
// 幂等处理status != 待支付则直接返回成功
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, rechargeNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "充值订单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
// 幂等检查
if record.Status != constants.RechargeStatusPending {
s.logger.Info("代理充值订单已处理,跳过",
zap.String("recharge_no", rechargeNo),
zap.Int("status", record.Status),
)
return nil
}
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新WHERE status = 1
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]interface{}{
"status": constants.RechargeStatusCompleted,
"payment_transaction_id": paymentTransactionID,
"paid_at": now,
"completed_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
}
if result.RowsAffected == 0 {
return nil
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
}
// 创建交易记录
remark := "在线支付充值"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: record.UserID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: record.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
})
if err != nil {
return err
}
s.logger.Info("代理充值支付回调处理成功",
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", record.Amount),
zap.Uint("shop_id", record.ShopID),
)
return nil
}
// GetByID 根据ID查询充值订单详情
// GET /api/admin/agent-recharges/:id
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
record, err := s.agentRechargeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
shopName := ""
if shop != nil {
shopName = shop.ShopName
}
return toResponse(record, shopName), nil
}
// List 分页查询充值订单列表
// GET /api/admin/agent-recharges
func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) ([]*dto.AgentRechargeResponse, int64, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
query := s.db.WithContext(ctx).Model(&model.AgentRechargeRecord{})
if req.ShopID != nil {
query = query.Where("shop_id = ?", *req.ShopID)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.StartDate != "" {
query = query.Where("created_at >= ?", req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
query = query.Where("created_at <= ?", req.EndDate+" 23:59:59")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败")
}
var records []*model.AgentRechargeRecord
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录列表失败")
}
// 批量查询店铺名称
shopIDs := make([]uint, 0, len(records))
for _, r := range records {
shopIDs = append(shopIDs, r.ShopID)
}
shopMap := make(map[uint]string)
if len(shopIDs) > 0 {
shops, err := s.shopStore.GetByIDs(ctx, shopIDs)
if err == nil {
for _, sh := range shops {
shopMap[sh.ID] = sh.ShopName
}
}
}
list := make([]*dto.AgentRechargeResponse, 0, len(records))
for _, r := range records {
list = append(list, toResponse(r, shopMap[r.ShopID]))
}
return list, total, nil
}
// generateRechargeNo 生成代理充值订单号
// 格式: ARCH + 14位时间戳 + 6位随机数
func (s *Service) generateRechargeNo() string {
timestamp := time.Now().Format("20060102150405")
randomNum := rand.Intn(1000000)
return fmt.Sprintf("%s%s%06d", constants.AgentRechargeOrderPrefix, timestamp, randomNum)
}
// toResponse 将模型转换为响应 DTO
func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse {
resp := &dto.AgentRechargeResponse{
ID: record.ID,
RechargeNo: record.RechargeNo,
ShopID: record.ShopID,
ShopName: shopName,
AgentWalletID: record.AgentWalletID,
Amount: record.Amount,
PaymentMethod: record.PaymentMethod,
Status: record.Status,
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if record.PaymentChannel != nil {
resp.PaymentChannel = *record.PaymentChannel
}
if record.PaymentConfigID != nil {
resp.PaymentConfigID = record.PaymentConfigID
}
if record.PaymentTransactionID != nil {
resp.PaymentTransactionID = *record.PaymentTransactionID
}
if record.PaidAt != nil {
t := record.PaidAt.Format("2006-01-02 15:04:05")
resp.PaidAt = &t
}
if record.CompletedAt != nil {
t := record.CompletedAt.Format("2006-01-02 15:04:05")
resp.CompletedAt = &t
}
return resp
}