Files
junhong_cmp_fiber/internal/service/agent_recharge/service.go
huang 6caf0f6141
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat: 新增全局操作密码功能,将线下充值验证从登录密码改为统一操作密码
- 新增 RedisSystemOperationPasswordKey 常量函数(pkg/constants/redis.go)
- 新增 operation_password Service(Set/IsSet/Verify,bcrypt 哈希存 Redis)
- 新增 SuperAdminHandler 及两个接口:
  - POST /api/admin/super-admin/operation-password(设置/重置,仅超级管理员)
  - GET /api/admin/super-admin/operation-password/status(查询是否已设置)
- AgentRecharge.OfflinePay 操作密码验证从"查当前用户登录密码"改为"全局操作密码 Verify"
- Bootstrap/路由/文档生成器同步注册
2026-04-18 09:06:33 +08:00

516 lines
17 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"
"strings"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"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)
}
// OperationPasswordServiceInterface 全局操作密码服务接口
type OperationPasswordServiceInterface interface {
Verify(ctx context.Context, inputPassword string) error
}
// 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
wechatConfigService WechatConfigServiceInterface
auditService AuditServiceInterface
operationPasswordService OperationPasswordServiceInterface
redis *redis.Client
logger *zap.Logger
}
// New 创建代理预充值服务实例
func New(
db *gorm.DB,
agentRechargeStore *postgres.AgentRechargeStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTxStore *postgres.AgentWalletTransactionStore,
shopStore *postgres.ShopStore,
wechatConfigService WechatConfigServiceInterface,
auditService AuditServiceInterface,
operationPasswordService OperationPasswordServiceInterface,
rdb *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
agentRechargeStore: agentRechargeStore,
agentWalletStore: agentWalletStore,
agentWalletTxStore: agentWalletTxStore,
shopStore: shopStore,
wechatConfigService: wechatConfigService,
auditService: auditService,
operationPasswordService: operationPasswordService,
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.PaymentMethod == "offline" && strings.TrimSpace(req.PaymentVoucherKey) == "" {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")
}
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,
PaymentVoucherKey: strings.TrimSpace(req.PaymentVoucherKey),
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, "仅平台管理员可确认线下充值")
}
// 验证全局操作密码
if err := s.operationPasswordService.Verify(ctx, req.OperationPassword); err != nil {
return nil, err
}
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,
PaymentVoucherKey: record.PaymentVoucherKey,
Status: record.Status,
StatusName: constants.GetRechargeStatusName(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
}