first commit

This commit is contained in:
2025-11-27 13:09:54 +08:00
commit 3440744179
570 changed files with 61861 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
package service
import (
"context"
"database/sql"
"time"
"ycc-server/app/main/model"
"ycc-server/common/xerr"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
type AdminPromotionLinkStatsService struct {
logx.Logger
AdminPromotionLinkModel model.AdminPromotionLinkModel
AdminPromotionLinkStatsTotalModel model.AdminPromotionLinkStatsTotalModel
AdminPromotionLinkStatsHistoryModel model.AdminPromotionLinkStatsHistoryModel
}
func NewAdminPromotionLinkStatsService(
AdminPromotionLinkModel model.AdminPromotionLinkModel,
AdminPromotionLinkStatsTotalModel model.AdminPromotionLinkStatsTotalModel,
AdminPromotionLinkStatsHistoryModel model.AdminPromotionLinkStatsHistoryModel,
) *AdminPromotionLinkStatsService {
return &AdminPromotionLinkStatsService{
Logger: logx.WithContext(context.Background()),
AdminPromotionLinkModel: AdminPromotionLinkModel,
AdminPromotionLinkStatsTotalModel: AdminPromotionLinkStatsTotalModel,
AdminPromotionLinkStatsHistoryModel: AdminPromotionLinkStatsHistoryModel,
}
}
// ensureTotalStats 确保总统计记录存在,如果不存在则创建
func (s *AdminPromotionLinkStatsService) ensureTotalStats(ctx context.Context, session sqlx.Session, linkId int64) (*model.AdminPromotionLinkStatsTotal, error) {
totalStats, err := s.AdminPromotionLinkStatsTotalModel.FindOneByLinkId(ctx, linkId)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
// 如果记录不存在,创建新记录
totalStats = &model.AdminPromotionLinkStatsTotal{
LinkId: linkId,
ClickCount: 0,
PayCount: 0,
PayAmount: 0,
}
_, err = s.AdminPromotionLinkStatsTotalModel.Insert(ctx, session, totalStats)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建总统计记录失败: %+v", err)
}
// 重新获取创建后的记录
totalStats, err = s.AdminPromotionLinkStatsTotalModel.FindOneByLinkId(ctx, linkId)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "获取新创建的总统计记录失败: %+v", err)
}
} else {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询总统计失败: %+v", err)
}
}
return totalStats, nil
}
// ensureHistoryStats 确保历史统计记录存在,如果不存在则创建
func (s *AdminPromotionLinkStatsService) ensureHistoryStats(ctx context.Context, session sqlx.Session, linkId int64, today time.Time) (*model.AdminPromotionLinkStatsHistory, error) {
historyStats, err := s.AdminPromotionLinkStatsHistoryModel.FindOneByLinkIdStatsDate(ctx, linkId, today)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
// 如果记录不存在,创建新记录
historyStats = &model.AdminPromotionLinkStatsHistory{
LinkId: linkId,
StatsDate: today,
ClickCount: 0,
PayCount: 0,
PayAmount: 0,
}
_, err = s.AdminPromotionLinkStatsHistoryModel.Insert(ctx, session, historyStats)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建今日统计记录失败: %+v", err)
}
// 重新获取创建后的记录
historyStats, err = s.AdminPromotionLinkStatsHistoryModel.FindOneByLinkIdStatsDate(ctx, linkId, today)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "获取新创建的今日统计记录失败: %+v", err)
}
} else {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询今日统计记录失败: %+v", err)
}
}
return historyStats, nil
}
// UpdateLinkStats 更新推广链接统计
func (s *AdminPromotionLinkStatsService) UpdateLinkStats(ctx context.Context, linkId int64) error {
return s.AdminPromotionLinkStatsTotalModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
// 确保总统计记录存在
totalStats, err := s.ensureTotalStats(ctx, session, linkId)
if err != nil {
return err
}
// 更新总统计
totalStats.ClickCount++
totalStats.LastClickTime = sql.NullTime{Time: time.Now(), Valid: true}
err = s.AdminPromotionLinkStatsTotalModel.UpdateWithVersion(ctx, session, totalStats)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新总统计失败: %+v", err)
}
// 确保历史统计记录存在
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
historyStats, err := s.ensureHistoryStats(ctx, session, linkId, today)
if err != nil {
return err
}
// 更新历史统计
historyStats.ClickCount++
historyStats.LastClickTime = sql.NullTime{Time: time.Now(), Valid: true}
err = s.AdminPromotionLinkStatsHistoryModel.UpdateWithVersion(ctx, session, historyStats)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新历史统计失败: %+v", err)
}
return nil
})
}
// UpdatePaymentStats 更新付费统计
func (s *AdminPromotionLinkStatsService) UpdatePaymentStats(ctx context.Context, linkId int64, amount float64) error {
return s.AdminPromotionLinkStatsTotalModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
// 确保总统计记录存在
totalStats, err := s.ensureTotalStats(ctx, session, linkId)
if err != nil {
return err
}
// 更新总统计
totalStats.PayCount++
totalStats.PayAmount += amount
totalStats.LastPayTime = sql.NullTime{Time: time.Now(), Valid: true}
err = s.AdminPromotionLinkStatsTotalModel.UpdateWithVersion(ctx, session, totalStats)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新总统计失败: %+v", err)
}
// 确保历史统计记录存在
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
historyStats, err := s.ensureHistoryStats(ctx, session, linkId, today)
if err != nil {
return err
}
// 更新历史统计
historyStats.PayCount++
historyStats.PayAmount += amount
historyStats.LastPayTime = sql.NullTime{Time: time.Now(), Valid: true}
err = s.AdminPromotionLinkStatsHistoryModel.UpdateWithVersion(ctx, session, historyStats)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "更新历史统计失败: %+v", err)
}
return nil
})
}
// CreateLinkStats 创建新的推广链接统计记录
func (s *AdminPromotionLinkStatsService) CreateLinkStats(ctx context.Context, linkId int64) error {
return s.AdminPromotionLinkStatsTotalModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
// 检查总统计记录是否已存在
_, err := s.AdminPromotionLinkStatsTotalModel.FindOneByLinkId(ctx, linkId)
if err == nil {
// 记录已存在,不需要创建
return nil
}
if err != model.ErrNotFound {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "查询总统计记录失败: %+v", err)
}
// 创建总统计记录
totalStats := &model.AdminPromotionLinkStatsTotal{
LinkId: linkId,
ClickCount: 0,
PayCount: 0,
PayAmount: 0,
}
_, err = s.AdminPromotionLinkStatsTotalModel.Insert(ctx, session, totalStats)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建总统计记录失败: %+v", err)
}
// 创建今日历史统计记录
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
historyStats := &model.AdminPromotionLinkStatsHistory{
LinkId: linkId,
StatsDate: today,
ClickCount: 0,
PayCount: 0,
PayAmount: 0,
}
_, err = s.AdminPromotionLinkStatsHistoryModel.Insert(ctx, session, historyStats)
if err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DB_ERROR), "创建历史统计记录失败: %+v", err)
}
return nil
})
}

View File

@@ -0,0 +1,641 @@
package service
import (
"context"
"database/sql"
"strconv"
"time"
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/model"
"ycc-server/common/globalkey"
"ycc-server/pkg/lzkit/lzUtils"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
// AgentService 新代理系统服务
type AgentService struct {
config config.Config
OrderModel model.OrderModel
AgentModel model.AgentModel
AgentWalletModel model.AgentWalletModel
AgentRelationModel model.AgentRelationModel
AgentLinkModel model.AgentLinkModel
AgentOrderModel model.AgentOrderModel
AgentCommissionModel model.AgentCommissionModel
AgentRebateModel model.AgentRebateModel
AgentUpgradeModel model.AgentUpgradeModel
AgentWithdrawalModel model.AgentWithdrawalModel
AgentConfigModel model.AgentConfigModel
AgentProductConfigModel model.AgentProductConfigModel
AgentRealNameModel model.AgentRealNameModel
AgentWithdrawalTaxModel model.AgentWithdrawalTaxModel
}
// NewAgentService 创建新的代理服务
func NewAgentService(
c config.Config,
orderModel model.OrderModel,
agentModel model.AgentModel,
agentWalletModel model.AgentWalletModel,
agentRelationModel model.AgentRelationModel,
agentLinkModel model.AgentLinkModel,
agentOrderModel model.AgentOrderModel,
agentCommissionModel model.AgentCommissionModel,
agentRebateModel model.AgentRebateModel,
agentUpgradeModel model.AgentUpgradeModel,
agentWithdrawalModel model.AgentWithdrawalModel,
agentConfigModel model.AgentConfigModel,
agentProductConfigModel model.AgentProductConfigModel,
agentRealNameModel model.AgentRealNameModel,
agentWithdrawalTaxModel model.AgentWithdrawalTaxModel,
) *AgentService {
return &AgentService{
config: c,
OrderModel: orderModel,
AgentModel: agentModel,
AgentWalletModel: agentWalletModel,
AgentRelationModel: agentRelationModel,
AgentLinkModel: agentLinkModel,
AgentOrderModel: agentOrderModel,
AgentCommissionModel: agentCommissionModel,
AgentRebateModel: agentRebateModel,
AgentUpgradeModel: agentUpgradeModel,
AgentWithdrawalModel: agentWithdrawalModel,
AgentConfigModel: agentConfigModel,
AgentProductConfigModel: agentProductConfigModel,
AgentRealNameModel: agentRealNameModel,
AgentWithdrawalTaxModel: agentWithdrawalTaxModel,
}
}
// AgentProcess 处理代理订单(新系统)
// 根据新代理系统的收益分配规则处理订单
func (s *AgentService) AgentProcess(ctx context.Context, order *model.Order) error {
// 1. 检查是否是代理推广订单
agentOrder, err := s.AgentOrderModel.FindOneByOrderId(ctx, order.Id)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
// 不是代理订单,直接返回
return nil
}
return errors.Wrapf(err, "查询代理订单失败, orderId: %d", order.Id)
}
// 2. 检查订单是否已处理
if agentOrder.ProcessStatus == 1 {
logx.Infof("订单已处理, orderId: %d", order.Id)
return nil
}
// 3. 获取代理信息
agent, err := s.AgentModel.FindOne(ctx, agentOrder.AgentId)
if err != nil {
return errors.Wrapf(err, "查询代理信息失败, agentId: %d", agentOrder.AgentId)
}
// 4. 获取系统配置
basePrice, err := s.getConfigFloat(ctx, "base_price")
if err != nil {
return errors.Wrapf(err, "获取基础底价配置失败")
}
// 6. 使用事务处理订单
return s.AgentWalletModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error {
// 6.1 计算实际底价和代理收益
levelBonus := s.getLevelBonus(agent.Level)
actualBasePrice := basePrice + float64(levelBonus)
// 6.2 计算提价成本
priceThreshold, _ := s.getConfigFloat(ctx, "price_threshold")
priceFeeRate, _ := s.getConfigFloat(ctx, "price_fee_rate")
priceCost := s.calculatePriceCost(agentOrder.SetPrice, priceThreshold, priceFeeRate)
// 6.3 计算代理收益
agentProfit := agentOrder.SetPrice - actualBasePrice - priceCost
// 6.4 更新代理订单记录
agentOrder.ProcessStatus = 1
agentOrder.ProcessTime = lzUtils.TimeToNullTime(time.Now())
agentOrder.ProcessRemark = lzUtils.StringToNullString("处理成功")
if err := s.AgentOrderModel.UpdateWithVersion(transCtx, session, agentOrder); err != nil {
return errors.Wrapf(err, "更新代理订单失败")
}
// 6.5 发放代理佣金
if err := s.giveAgentCommission(transCtx, session, agentOrder.AgentId, order.Id, order.ProductId, agentProfit); err != nil {
return errors.Wrapf(err, "发放代理佣金失败")
}
// 6.6 分配等级加成返佣给上级链
if levelBonus > 0 {
if err := s.distributeLevelBonus(transCtx, session, agent, order.Id, order.ProductId, float64(levelBonus), levelBonus); err != nil {
return errors.Wrapf(err, "分配等级加成返佣失败")
}
}
return nil
})
}
// getLevelBonus 获取等级加成
func (s *AgentService) getLevelBonus(level int64) int64 {
switch level {
case 1: // 普通
return 6
case 2: // 黄金
return 3
case 3: // 钻石
return 0
default:
return 0
}
}
// calculatePriceCost 计算提价成本
func (s *AgentService) calculatePriceCost(setPrice, priceThreshold, priceFeeRate float64) float64 {
if setPrice <= priceThreshold {
return 0
}
return (setPrice - priceThreshold) * priceFeeRate
}
// giveAgentCommission 发放代理佣金
func (s *AgentService) giveAgentCommission(ctx context.Context, session sqlx.Session, agentId, orderId, productId int64, amount float64) error {
// 1. 创建佣金记录
commission := &model.AgentCommission{
AgentId: agentId,
OrderId: orderId,
ProductId: productId,
Amount: amount,
Status: 1, // 已发放
}
if _, err := s.AgentCommissionModel.Insert(ctx, session, commission); err != nil {
return errors.Wrapf(err, "创建佣金记录失败")
}
// 2. 更新钱包余额
wallet, err := s.AgentWalletModel.FindOneByAgentId(ctx, agentId)
if err != nil {
return errors.Wrapf(err, "查询钱包失败, agentId: %d", agentId)
}
wallet.Balance += amount
wallet.TotalEarnings += amount
if err := s.AgentWalletModel.UpdateWithVersion(ctx, session, wallet); err != nil {
return errors.Wrapf(err, "更新钱包失败")
}
return nil
}
// distributeLevelBonus 分配等级加成返佣给上级链
func (s *AgentService) distributeLevelBonus(ctx context.Context, session sqlx.Session, agent *model.Agent, orderId, productId int64, levelBonus float64, levelBonusInt int64) error {
// 钻石代理等级加成为0无返佣分配
if agent.Level == 3 {
return nil
}
// 黄金代理等级加成3元全部给钻石上级
if agent.Level == 2 {
diamondParent, err := s.findDiamondParent(ctx, agent.Id)
if err != nil {
return errors.Wrapf(err, "查找钻石上级失败")
}
if diamondParent != nil {
return s.giveRebate(ctx, session, diamondParent.Id, agent.Id, orderId, productId, levelBonus, levelBonusInt, 2) // 2=钻石上级返佣
}
// 找不到钻石上级,返佣归平台(异常情况)
return nil
}
// 普通代理等级加成6元按规则分配给上级链
if agent.Level == 1 {
return s.distributeNormalAgentBonus(ctx, session, agent, orderId, productId, levelBonus, levelBonusInt)
}
return nil
}
// distributeNormalAgentBonus 普通代理的等级加成返佣分配6元
func (s *AgentService) distributeNormalAgentBonus(ctx context.Context, session sqlx.Session, agent *model.Agent, orderId, productId int64, amount float64, levelBonusInt int64) error {
// 1. 查找直接上级
parent, err := s.findDirectParent(ctx, agent.Id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return errors.Wrapf(err, "查找直接上级失败")
}
if parent == nil {
// 无上级,全部归平台
return nil
}
// 2. 给直接上级分配固定金额
var directParentAmount float64
switch parent.Level {
case 3: // 钻石
directParentAmount = 6
case 2: // 黄金
directParentAmount = 3
case 1: // 普通
directParentAmount = 2
default:
directParentAmount = 0
}
if directParentAmount > 0 {
if err := s.giveRebate(ctx, session, parent.Id, agent.Id, orderId, productId, directParentAmount, levelBonusInt, 1); err != nil {
return errors.Wrapf(err, "给直接上级返佣失败")
}
}
remaining := amount - directParentAmount
if remaining <= 0 {
return nil
}
// 3. 分配剩余金额
// 确定查找起点:直接上级是普通时从直接上级开始查找,否则从直接上级的上级开始查找
searchStart := parent
if parent.Level != 1 {
searchStartParent, err := s.findDirectParent(ctx, parent.Id)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return errors.Wrapf(err, "查找上级的上级失败")
}
if searchStartParent != nil {
searchStart = searchStartParent
}
}
if searchStart != nil {
// 查找上级链中的钻石和黄金
diamondParent, _ := s.findDiamondParent(ctx, searchStart.Id)
goldParent, _ := s.findGoldParent(ctx, searchStart.Id)
// 按优先级分配剩余金额
if diamondParent != nil {
// 优先级1有钻石剩余金额全部给钻石
return s.giveRebate(ctx, session, diamondParent.Id, agent.Id, orderId, productId, remaining, levelBonusInt, 2)
} else if goldParent != nil {
// 优先级2只有黄金最多3元给黄金剩余归平台
goldAmount := remaining
if goldAmount > 3 {
goldAmount = 3
}
if err := s.giveRebate(ctx, session, goldParent.Id, agent.Id, orderId, productId, goldAmount, levelBonusInt, 3); err != nil {
return errors.Wrapf(err, "给黄金上级返佣失败")
}
// 剩余归平台(不需要记录)
}
// 优先级3都没有剩余金额归平台不需要记录
}
return nil
}
// giveRebate 发放返佣
func (s *AgentService) giveRebate(ctx context.Context, session sqlx.Session, agentId, sourceAgentId, orderId, productId int64, amount float64, levelBonus int64, rebateType int64) error {
// 1. 创建返佣记录
rebate := &model.AgentRebate{
AgentId: agentId,
SourceAgentId: sourceAgentId,
OrderId: orderId,
ProductId: productId,
RebateType: rebateType,
LevelBonus: float64(levelBonus), // 等级加成金额
RebateAmount: amount,
Status: 1, // 已发放
}
if _, err := s.AgentRebateModel.Insert(ctx, session, rebate); err != nil {
return errors.Wrapf(err, "创建返佣记录失败")
}
// 2. 更新钱包余额
wallet, err := s.AgentWalletModel.FindOneByAgentId(ctx, agentId)
if err != nil {
return errors.Wrapf(err, "查询钱包失败, agentId: %d", agentId)
}
wallet.Balance += amount
wallet.TotalEarnings += amount
if err := s.AgentWalletModel.UpdateWithVersion(ctx, session, wallet); err != nil {
return errors.Wrapf(err, "更新钱包失败")
}
return nil
}
// FindDirectParent 查找直接上级(公开方法)
func (s *AgentService) FindDirectParent(ctx context.Context, agentId int64) (*model.Agent, error) {
return s.findDirectParent(ctx, agentId)
}
// findDirectParent 查找直接上级
func (s *AgentService) findDirectParent(ctx context.Context, agentId int64) (*model.Agent, error) {
// 查找关系类型为1直接关系的上级
builder := s.AgentRelationModel.SelectBuilder()
builder = builder.Where("child_id = ? AND relation_type = ? AND del_state = ?", agentId, 1, globalkey.DelStateNo)
relations, err := s.AgentRelationModel.FindAll(ctx, builder, "")
if err != nil {
return nil, err
}
if len(relations) == 0 {
return nil, model.ErrNotFound
}
// 返回第一个直接上级
return s.AgentModel.FindOne(ctx, relations[0].ParentId)
}
// findDiamondParent 向上查找钻石上级
func (s *AgentService) findDiamondParent(ctx context.Context, agentId int64) (*model.Agent, error) {
currentId := agentId
maxDepth := 100 // 防止无限循环
depth := 0
for depth < maxDepth {
parent, err := s.findDirectParent(ctx, currentId)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrNotFound
}
return nil, err
}
if parent.Level == 3 { // 钻石
return parent, nil
}
currentId = parent.Id
depth++
}
return nil, model.ErrNotFound
}
// findGoldParent 向上查找黄金上级
func (s *AgentService) findGoldParent(ctx context.Context, agentId int64) (*model.Agent, error) {
currentId := agentId
maxDepth := 100 // 防止无限循环
depth := 0
for depth < maxDepth {
parent, err := s.findDirectParent(ctx, currentId)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return nil, model.ErrNotFound
}
return nil, err
}
if parent.Level == 2 { // 黄金
return parent, nil
}
currentId = parent.Id
depth++
}
return nil, model.ErrNotFound
}
// getConfigFloat 获取配置值(浮点数)
func (s *AgentService) getConfigFloat(ctx context.Context, configKey string) (float64, error) {
config, err := s.AgentConfigModel.FindOneByConfigKey(ctx, configKey)
if err != nil {
return 0, err
}
value, err := strconv.ParseFloat(config.ConfigValue, 64)
if err != nil {
return 0, errors.Wrapf(err, "解析配置值失败, key: %s, value: %s", configKey, config.ConfigValue)
}
return value, nil
}
// getConfigInt 获取配置值(整数)
func (s *AgentService) getConfigInt(ctx context.Context, configKey string) (int64, error) {
config, err := s.AgentConfigModel.FindOneByConfigKey(ctx, configKey)
if err != nil {
return 0, err
}
value, err := strconv.ParseInt(config.ConfigValue, 10, 64)
if err != nil {
return 0, errors.Wrapf(err, "解析配置值失败, key: %s, value: %s", configKey, config.ConfigValue)
}
return value, nil
}
// ProcessUpgrade 处理代理升级
func (s *AgentService) ProcessUpgrade(ctx context.Context, agentId, toLevel int64, upgradeType int64, upgradeFee, rebateAmount float64, orderNo string, operatorAgentId int64) error {
return s.AgentWalletModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error {
// 1. 获取代理信息
agent, err := s.AgentModel.FindOne(transCtx, agentId)
if err != nil {
return errors.Wrapf(err, "查询代理信息失败, agentId: %d", agentId)
}
// 2. 如果是自主付费升级,处理返佣
if upgradeType == 1 { // 自主付费
// 查找原直接上级
parent, err := s.findDirectParent(transCtx, agentId)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return errors.Wrapf(err, "查找直接上级失败")
}
if parent != nil && rebateAmount > 0 {
// 返佣给原直接上级
if err := s.giveRebateForUpgrade(transCtx, session, parent.Id, agentId, rebateAmount); err != nil {
return errors.Wrapf(err, "返佣给上级失败")
}
}
}
// 3. 执行升级操作
agent.Level = toLevel
// 4. 检查是否需要脱离直接上级关系
needDetach, err := s.needDetachFromParent(transCtx, agent, toLevel)
if err != nil {
return errors.Wrapf(err, "检查是否需要脱离关系失败")
}
if needDetach {
// 脱离直接上级关系
if err := s.detachFromParent(transCtx, session, agentId); err != nil {
return errors.Wrapf(err, "脱离直接上级关系失败")
}
}
// 5. 如果升级为钻石,独立成新团队
if toLevel == 3 {
agent.TeamLeaderId = sql.NullInt64{Int64: agentId, Valid: true}
// 更新所有下级的团队首领
if err := s.updateChildrenTeamLeader(transCtx, session, agentId, agentId); err != nil {
return errors.Wrapf(err, "更新下级团队首领失败")
}
} else {
// 更新团队首领(查找上级链中的钻石代理)
teamLeaderId, err := s.findTeamLeaderId(transCtx, agentId)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return errors.Wrapf(err, "查找团队首领失败")
}
if teamLeaderId > 0 {
agent.TeamLeaderId = sql.NullInt64{Int64: teamLeaderId, Valid: true}
}
}
// 6. 更新代理记录
if err := s.AgentModel.UpdateWithVersion(transCtx, session, agent); err != nil {
return errors.Wrapf(err, "更新代理记录失败")
}
// 7. 更新升级记录状态
// 这里需要先查询升级记录暂时先跳过在logic中处理
return nil
})
}
// needDetachFromParent 检查是否需要脱离直接上级关系
func (s *AgentService) needDetachFromParent(ctx context.Context, agent *model.Agent, newLevel int64) (bool, error) {
parent, err := s.findDirectParent(ctx, agent.Id)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return false, nil // 没有上级,不需要脱离
}
return false, err
}
// 规则1下级不能比上级等级高
if newLevel > parent.Level {
return true, nil
}
// 规则2同级不能作为上下级除了普通代理
if newLevel == parent.Level {
if newLevel == 2 || newLevel == 3 { // 黄金或钻石
return true, nil
}
}
// 规则3钻石 → 黄金禁止(特殊规则)
if newLevel == 2 && parent.Level == 3 {
return true, nil
}
return false, nil
}
// detachFromParent 脱离直接上级关系
func (s *AgentService) detachFromParent(ctx context.Context, session sqlx.Session, agentId int64) error {
// 查找直接关系
builder := s.AgentRelationModel.SelectBuilder().
Where("child_id = ? AND relation_type = ? AND del_state = ?", agentId, 1, globalkey.DelStateNo)
relations, err := s.AgentRelationModel.FindAll(ctx, builder, "")
if err != nil {
return err
}
if len(relations) == 0 {
return nil // 没有关系,不需要脱离
}
// 将直接关系标记为已脱离
relation := relations[0]
relation.RelationType = 2 // 已脱离
relation.DetachReason = lzUtils.StringToNullString("upgrade")
relation.DetachTime = lzUtils.TimeToNullTime(time.Now())
if err := s.AgentRelationModel.UpdateWithVersion(ctx, session, relation); err != nil {
return errors.Wrapf(err, "更新关系记录失败")
}
return nil
}
// updateChildrenTeamLeader 更新所有下级的团队首领
func (s *AgentService) updateChildrenTeamLeader(ctx context.Context, session sqlx.Session, agentId, teamLeaderId int64) error {
// 递归更新所有下级
var updateChildren func(int64) error
updateChildren = func(parentId int64) error {
// 查找直接下级
builder := s.AgentRelationModel.SelectBuilder().
Where("parent_id = ? AND relation_type = ? AND del_state = ?", parentId, 1, globalkey.DelStateNo)
relations, err := s.AgentRelationModel.FindAll(ctx, builder, "")
if err != nil {
return err
}
for _, relation := range relations {
child, err := s.AgentModel.FindOne(ctx, relation.ChildId)
if err != nil {
continue
}
child.TeamLeaderId = sql.NullInt64{Int64: teamLeaderId, Valid: true}
if err := s.AgentModel.UpdateWithVersion(ctx, session, child); err != nil {
return errors.Wrapf(err, "更新下级团队首领失败, childId: %d", child.Id)
}
// 递归更新下级的下级
if err := updateChildren(child.Id); err != nil {
return err
}
}
return nil
}
return updateChildren(agentId)
}
// findTeamLeaderId 查找团队首领ID钻石代理
func (s *AgentService) findTeamLeaderId(ctx context.Context, agentId int64) (int64, error) {
diamondParent, err := s.findDiamondParent(ctx, agentId)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return 0, nil
}
return 0, err
}
return diamondParent.Id, nil
}
// giveRebateForUpgrade 发放升级返佣
func (s *AgentService) giveRebateForUpgrade(ctx context.Context, session sqlx.Session, parentAgentId, upgradeAgentId int64, amount float64) error {
// 更新钱包余额
wallet, err := s.AgentWalletModel.FindOneByAgentId(ctx, parentAgentId)
if err != nil {
return errors.Wrapf(err, "查询钱包失败, agentId: %d", parentAgentId)
}
wallet.Balance += amount
wallet.TotalEarnings += amount
if err := s.AgentWalletModel.UpdateWithVersion(ctx, session, wallet); err != nil {
return errors.Wrapf(err, "更新钱包失败")
}
return nil
}
// GetUpgradeFee 获取升级费用
func (s *AgentService) GetUpgradeFee(fromLevel, toLevel int64) float64 {
if fromLevel == 1 && toLevel == 2 {
return 199 // 普通→黄金
} else if toLevel == 3 {
return 980 // 升级为钻石
}
return 0
}
// GetUpgradeRebate 获取升级返佣金额
func (s *AgentService) GetUpgradeRebate(fromLevel, toLevel int64) float64 {
if fromLevel == 1 && toLevel == 2 {
return 139 // 普通→黄金返佣
} else if toLevel == 3 {
return 680 // 升级为钻石返佣
}
return 0
}

View File

@@ -0,0 +1,258 @@
package service
import (
"context"
"crypto/rand"
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/model"
"ycc-server/pkg/lzkit/lzUtils"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"sync/atomic"
"time"
"github.com/smartwalle/alipay/v3"
)
type AliPayService struct {
config config.AlipayConfig
AlipayClient *alipay.Client
}
// NewAliPayService 是一个构造函数,用于初始化 AliPayService
func NewAliPayService(c config.Config) *AliPayService {
client, err := alipay.New(c.Alipay.AppID, c.Alipay.PrivateKey, c.Alipay.IsProduction)
if err != nil {
panic(fmt.Sprintf("创建支付宝客户端失败: %v", err))
}
// 加载支付宝公钥
err = client.LoadAliPayPublicKey(c.Alipay.AlipayPublicKey)
if err != nil {
panic(fmt.Sprintf("加载支付宝公钥失败: %v", err))
}
// 加载证书
if err = client.LoadAppCertPublicKeyFromFile(c.Alipay.AppCertPath); err != nil {
panic(fmt.Sprintf("加载应用公钥证书失败: %v", err))
}
if err = client.LoadAlipayCertPublicKeyFromFile(c.Alipay.AlipayCertPath); err != nil {
panic(fmt.Sprintf("加载支付宝公钥证书失败: %v", err))
}
if err = client.LoadAliPayRootCertFromFile(c.Alipay.AlipayRootCertPath); err != nil {
panic(fmt.Sprintf("加载根证书失败: %v", err))
}
return &AliPayService{
config: c.Alipay,
AlipayClient: client,
}
}
func (a *AliPayService) CreateAlipayAppOrder(amount float64, subject string, outTradeNo string) (string, error) {
client := a.AlipayClient
totalAmount := lzUtils.ToAlipayAmount(amount)
// 构造移动支付请求
p := alipay.TradeAppPay{
Trade: alipay.Trade{
Subject: subject,
OutTradeNo: outTradeNo,
TotalAmount: totalAmount,
ProductCode: "QUICK_MSECURITY_PAY", // 移动端支付专用代码
NotifyURL: a.config.NotifyUrl, // 异步回调通知地址
},
}
// 获取APP支付字符串这里会签名
payStr, err := client.TradeAppPay(p)
if err != nil {
return "", fmt.Errorf("创建支付宝订单失败: %v", err)
}
return payStr, nil
}
// CreateAlipayH5Order 创建支付宝H5支付订单
func (a *AliPayService) CreateAlipayH5Order(amount float64, subject string, outTradeNo string) (string, error) {
client := a.AlipayClient
totalAmount := lzUtils.ToAlipayAmount(amount)
// 构造H5支付请求
p := alipay.TradeWapPay{
Trade: alipay.Trade{
Subject: subject,
OutTradeNo: outTradeNo,
TotalAmount: totalAmount,
ProductCode: "QUICK_WAP_PAY", // H5支付专用产品码
NotifyURL: a.config.NotifyUrl, // 异步回调通知地址
ReturnURL: a.config.ReturnURL,
},
}
// 获取H5支付请求字符串这里会签名
payUrl, err := client.TradeWapPay(p)
if err != nil {
return "", fmt.Errorf("创建支付宝H5订单失败: %v", err)
}
return payUrl.String(), nil
}
// CreateAlipayOrder 根据平台类型创建支付宝支付订单
func (a *AliPayService) CreateAlipayOrder(ctx context.Context, amount float64, subject string, outTradeNo string) (string, error) {
// 根据 ctx 中的 platform 判断平台
platform, platformOk := ctx.Value("platform").(string)
if !platformOk {
return "", fmt.Errorf("无的支付平台: %s", platform)
}
switch platform {
case model.PlatformApp:
// 调用App支付的创建方法
return a.CreateAlipayAppOrder(amount, subject, outTradeNo)
case model.PlatformH5:
// 调用H5支付的创建方法并传入 returnUrl
return a.CreateAlipayH5Order(amount, subject, outTradeNo)
default:
return "", fmt.Errorf("不支持的支付平台: %s", platform)
}
}
// AliRefund 发起支付宝退款
func (a *AliPayService) AliRefund(ctx context.Context, outTradeNo string, refundAmount float64) (*alipay.TradeRefundRsp, error) {
refund := alipay.TradeRefund{
OutTradeNo: outTradeNo,
RefundAmount: lzUtils.ToAlipayAmount(refundAmount),
OutRequestNo: fmt.Sprintf("refund-%s", outTradeNo),
}
// 发起退款请求
refundResp, err := a.AlipayClient.TradeRefund(ctx, refund)
if err != nil {
return nil, fmt.Errorf("支付宝退款请求错误:%v", err)
}
return refundResp, nil
}
// HandleAliPaymentNotification 支付宝支付回调
func (a *AliPayService) HandleAliPaymentNotification(r *http.Request) (*alipay.Notification, error) {
// 解析表单
err := r.ParseForm()
if err != nil {
return nil, fmt.Errorf("解析请求表单失败:%v", err)
}
// 解析并验证通知DecodeNotification 会自动验证签名
notification, err := a.AlipayClient.DecodeNotification(r.Form)
if err != nil {
return nil, fmt.Errorf("验证签名失败: %v", err)
}
return notification, nil
}
func (a *AliPayService) QueryOrderStatus(ctx context.Context, outTradeNo string) (*alipay.TradeQueryRsp, error) {
queryRequest := alipay.TradeQuery{
OutTradeNo: outTradeNo,
}
// 发起查询请求
resp, err := a.AlipayClient.TradeQuery(ctx, queryRequest)
if err != nil {
return nil, fmt.Errorf("查询支付宝订单失败: %v", err)
}
// 返回交易状态
if resp.IsSuccess() {
return resp, nil
}
return nil, fmt.Errorf("查询支付宝订单失败: %v", resp.SubMsg)
}
// 添加全局原子计数器
var alipayOrderCounter uint32 = 0
// GenerateOutTradeNo 生成唯一订单号的函数 - 优化版本
func (a *AliPayService) GenerateOutTradeNo() string {
// 获取当前时间戳(毫秒级)
timestamp := time.Now().UnixMilli()
timeStr := strconv.FormatInt(timestamp, 10)
// 原子递增计数器
counter := atomic.AddUint32(&alipayOrderCounter, 1)
// 生成4字节真随机数
randomBytes := make([]byte, 4)
_, err := rand.Read(randomBytes)
if err != nil {
// 如果随机数生成失败,回退到使用时间纳秒数据
randomBytes = []byte(strconv.FormatInt(time.Now().UnixNano()%1000000, 16))
}
randomHex := hex.EncodeToString(randomBytes)
// 组合所有部分: 前缀 + 时间戳 + 计数器 + 随机数
orderNo := fmt.Sprintf("%s%06x%s", timeStr[:10], counter%0xFFFFFF, randomHex[:6])
// 确保长度不超过32字符大多数支付平台的限制
if len(orderNo) > 32 {
orderNo = orderNo[:32]
}
return orderNo
}
// AliTransfer 支付宝单笔转账到支付宝账户(提现功能)
func (a *AliPayService) AliTransfer(
ctx context.Context,
payeeAccount string, // 收款方支付宝账户
payeeName string, // 收款方姓名
amount float64, // 转账金额
remark string, // 转账备注
outBizNo string, // 商户转账唯一订单号可使用GenerateOutTradeNo生成
) (*alipay.FundTransUniTransferRsp, error) {
// 参数校验
if payeeAccount == "" {
return nil, fmt.Errorf("收款账户不能为空")
}
if amount <= 0 {
return nil, fmt.Errorf("转账金额必须大于0")
}
// 构造转账请求
req := alipay.FundTransUniTransfer{
OutBizNo: outBizNo,
TransAmount: lzUtils.ToAlipayAmount(amount), // 金额格式转换
ProductCode: "TRANS_ACCOUNT_NO_PWD", // 单笔无密转账到支付宝账户
BizScene: "DIRECT_TRANSFER", // 单笔转账
OrderTitle: "账户提现", // 转账标题
Remark: remark,
PayeeInfo: &alipay.PayeeInfo{
Identity: payeeAccount,
IdentityType: "ALIPAY_LOGON_ID", // 根据账户类型选择:
Name: payeeName,
// ALIPAY_USER_ID/ALIPAY_LOGON_ID
},
}
// 执行转账请求
transferRsp, err := a.AlipayClient.FundTransUniTransfer(ctx, req)
if err != nil {
return nil, fmt.Errorf("支付宝转账请求失败: %v", err)
}
return transferRsp, nil
}
func (a *AliPayService) QueryTransferStatus(
ctx context.Context,
outBizNo string,
) (*alipay.FundTransOrderQueryRsp, error) {
req := alipay.FundTransOrderQuery{
OutBizNo: outBizNo,
}
response, err := a.AlipayClient.FundTransOrderQuery(ctx, req)
if err != nil {
return nil, fmt.Errorf("支付宝接口调用失败: %v", err)
}
// 处理响应
if response.Code.IsFailure() {
return nil, fmt.Errorf("支付宝返回错误: %s-%s", response.Code, response.Msg)
}
return response, nil
}

View File

@@ -0,0 +1,223 @@
package service
import (
"context"
"fmt"
"regexp"
"strings"
"ycc-server/app/main/model"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/rest"
)
type ApiRegistryService struct {
adminApiModel model.AdminApiModel
}
func NewApiRegistryService(adminApiModel model.AdminApiModel) *ApiRegistryService {
return &ApiRegistryService{
adminApiModel: adminApiModel,
}
}
// RegisterAllApis 自动注册所有API到数据库
func (s *ApiRegistryService) RegisterAllApis(ctx context.Context, routes []rest.Route) error {
logx.Infof("开始注册API共 %d 个路由", len(routes))
registeredCount := 0
skippedCount := 0
for _, route := range routes {
// 跳过不需要权限控制的API
if s.shouldSkipApi(route.Path) {
skippedCount++
continue
}
// 解析API信息
apiInfo := s.parseRouteToApi(route)
// 检查是否已存在
existing, err := s.adminApiModel.FindOneByApiCode(ctx, apiInfo.ApiCode)
if err != nil && !errors.Is(err, model.ErrNotFound) {
logx.Errorf("查询API失败: %v, apiCode: %s", err, apiInfo.ApiCode)
continue
}
// 如果不存在则插入
if existing == nil {
_, err = s.adminApiModel.Insert(ctx, nil, apiInfo)
if err != nil {
logx.Errorf("插入API失败: %v, apiCode: %s", err, apiInfo.ApiCode)
continue
}
registeredCount++
logx.Infof("注册API成功: %s %s", apiInfo.Method, apiInfo.Url)
} else {
// 如果存在但信息有变化,则更新
if s.shouldUpdateApi(existing, apiInfo) {
existing.ApiName = apiInfo.ApiName
existing.Method = apiInfo.Method
existing.Url = apiInfo.Url
existing.Description = apiInfo.Description
_, err = s.adminApiModel.Update(ctx, nil, existing)
if err != nil {
logx.Errorf("更新API失败: %v, apiCode: %s", err, apiInfo.ApiCode)
continue
}
logx.Infof("更新API成功: %s %s", apiInfo.Method, apiInfo.Url)
}
}
}
logx.Infof("API注册完成新增: %d, 跳过: %d", registeredCount, skippedCount)
return nil
}
// shouldSkipApi 判断是否应该跳过此API
func (s *ApiRegistryService) shouldSkipApi(path string) bool {
// 跳过公开API
skipPaths := []string{
"/api/v1/admin/auth/login", // 登录接口
"/api/v1/app/", // 前端应用接口
"/api/v1/agent/", // 代理接口
"/api/v1/user/", // 用户接口
"/api/v1/auth/", // 认证接口
"/api/v1/notification/", // 通知接口
"/api/v1/pay/", // 支付接口
"/api/v1/query/", // 查询接口
"/api/v1/product/", // 产品接口
"/api/v1/authorization/", // 授权接口
"/health", // 健康检查
}
for _, skipPath := range skipPaths {
if strings.HasPrefix(path, skipPath) {
return true
}
}
return false
}
// parseRouteToApi 将路由解析为API信息
func (s *ApiRegistryService) parseRouteToApi(route rest.Route) *model.AdminApi {
// 生成API编码
apiCode := s.generateApiCode(route.Method, route.Path)
// 生成API名称
apiName := s.generateApiName(route.Path)
// 生成描述
description := s.generateDescription(route.Method, route.Path)
return &model.AdminApi{
ApiName: apiName,
ApiCode: apiCode,
Method: route.Method,
Url: route.Path,
Status: 1, // 默认启用
Description: description,
}
}
// generateApiCode 生成API编码
func (s *ApiRegistryService) generateApiCode(method, path string) string {
// 移除路径参数,如 :id
cleanPath := regexp.MustCompile(`/:[\w]+`).ReplaceAllString(path, "")
// 转换为小写并替换特殊字符
apiCode := strings.ToLower(method) + "_" + strings.ReplaceAll(cleanPath, "/", "_")
apiCode = strings.TrimPrefix(apiCode, "_")
apiCode = strings.TrimSuffix(apiCode, "_")
return apiCode
}
// generateApiName 生成API名称
func (s *ApiRegistryService) generateApiName(path string) string {
// 从路径中提取模块和操作
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 3 {
return path
}
// 获取模块名和操作名
module := parts[len(parts)-2]
action := parts[len(parts)-1]
// 转换为中文描述
moduleMap := map[string]string{
"agent": "代理管理",
"auth": "认证管理",
"feature": "功能管理",
"menu": "菜单管理",
"notification": "通知管理",
"order": "订单管理",
"platform_user": "平台用户",
"product": "产品管理",
"promotion": "推广管理",
"query": "查询管理",
"role": "角色管理",
"user": "用户管理",
}
actionMap := map[string]string{
"list": "列表",
"create": "创建",
"update": "更新",
"delete": "删除",
"detail": "详情",
"login": "登录",
"config": "配置",
"example": "示例",
"refund": "退款",
"link": "链接",
"stats": "统计",
"cleanup": "清理",
"record": "记录",
}
moduleName := moduleMap[module]
if moduleName == "" {
moduleName = module
}
actionName := actionMap[action]
if actionName == "" {
actionName = action
}
return fmt.Sprintf("%s-%s", moduleName, actionName)
}
// generateDescription 生成API描述
func (s *ApiRegistryService) generateDescription(method, path string) string {
methodMap := map[string]string{
"GET": "查询",
"POST": "创建",
"PUT": "更新",
"DELETE": "删除",
}
methodDesc := methodMap[method]
if methodDesc == "" {
methodDesc = method
}
apiName := s.generateApiName(path)
return fmt.Sprintf("%s%s", methodDesc, apiName)
}
// shouldUpdateApi 判断是否需要更新API
func (s *ApiRegistryService) shouldUpdateApi(existing, new *model.AdminApi) bool {
return existing.ApiName != new.ApiName ||
existing.Method != new.Method ||
existing.Url != new.Url ||
existing.Description != new.Description
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/x509"
"ycc-server/app/main/api/internal/config"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"net/http"
"strconv"
"time"
"github.com/golang-jwt/jwt/v4"
)
// ApplePayService 是 Apple IAP 支付服务的结构体
type ApplePayService struct {
config config.ApplepayConfig // 配置项
}
// NewApplePayService 是一个构造函数,用于初始化 ApplePayService
func NewApplePayService(c config.Config) *ApplePayService {
return &ApplePayService{
config: c.Applepay,
}
}
func (a *ApplePayService) GetIappayAppID(productName string) string {
return fmt.Sprintf("%s.%s", a.config.BundleID, productName)
}
// VerifyReceipt 验证苹果支付凭证
func (a *ApplePayService) VerifyReceipt(ctx context.Context, receipt string) (*AppleVerifyResponse, error) {
var reqUrl string
if a.config.Sandbox {
reqUrl = a.config.SandboxVerifyURL
} else {
reqUrl = a.config.ProductionVerifyURL
}
// 读取私钥
privateKey, err := loadPrivateKey(a.config.LoadPrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("加载私钥失败:%v", err)
}
// 生成 JWT
token, err := generateJWT(privateKey, a.config.KeyID, a.config.IssuerID)
if err != nil {
return nil, fmt.Errorf("生成JWT失败%v", err)
}
// 构造查询参数
queryParams := fmt.Sprintf("?receipt-data=%s", receipt)
fullUrl := reqUrl + queryParams
// 构建 HTTP GET 请求
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullUrl, nil)
if err != nil {
return nil, fmt.Errorf("创建 HTTP 请求失败:%v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
// 发送请求
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("请求苹果验证接口失败:%v", err)
}
defer resp.Body.Close()
// 解析响应
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应体失败:%v", err)
}
var verifyResponse AppleVerifyResponse
err = json.Unmarshal(body, &verifyResponse)
if err != nil {
return nil, fmt.Errorf("解析响应体失败:%v", err)
}
// 根据实际响应处理逻辑
if verifyResponse.Status != 0 {
return nil, fmt.Errorf("验证失败,状态码:%d", verifyResponse.Status)
}
return &verifyResponse, nil
}
func loadPrivateKey(path string) (*ecdsa.PrivateKey, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "PRIVATE KEY" {
return nil, fmt.Errorf("无效的私钥数据")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
ecdsaKey, ok := key.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("私钥类型错误")
}
return ecdsaKey, nil
}
func generateJWT(privateKey *ecdsa.PrivateKey, keyID, issuerID string) (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: issuerID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(1 * time.Hour)),
Audience: jwt.ClaimStrings{"appstoreconnect-v1"},
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = keyID
tokenString, err := token.SignedString(privateKey)
if err != nil {
return "", err
}
return tokenString, nil
}
// GenerateOutTradeNo 生成唯一订单号
func (a *ApplePayService) GenerateOutTradeNo() string {
length := 16
timestamp := time.Now().UnixNano()
timeStr := strconv.FormatInt(timestamp, 10)
randomPart := strconv.Itoa(int(timestamp % 1e6))
combined := timeStr + randomPart
if len(combined) >= length {
return combined[:length]
}
for len(combined) < length {
combined += strconv.Itoa(int(timestamp % 10))
}
return combined
}
// AppleVerifyResponse 定义苹果验证接口的响应结构
type AppleVerifyResponse struct {
Status int `json:"status"` // 验证状态码0 表示收据有效
Receipt *Receipt `json:"receipt"` // 收据信息
}
// Receipt 定义收据的精简结构
type Receipt struct {
BundleID string `json:"bundle_id"` // 应用的 Bundle ID
InApp []InAppItem `json:"in_app"` // 应用内购买记录
}
// InAppItem 定义单条交易记录
type InAppItem struct {
ProductID string `json:"product_id"` // 商品 ID
TransactionID string `json:"transaction_id"` // 交易 ID
PurchaseDate string `json:"purchase_date"` // 购买日期 (ISO 8601)
OriginalTransID string `json:"original_transaction_id"` // 原始交易 ID
}

View File

@@ -0,0 +1,60 @@
// asynq_service.go
package service
import (
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/api/internal/types"
"encoding/json"
"github.com/hibiken/asynq"
"github.com/zeromicro/go-zero/core/logx"
)
type AsynqService struct {
client *asynq.Client
config config.Config
}
// NewAsynqService 创建并初始化 Asynq 客户端
func NewAsynqService(c config.Config) *AsynqService {
client := asynq.NewClient(asynq.RedisClientOpt{
Addr: c.CacheRedis[0].Host,
Password: c.CacheRedis[0].Pass,
})
return &AsynqService{client: client, config: c}
}
// Close 关闭 Asynq 客户端
func (s *AsynqService) Close() error {
return s.client.Close()
}
func (s *AsynqService) SendQueryTask(orderID int64) error {
// 准备任务的 payload
payload := types.MsgPaySuccessQueryPayload{
OrderID: orderID,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
logx.Errorf("发送异步任务失败 (无法编码 payload): %v, 订单号: %d", err, orderID)
return err // 直接返回错误,避免继续执行
}
options := []asynq.Option{
asynq.MaxRetry(5), // 设置最大重试次数
}
// 创建任务
task := asynq.NewTask(types.MsgPaySuccessQuery, payloadBytes, options...)
// 将任务加入队列并获取任务信息
info, err := s.client.Enqueue(task)
if err != nil {
logx.Errorf("发送异步任务失败 (加入队列失败): %+v, 订单号: %d", err, orderID)
return err
}
// 记录成功日志,带上任务 ID 和队列信息
logx.Infof("发送异步任务成功任务ID: %s, 队列: %s, 订单号: %d", info.ID, info.Queue, orderID)
return nil
}

View File

@@ -0,0 +1,224 @@
package service
import (
"bytes"
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/model"
"github.com/jung-kurt/gofpdf"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/logx"
)
type AuthorizationService struct {
config config.Config
authDocModel model.AuthorizationDocumentModel
fileStoragePath string
fileBaseURL string
}
// NewAuthorizationService 创建授权书服务实例
func NewAuthorizationService(c config.Config, authDocModel model.AuthorizationDocumentModel) *AuthorizationService {
return &AuthorizationService{
config: c,
authDocModel: authDocModel,
fileStoragePath: "data/authorization_docs", // 使用相对路径,兼容开发环境
fileBaseURL: c.Authorization.FileBaseURL, // 从配置文件读取
}
}
// GenerateAuthorizationDocument 生成授权书PDF
func (s *AuthorizationService) GenerateAuthorizationDocument(
ctx context.Context,
userID int64,
orderID int64,
queryID int64,
userInfo map[string]interface{},
) (*model.AuthorizationDocument, error) {
// 1. 生成PDF内容
pdfBytes, err := s.generatePDFContent(userInfo)
if err != nil {
return nil, errors.Wrapf(err, "生成PDF内容失败")
}
// 2. 创建文件存储目录
year := time.Now().Format("2006")
month := time.Now().Format("01")
dirPath := filepath.Join(s.fileStoragePath, year, month)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, errors.Wrapf(err, "创建存储目录失败: %s", dirPath)
}
// 3. 生成文件名和路径
fileName := fmt.Sprintf("auth_%d_%d_%s.pdf", userID, orderID, time.Now().Format("20060102_150405"))
filePath := filepath.Join(dirPath, fileName)
// 只存储相对路径,不包含域名
relativePath := fmt.Sprintf("%s/%s/%s", year, month, fileName)
// 4. 保存PDF文件
if err := os.WriteFile(filePath, pdfBytes, 0644); err != nil {
return nil, errors.Wrapf(err, "保存PDF文件失败: %s", filePath)
}
// 5. 保存到数据库
authDoc := &model.AuthorizationDocument{
UserId: userID,
OrderId: orderID,
QueryId: queryID,
FileName: fileName,
FilePath: filePath,
FileUrl: relativePath, // 只存储相对路径
FileSize: int64(len(pdfBytes)),
FileType: "pdf",
Status: "active",
ExpireTime: sql.NullTime{Valid: false}, // 永久保留,不设置过期时间
}
result, err := s.authDocModel.Insert(ctx, nil, authDoc)
if err != nil {
// 如果数据库保存失败,删除已创建的文件
os.Remove(filePath)
return nil, errors.Wrapf(err, "保存授权书记录失败")
}
authDoc.Id, _ = result.LastInsertId()
logx.Infof("授权书生成成功: userID=%d, orderID=%d, filePath=%s", userID, orderID, filePath)
return authDoc, nil
}
// GetFullFileURL 获取完整的文件访问URL
func (s *AuthorizationService) GetFullFileURL(relativePath string) string {
if relativePath == "" {
return ""
}
return fmt.Sprintf("%s/%s", s.fileBaseURL, relativePath)
}
// generatePDFContent 生成PDF内容
func (s *AuthorizationService) generatePDFContent(userInfo map[string]interface{}) ([]byte, error) {
// 创建PDF文档
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// 添加中文字体支持 - 参考imageService的路径处理方式
fontPaths := []string{
"static/SIMHEI.TTF", // 相对于工作目录的路径与imageService一致
"/app/static/SIMHEI.TTF", // Docker容器内的字体文件
"app/main/api/static/SIMHEI.TTF", // 开发环境备用路径
}
// 尝试添加字体
fontAdded := false
for _, fontPath := range fontPaths {
if _, err := os.Stat(fontPath); err == nil {
pdf.AddUTF8Font("ChineseFont", "", fontPath)
fontAdded = true
logx.Infof("成功加载字体: %s", fontPath)
break
} else {
logx.Debugf("字体文件不存在: %s, 错误: %v", fontPath, err)
}
}
// 如果没有找到字体文件,使用默认字体,并记录警告
if !fontAdded {
pdf.SetFont("Arial", "", 12)
logx.Errorf("未找到中文字体文件使用默认Arial字体可能无法正确显示中文")
} else {
// 设置默认字体
pdf.SetFont("ChineseFont", "", 12)
}
// 获取用户信息
name := getUserInfoString(userInfo, "name")
idCard := getUserInfoString(userInfo, "id_card")
// 生成当前日期
currentDate := time.Now().Format("2006年1月2日")
// 设置标题样式 - 大字体、居中
if fontAdded {
pdf.SetFont("ChineseFont", "", 20) // 使用20号字体
} else {
pdf.SetFont("Arial", "", 20)
}
pdf.CellFormat(0, 15, "授权书", "", 1, "C", false, 0, "")
// 添加空行
pdf.Ln(5)
// 设置正文样式 - 正常字体
if fontAdded {
pdf.SetFont("ChineseFont", "", 12)
} else {
pdf.SetFont("Arial", "", 12)
}
// 构建授权书内容(去掉标题部分)
content := fmt.Sprintf(`海南省学宇思网络科技有限公司:
本人%s拟向贵司申请大数据分析报告查询业务贵司需要了解本人相关状况用于查询大数据分析报告因此本人同意向贵司提供本人的姓名和手机号等个人信息并同意贵司向第三方包括但不限于西部数据交易有限公司传送上述信息。第三方将使用上述信息核实信息真实情况查询信用记录并生成报告。
授权内容如下:
贵司向依法成立的第三方服务商(包括但不限于西部数据交易有限公司)根据本人提交的信息进行核实,并有权通过前述第三方服务机构查询、使用本人的身份信息、设备信息、运营商信息等,查询本人信息(包括但不限于学历、婚姻、资产状况及对信息主体产生负面影响的不良信息),出具相关报告。
依法成立的第三方服务商查询或核实、搜集、保存、处理、共享、使用(含合法业务应用)本人相关数据,且不再另行告知本人,但法律、法规、监管政策禁止的除外。
本人授权有效期为自授权之日起 1个月。本授权为不可撤销授权但法律法规另有规定的除外。
用户声明与承诺:
本人在授权签署前,已通过实名认证及动态验证码验证(或其他身份验证手段),确认本授权行为为本人真实意思表示,平台已履行身份验证义务。
本人在此声明已充分理解上述授权条款含义,知晓并自愿承担因授权数据使用可能带来的后果,包括但不限于影响个人信用评分、生活行为等。本人确认授权范围内的相关信息由本人提供并真实有效。
若用户冒名签署或提供虚假信息,由用户自行承担全部法律责任,平台不承担任何后果。
特别提示:
本产品所有数据均来自第三方。可能部分数据未公开、数据更新延迟或信息受到限制,贵司不对数据的准确性、真实性、完整性做任何承诺。用户需根据实际情况,结合报告内容自行判断与决策。
本产品仅供用户本人查询或被授权查询。除非用户取得合法授权,用户不得利用本产品查询他人信息。用户因未获得合法授权而擅自查询他人信息所产生的任何后果,由用户自行承担责任。
本授权书涉及对本人敏感信息(包括但不限于婚姻状态、资产状况等)的查询与使用。本人已充分知晓相关信息的敏感性,并明确同意贵司及其合作方依据授权范围使用相关信息。
平台声明:本授权书涉及的信息核实及查询结果由第三方服务商提供,平台不对数据的准确性、完整性、实时性承担责任;用户根据报告所作决策的风险由用户自行承担,平台对此不承担法律责任。
本授权书中涉及的数据查询和报告生成由依法成立的第三方服务商提供。若因第三方行为导致数据错误或损失,用户应向第三方主张权利,平台不承担相关责任。
附加说明:
本人在授权的相关数据将依据法律法规及贵司内部数据管理规范妥善存储,存储期限为法律要求的最短必要时间。超过存储期限或在数据使用目的达成后,贵司将对相关数据进行销毁或匿名化处理。
本人有权随时撤回本授权书中的授权,但撤回前的授权行为及其法律后果仍具有法律效力。若需撤回授权,本人可通过贵司官方渠道提交书面申请,贵司将在收到申请后依法停止对本人数据的使用。
你通过"一查查"自愿支付相应费用用于购买海南省学宇思网络科技有限公司的大数据报告产品。如若对产品内容存在异议可通过邮箱admin@iieeii.com或APP"联系客服"按钮进行反馈贵司将在收到异议之日起20日内进行核查和处理并将结果答复。
你向海南省学宇思网络科技有限公司的支付方式为:海南省学宇思网络科技有限公司及其经官方授权的相关企业的支付宝账户。
争议解决机制:
若因本授权书引发争议,双方应友好协商解决;协商不成的,双方同意将争议提交至授权书签署地(海南省)有管辖权的人民法院解决。
签署方式的法律效力声明:
本授权书通过用户在线勾选、电子签名或其他网络签署方式完成,与手写签名具有同等法律效力。平台已通过技术手段保存签署过程的完整记录,作为用户真实意思表示的证据。
本授权书于 %s 生效。
授权人:%s
身份证号:%s
签署时间:%s`, name, currentDate, name, idCard, currentDate)
// 将内容写入PDF
pdf.MultiCell(0, 6, content, "", "", false)
// 生成PDF字节数组
var buf bytes.Buffer
err := pdf.Output(&buf)
if err != nil {
return nil, errors.Wrapf(err, "生成PDF字节数组失败")
}
return buf.Bytes(), nil
}
// getUserInfoString 安全获取用户信息字符串
func getUserInfoString(userInfo map[string]interface{}, key string) string {
if value, exists := userInfo[key]; exists {
if str, ok := value.(string); ok {
return str
}
}
return ""
}

View File

@@ -0,0 +1,670 @@
package service
import (
"context"
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/model"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Masterminds/squirrel"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
// mockResult 模拟sql.Result
type mockResult struct {
lastInsertId int64
rowsAffected int64
}
func (m *mockResult) LastInsertId() (int64, error) {
return m.lastInsertId, nil
}
func (m *mockResult) RowsAffected() (int64, error) {
return m.rowsAffected, nil
}
// MockAuthorizationDocumentModel 模拟授权书模型
type MockAuthorizationDocumentModel struct {
mock.Mock
}
func (m *MockAuthorizationDocumentModel) Insert(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) (sql.Result, error) {
args := m.Called(ctx, session, data)
return args.Get(0).(sql.Result), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) FindOne(ctx context.Context, id int64) (*model.AuthorizationDocument, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*model.AuthorizationDocument), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) Update(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) (sql.Result, error) {
args := m.Called(ctx, session, data)
return args.Get(0).(sql.Result), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) UpdateWithVersion(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) error {
args := m.Called(ctx, session, data)
return args.Error(0)
}
func (m *MockAuthorizationDocumentModel) Trans(ctx context.Context, fn func(context.Context, sqlx.Session) error) error {
args := m.Called(ctx, fn)
return args.Error(0)
}
func (m *MockAuthorizationDocumentModel) SelectBuilder() squirrel.SelectBuilder {
args := m.Called()
return args.Get(0).(squirrel.SelectBuilder)
}
func (m *MockAuthorizationDocumentModel) DeleteSoft(ctx context.Context, session sqlx.Session, data *model.AuthorizationDocument) error {
args := m.Called(ctx, session, data)
return args.Error(0)
}
func (m *MockAuthorizationDocumentModel) FindSum(ctx context.Context, sumBuilder squirrel.SelectBuilder, field string) (float64, error) {
args := m.Called(ctx, sumBuilder, field)
return args.Get(0).(float64), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) FindCount(ctx context.Context, countBuilder squirrel.SelectBuilder, field string) (int64, error) {
args := m.Called(ctx, countBuilder, field)
return args.Get(0).(int64), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) FindAll(ctx context.Context, rowBuilder squirrel.SelectBuilder, orderBy string) ([]*model.AuthorizationDocument, error) {
args := m.Called(ctx, rowBuilder, orderBy)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) FindPageListByPage(ctx context.Context, rowBuilder squirrel.SelectBuilder, page, pageSize int64, orderBy string) ([]*model.AuthorizationDocument, error) {
args := m.Called(ctx, rowBuilder, page, pageSize, orderBy)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) FindPageListByPageWithTotal(ctx context.Context, rowBuilder squirrel.SelectBuilder, page, pageSize int64, orderBy string) ([]*model.AuthorizationDocument, int64, error) {
args := m.Called(ctx, rowBuilder, page, pageSize, orderBy)
if args.Get(0) == nil {
return nil, args.Get(1).(int64), args.Error(2)
}
return args.Get(0).([]*model.AuthorizationDocument), args.Get(1).(int64), args.Error(2)
}
func (m *MockAuthorizationDocumentModel) FindPageListByIdDESC(ctx context.Context, rowBuilder squirrel.SelectBuilder, preMinId, pageSize int64) ([]*model.AuthorizationDocument, error) {
args := m.Called(ctx, rowBuilder, preMinId, pageSize)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) FindPageListByIdASC(ctx context.Context, rowBuilder squirrel.SelectBuilder, preMaxId, pageSize int64) ([]*model.AuthorizationDocument, error) {
args := m.Called(ctx, rowBuilder, preMaxId, pageSize)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
}
func (m *MockAuthorizationDocumentModel) Delete(ctx context.Context, session sqlx.Session, id int64) error {
args := m.Called(ctx, session, id)
return args.Error(0)
}
func (m *MockAuthorizationDocumentModel) FindByOrderId(ctx context.Context, orderId int64) ([]*model.AuthorizationDocument, error) {
args := m.Called(ctx, orderId)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]*model.AuthorizationDocument), args.Error(1)
}
// MockResult 模拟数据库结果
type MockResult struct {
lastInsertId int64
}
func (m *MockResult) LastInsertId() (int64, error) {
return m.lastInsertId, nil
}
func (m *MockResult) RowsAffected() (int64, error) {
return 1, nil
}
// TestNewAuthorizationService 测试创建授权书服务
func TestNewAuthorizationService(t *testing.T) {
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.com/api/v1/auth-docs",
},
}
mockModel := &MockAuthorizationDocumentModel{}
service := NewAuthorizationService(config, mockModel)
assert.NotNil(t, service)
assert.Equal(t, "data/authorization_docs", service.fileStoragePath)
assert.Equal(t, "https://test.com/api/v1/auth-docs", service.fileBaseURL)
}
// TestGetFullFileURL 测试获取完整文件URL
func TestGetFullFileURL(t *testing.T) {
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.com/api/v1/auth-docs",
},
}
mockModel := &MockAuthorizationDocumentModel{}
service := NewAuthorizationService(config, mockModel)
tests := []struct {
name string
relativePath string
expected string
}{
{
name: "正常相对路径",
relativePath: "2025/09/auth_123_456_20250913_160800.pdf",
expected: "https://test.com/api/v1/auth-docs/2025/09/auth_123_456_20250913_160800.pdf",
},
{
name: "空路径",
relativePath: "",
expected: "",
},
{
name: "只有文件名",
relativePath: "test.pdf",
expected: "https://test.com/api/v1/auth-docs/test.pdf",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.GetFullFileURL(tt.relativePath)
assert.Equal(t, tt.expected, result)
})
}
}
// TestGenerateAuthorizationDocument 测试生成授权书
func TestGenerateAuthorizationDocument(t *testing.T) {
// 创建测试配置
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
},
}
// 创建模拟的数据库模型
mockModel := &MockAuthorizationDocumentModel{}
// 创建授权书服务
service := NewAuthorizationService(config, mockModel)
// 准备测试数据
userInfo := map[string]interface{}{
"name": "张三",
"id_card": "110101199001011234",
"mobile": "13800138000",
}
// 模拟数据库插入成功
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
&mockResult{lastInsertId: 1, rowsAffected: 1}, nil)
// 执行测试
authDoc, err := service.GenerateAuthorizationDocument(
context.Background(),
1, // userID
2, // orderID
3, // queryID
userInfo,
)
// 验证结果
assert.NoError(t, err)
assert.NotNil(t, authDoc)
assert.Equal(t, int64(1), authDoc.UserId)
assert.Equal(t, int64(2), authDoc.OrderId)
assert.Equal(t, int64(3), authDoc.QueryId)
assert.Equal(t, "pdf", authDoc.FileType)
assert.Equal(t, "active", authDoc.Status)
assert.False(t, authDoc.ExpireTime.Valid) // 永久保留,不设置过期时间
// 验证文件路径格式兼容Windows和Unix路径分隔符
assert.True(t, strings.Contains(authDoc.FilePath, "data/authorization_docs") ||
strings.Contains(authDoc.FilePath, "data\\authorization_docs"))
assert.Contains(t, authDoc.FileName, "auth_")
assert.Contains(t, authDoc.FileName, ".pdf")
// 验证相对路径格式
assert.Regexp(t, `^\d{4}/\d{2}/auth_\d+_\d+_\d{8}_\d{6}\.pdf$`, authDoc.FileUrl)
// 验证文件大小
assert.Greater(t, authDoc.FileSize, int64(0))
// 验证数据库调用
mockModel.AssertExpectations(t)
// 验证文件是否真的被创建
if _, err := os.Stat(authDoc.FilePath); err == nil {
t.Logf("✅ 授权书文件已创建: %s", authDoc.FilePath)
t.Logf("📊 文件大小: %d 字节", authDoc.FileSize)
} else {
t.Logf("⚠️ 文件未找到: %s", authDoc.FilePath)
}
}
// TestGenerateAuthorizationDocument_DatabaseError 测试数据库错误
func TestGenerateAuthorizationDocument_DatabaseError(t *testing.T) {
// 创建测试配置
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
},
}
// 创建模拟的数据库模型
mockModel := &MockAuthorizationDocumentModel{}
// 创建授权书服务
service := NewAuthorizationService(config, mockModel)
// 准备测试数据
userInfo := map[string]interface{}{
"name": "李四",
"id_card": "110101199001011235",
"mobile": "13800138001",
}
// 模拟数据库插入失败
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
(*mockResult)(nil), errors.New("数据库连接失败"))
// 执行测试
authDoc, err := service.GenerateAuthorizationDocument(
context.Background(),
1, // userID
2, // orderID
3, // queryID
userInfo,
)
// 验证结果
assert.Error(t, err)
assert.Nil(t, authDoc)
assert.Contains(t, err.Error(), "数据库连接失败")
// 验证数据库调用
mockModel.AssertExpectations(t)
}
// TestGeneratePDFContent 测试生成PDF内容
func TestGeneratePDFContent(t *testing.T) {
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.com/api/v1/auth-docs",
},
}
mockModel := &MockAuthorizationDocumentModel{}
service := NewAuthorizationService(config, mockModel)
userInfo := map[string]interface{}{
"name": "张三",
"id_card": "110101199001011234",
}
pdfBytes, err := service.generatePDFContent(userInfo)
// 验证结果
assert.NoError(t, err)
assert.NotNil(t, pdfBytes)
assert.Greater(t, len(pdfBytes), 0)
// 验证PDF内容PDF是二进制格式只验证基本结构
assert.Contains(t, string(pdfBytes), "%PDF") // PDF文件头
// 按照 GenerateAuthorizationDocument 的方式保存文件到本地
// 1. 创建文件存储目录
year := time.Now().Format("2006")
month := time.Now().Format("01")
dirPath := filepath.Join("data", "authorization_docs", year, month)
if err := os.MkdirAll(dirPath, 0755); err != nil {
t.Fatalf("创建存储目录失败: %v", err)
}
// 2. 生成文件名和路径
fileName := fmt.Sprintf("test_auth_%s.pdf", time.Now().Format("20060102_150405"))
filePath := filepath.Join(dirPath, fileName)
relativePath := fmt.Sprintf("%s/%s/%s", year, month, fileName)
// 3. 保存PDF文件
if err := os.WriteFile(filePath, pdfBytes, 0644); err != nil {
t.Fatalf("保存PDF文件失败: %v", err)
}
// 4. 验证文件是否保存成功
if _, err := os.Stat(filePath); err == nil {
t.Logf("✅ PDF文件已保存到本地")
t.Logf("📄 文件名: %s", fileName)
t.Logf("📁 文件路径: %s", filePath)
t.Logf("🔗 相对路径: %s", relativePath)
t.Logf("📊 文件大小: %d 字节", len(pdfBytes))
// 获取绝对路径
absPath, _ := filepath.Abs(filePath)
t.Logf("📍 绝对路径: %s", absPath)
} else {
t.Errorf("❌ PDF文件保存失败: %v", err)
}
}
// TestSavePDFToLocal 专门测试保存PDF文件到本地
func TestSavePDFToLocal(t *testing.T) {
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.com/api/v1/auth-docs",
},
}
mockModel := &MockAuthorizationDocumentModel{}
service := NewAuthorizationService(config, mockModel)
// 准备测试数据
userInfo := map[string]interface{}{
"name": "何志勇",
"id_card": "452528197907133014",
"mobile": "18276151590",
}
// 生成PDF内容
pdfBytes, err := service.generatePDFContent(userInfo)
assert.NoError(t, err)
assert.NotNil(t, pdfBytes)
assert.Greater(t, len(pdfBytes), 0)
// 按照 GenerateAuthorizationDocument 的方式保存文件到本地
// 1. 创建文件存储目录
year := time.Now().Format("2006")
month := time.Now().Format("01")
dirPath := filepath.Join("data", "authorization_docs", year, month)
if err := os.MkdirAll(dirPath, 0755); err != nil {
t.Fatalf("创建存储目录失败: %v", err)
}
// 2. 生成文件名和路径
fileName := fmt.Sprintf("local_test_auth_%s.pdf", time.Now().Format("20060102_150405"))
filePath := filepath.Join(dirPath, fileName)
relativePath := fmt.Sprintf("%s/%s/%s", year, month, fileName)
// 3. 保存PDF文件
if err := os.WriteFile(filePath, pdfBytes, 0644); err != nil {
t.Fatalf("保存PDF文件失败: %v", err)
}
// 4. 验证文件是否保存成功
if _, err := os.Stat(filePath); err == nil {
t.Logf("✅ PDF文件已保存到本地")
t.Logf("📄 文件名: %s", fileName)
t.Logf("📁 文件路径: %s", filePath)
t.Logf("🔗 相对路径: %s", relativePath)
t.Logf("📊 文件大小: %d 字节", len(pdfBytes))
// 获取绝对路径
absPath, _ := filepath.Abs(filePath)
t.Logf("📍 绝对路径: %s", absPath)
// 验证文件内容
fileInfo, err := os.Stat(filePath)
assert.NoError(t, err)
assert.Greater(t, fileInfo.Size(), int64(1000)) // 文件应该大于1KB
t.Logf("🎉 文件保存验证通过!")
} else {
t.Errorf("❌ PDF文件保存失败: %v", err)
}
}
// TestGeneratePDFContent_EmptyUserInfo 测试空用户信息
func TestGeneratePDFContent_EmptyUserInfo(t *testing.T) {
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.com/api/v1/auth-docs",
},
}
mockModel := &MockAuthorizationDocumentModel{}
service := NewAuthorizationService(config, mockModel)
userInfo := map[string]interface{}{}
pdfBytes, err := service.generatePDFContent(userInfo)
// 验证结果
assert.NoError(t, err)
assert.NotNil(t, pdfBytes)
assert.Greater(t, len(pdfBytes), 0)
// 验证PDF内容PDF是二进制格式只验证基本结构
assert.Contains(t, string(pdfBytes), "%PDF") // PDF文件头
}
// TestGetUserInfoString 测试获取用户信息字符串
func TestGetUserInfoString(t *testing.T) {
userInfo := map[string]interface{}{
"name": "张三",
"id_card": "110101199001011234",
"age": 30,
"empty": "",
}
tests := []struct {
name string
key string
expected string
}{
{
name: "正常字符串",
key: "name",
expected: "张三",
},
{
name: "身份证号",
key: "id_card",
expected: "110101199001011234",
},
{
name: "空字符串",
key: "empty",
expected: "",
},
{
name: "不存在的键",
key: "not_exist",
expected: "",
},
{
name: "非字符串类型",
key: "age",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getUserInfoString(userInfo, tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
// BenchmarkGeneratePDFContent 性能测试
func BenchmarkGeneratePDFContent(b *testing.B) {
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.com/api/v1/auth-docs",
},
}
mockModel := &MockAuthorizationDocumentModel{}
service := NewAuthorizationService(config, mockModel)
userInfo := map[string]interface{}{
"name": "张三",
"id_card": "110101199001011234",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := service.generatePDFContent(userInfo)
if err != nil {
b.Fatal(err)
}
}
}
// TestAuthorizationService_Integration 集成测试(需要真实文件系统)
func TestAuthorizationService_Integration(t *testing.T) {
// 创建测试配置
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
},
}
// 创建模拟的数据库模型
mockModel := &MockAuthorizationDocumentModel{}
// 创建授权书服务
service := NewAuthorizationService(config, mockModel)
// 准备测试数据
userInfo := map[string]interface{}{
"name": "王五",
"id_card": "110101199001011236",
"mobile": "13800138002",
}
// 模拟数据库插入成功
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
&mockResult{lastInsertId: 1, rowsAffected: 1}, nil)
// 执行测试
authDoc, err := service.GenerateAuthorizationDocument(
context.Background(),
1, // userID
2, // orderID
3, // queryID
userInfo,
)
// 验证结果
assert.NoError(t, err)
assert.NotNil(t, authDoc)
// 测试GetFullFileURL方法
fullURL := service.GetFullFileURL(authDoc.FileUrl)
expectedURL := fmt.Sprintf("%s/%s", config.Authorization.FileBaseURL, authDoc.FileUrl)
assert.Equal(t, expectedURL, fullURL)
// 验证文件是否真的被创建
if _, err := os.Stat(authDoc.FilePath); err == nil {
t.Logf("✅ 集成测试成功 - 授权书文件已创建")
t.Logf("📄 文件名: %s", authDoc.FileName)
t.Logf("📁 文件路径: %s", authDoc.FilePath)
t.Logf("🔗 相对路径: %s", authDoc.FileUrl)
t.Logf("🌐 完整URL: %s", fullURL)
t.Logf("📊 文件大小: %d 字节", authDoc.FileSize)
} else {
t.Logf("⚠️ 集成测试 - 文件未找到: %s", authDoc.FilePath)
}
// 验证数据库调用
mockModel.AssertExpectations(t)
}
// TestGeneratePDFFile 专门测试PDF文件生成
func TestGeneratePDFFile(t *testing.T) {
// 创建测试配置
config := config.Config{
Authorization: config.AuthorizationConfig{
FileBaseURL: "https://test.example.com/api/v1/auth-docs",
},
}
// 创建模拟的数据库模型
mockModel := &MockAuthorizationDocumentModel{}
// 创建授权书服务
service := NewAuthorizationService(config, mockModel)
// 准备测试数据
userInfo := map[string]interface{}{
"name": "测试用户",
"id_card": "110101199001011237",
"mobile": "13800138003",
}
// 模拟数据库插入成功
mockModel.On("Insert", mock.Anything, mock.Anything, mock.Anything).Return(
&mockResult{lastInsertId: 1, rowsAffected: 1}, nil)
// 执行测试
authDoc, err := service.GenerateAuthorizationDocument(
context.Background(),
999, // userID
888, // orderID
777, // queryID
userInfo,
)
// 验证结果
assert.NoError(t, err)
assert.NotNil(t, authDoc)
// 验证文件是否真的被创建
if _, err := os.Stat(authDoc.FilePath); err == nil {
t.Logf("✅ PDF文件生成成功")
t.Logf("📄 文件名: %s", authDoc.FileName)
t.Logf("📁 文件路径: %s", authDoc.FilePath)
t.Logf("🔗 相对路径: %s", authDoc.FileUrl)
t.Logf("📊 文件大小: %d 字节", authDoc.FileSize)
// 验证文件内容
fileInfo, err := os.Stat(authDoc.FilePath)
assert.NoError(t, err)
assert.Greater(t, fileInfo.Size(), int64(1000)) // 文件应该大于1KB
// 验证文件名格式
assert.Regexp(t, `^auth_999_888_\d{8}_\d{6}\.pdf$`, authDoc.FileName)
// 验证路径格式
assert.Regexp(t, `^\d{4}/\d{2}/auth_999_888_\d{8}_\d{6}\.pdf$`, authDoc.FileUrl)
t.Logf("🎉 所有验证通过!")
} else {
t.Errorf("❌ PDF文件未创建: %s", authDoc.FilePath)
}
// 验证数据库调用
mockModel.AssertExpectations(t)
}

View File

@@ -0,0 +1,47 @@
package service
import (
"context"
"ycc-server/app/main/model"
"errors"
)
type DictService struct {
adminDictTypeModel model.AdminDictTypeModel
adminDictDataModel model.AdminDictDataModel
}
func NewDictService(adminDictTypeModel model.AdminDictTypeModel, adminDictDataModel model.AdminDictDataModel) *DictService {
return &DictService{adminDictTypeModel: adminDictTypeModel, adminDictDataModel: adminDictDataModel}
}
func (s *DictService) GetDictLabel(ctx context.Context, dictType string, dictValue int64) (string, error) {
dictTypeModel, err := s.adminDictTypeModel.FindOneByDictType(ctx, dictType)
if err != nil {
return "", err
}
if dictTypeModel.Status != 1 {
return "", errors.New("字典类型未启用")
}
dictData, err := s.adminDictDataModel.FindOneByDictTypeDictValue(ctx, dictTypeModel.DictType, dictValue)
if err != nil {
return "", err
}
if dictData.Status != 1 {
return "", errors.New("字典数据未启用")
}
return dictData.DictLabel, nil
}
func (s *DictService) GetDictValue(ctx context.Context, dictType string, dictLabel string) (int64, error) {
dictTypeModel, err := s.adminDictTypeModel.FindOneByDictType(ctx, dictType)
if err != nil {
return 0, err
}
if dictTypeModel.Status != 1 {
return 0, errors.New("字典类型未启用")
}
dictData, err := s.adminDictDataModel.FindOneByDictTypeDictLabel(ctx, dictTypeModel.DictType, dictLabel)
if err != nil {
return 0, err
}
return dictData.DictValue, nil
}

View File

@@ -0,0 +1,173 @@
package service
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"github.com/fogleman/gg"
"github.com/skip2/go-qrcode"
"github.com/zeromicro/go-zero/core/logx"
)
type ImageService struct {
baseImagePath string
}
func NewImageService() *ImageService {
return &ImageService{
baseImagePath: "static/images", // 原图存放目录
}
}
// ProcessImageWithQRCode 处理图片,在中间添加二维码
func (s *ImageService) ProcessImageWithQRCode(qrcodeType, qrcodeUrl string) ([]byte, string, error) {
// 1. 根据qrcodeType确定使用哪张背景图
var backgroundImageName string
switch qrcodeType {
case "promote":
backgroundImageName = "tg_qrcode_1.png"
case "invitation":
backgroundImageName = "yq_qrcode_1.png"
default:
backgroundImageName = "tg_qrcode_1.png" // 默认使用第一张图片
}
// 2. 读取原图
originalImagePath := filepath.Join(s.baseImagePath, backgroundImageName)
originalImage, err := s.loadImage(originalImagePath)
if err != nil {
logx.Errorf("加载原图失败: %v, 图片路径: %s", err, originalImagePath)
return nil, "", fmt.Errorf("加载原图失败: %v", err)
}
// 3. 获取原图尺寸
bounds := originalImage.Bounds()
imgWidth := bounds.Dx()
imgHeight := bounds.Dy()
// 4. 创建绘图上下文
dc := gg.NewContext(imgWidth, imgHeight)
// 5. 绘制原图作为背景
dc.DrawImageAnchored(originalImage, imgWidth/2, imgHeight/2, 0.5, 0.5)
// 6. 生成二维码(去掉白边)
qrCode, err := qrcode.New(qrcodeUrl, qrcode.Medium)
if err != nil {
logx.Errorf("生成二维码失败: %v, 二维码内容: %s", err, qrcodeUrl)
return nil, "", fmt.Errorf("生成二维码失败: %v", err)
}
// 禁用二维码边框,去掉白边
qrCode.DisableBorder = true
// 7. 根据二维码类型设置不同的尺寸和位置
var qrSize int
var qrX, qrY int
switch qrcodeType {
case "promote":
// promote类型精确设置二维码尺寸
qrSize = 280 // 固定尺寸280px
// 左下角位置:距左边和底边留一些边距
qrX = 192 // 距左边180px
qrY = imgHeight - qrSize - 190 // 距底边100px
case "invitation":
// invitation类型精确设置二维码尺寸
qrSize = 360 // 固定尺寸320px
// 中间偏上位置
qrX = (imgWidth - qrSize) / 2 // 水平居中
qrY = 555 // 垂直位置200px
default:
// 默认promote样式
qrSize = 280 // 固定尺寸280px
qrX = 200 // 距左边180px
qrY = imgHeight - qrSize - 200 // 距底边100px
}
// 8. 生成指定尺寸的二维码图片
qrCodeImage := qrCode.Image(qrSize)
// 9. 直接绘制二维码(不添加背景)
dc.DrawImageAnchored(qrCodeImage, qrX+qrSize/2, qrY+qrSize/2, 0.5, 0.5)
// 11. 输出为字节数组
var buf bytes.Buffer
err = png.Encode(&buf, dc.Image())
if err != nil {
logx.Errorf("编码图片失败: %v", err)
return nil, "", fmt.Errorf("编码图片失败: %v", err)
}
logx.Infof("成功生成带二维码的图片,类型: %s, 二维码内容: %s, 图片尺寸: %dx%d, 二维码尺寸: %dx%d, 位置: (%d,%d)",
qrcodeType, qrcodeUrl, imgWidth, imgHeight, qrSize, qrSize, qrX, qrY)
return buf.Bytes(), "image/png", nil
}
// loadImage 加载图片文件
func (s *ImageService) loadImage(path string) (image.Image, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
// 尝试解码PNG
img, err := png.Decode(file)
if err != nil {
// 如果PNG解码失败重新打开文件尝试JPEG
file.Close()
file, err = os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
img, err = jpeg.Decode(file)
if err != nil {
// 如果还是失败,使用通用解码器
file.Close()
file, err = os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
img, _, err = image.Decode(file)
if err != nil {
return nil, err
}
}
}
return img, nil
}
// GetSupportedImageTypes 获取支持的图片类型列表
func (s *ImageService) GetSupportedImageTypes() []string {
return []string{"promote", "invitation"}
}
// CheckImageExists 检查指定类型的背景图是否存在
func (s *ImageService) CheckImageExists(qrcodeType string) bool {
var backgroundImageName string
switch qrcodeType {
case "promote":
backgroundImageName = "tg_qrcode_1.png"
case "invitation":
backgroundImageName = "yq_qrcode_1.png"
default:
backgroundImageName = "tg_qrcode_1.png"
}
imagePath := filepath.Join(s.baseImagePath, backgroundImageName)
_, err := os.Stat(imagePath)
return err == nil
}

View File

@@ -0,0 +1,416 @@
package tianyuanapi
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// API调用相关错误类型
var (
ErrQueryEmpty = errors.New("查询为空")
ErrSystem = errors.New("接口异常")
ErrDecryptFail = errors.New("解密失败")
ErrRequestParam = errors.New("请求参数结构不正确")
ErrInvalidParam = errors.New("参数校验不正确")
ErrInvalidIP = errors.New("未经授权的IP")
ErrMissingAccessId = errors.New("缺少Access-Id")
ErrInvalidAccessId = errors.New("未经授权的AccessId")
ErrFrozenAccount = errors.New("账户已冻结")
ErrArrears = errors.New("账户余额不足,无法请求")
ErrProductNotFound = errors.New("产品不存在")
ErrProductDisabled = errors.New("产品已停用")
ErrNotSubscribed = errors.New("未订阅此产品")
ErrBusiness = errors.New("业务失败")
)
// 错误码映射 - 严格按照用户要求
var ErrorCodeMap = map[error]int{
ErrQueryEmpty: 1000,
ErrSystem: 1001,
ErrDecryptFail: 1002,
ErrRequestParam: 1003,
ErrInvalidParam: 1003,
ErrInvalidIP: 1004,
ErrMissingAccessId: 1005,
ErrInvalidAccessId: 1006,
ErrFrozenAccount: 1007,
ErrArrears: 1007,
ErrProductNotFound: 1008,
ErrProductDisabled: 1008,
ErrNotSubscribed: 1008,
ErrBusiness: 2001,
}
// ApiCallOptions API调用选项
type ApiCallOptions struct {
Json bool `json:"json,omitempty"` // 是否返回JSON格式
}
// Client 天元API客户端
type Client struct {
accessID string
key string
baseURL string
timeout time.Duration
client *http.Client
}
// Config 客户端配置
type Config struct {
AccessID string // 访问ID
Key string // AES密钥16进制
BaseURL string // API基础URL
Timeout time.Duration // 超时时间
}
// Request 请求参数
type Request struct {
InterfaceName string `json:"interfaceName"` // 接口名称
Params map[string]interface{} `json:"params"` // 请求参数
Timeout int `json:"timeout"` // 超时时间(毫秒)
Options *ApiCallOptions `json:"options"` // 调用选项
}
// ApiResponse HTTP API响应
type ApiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
TransactionID string `json:"transaction_id"` // 流水号
Data string `json:"data"` // 加密的数据
}
// Response Call方法的响应
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
Success bool `json:"success"`
TransactionID string `json:"transaction_id"` // 流水号
Data interface{} `json:"data"` // 解密后的数据
Timeout int64 `json:"timeout"` // 请求耗时(毫秒)
Error string `json:"error,omitempty"`
}
// NewClient 创建新的客户端实例
func NewClient(config Config) (*Client, error) {
// 参数校验
if config.AccessID == "" {
return nil, fmt.Errorf("accessID不能为空")
}
if config.Key == "" {
return nil, fmt.Errorf("key不能为空")
}
if config.BaseURL == "" {
config.BaseURL = "http://127.0.0.1:8080"
}
if config.Timeout == 0 {
config.Timeout = 60 * time.Second
}
// 验证密钥格式
if _, err := hex.DecodeString(config.Key); err != nil {
return nil, fmt.Errorf("无效的密钥格式必须是16进制字符串: %v", err)
}
return &Client{
accessID: config.AccessID,
key: config.Key,
baseURL: config.BaseURL,
timeout: config.Timeout,
client: &http.Client{
Timeout: config.Timeout,
},
}, nil
}
// Call 调用API接口
func (c *Client) Call(req Request) (*Response, error) {
startTime := time.Now()
// 参数校验
if err := c.validateRequest(req); err != nil {
return nil, fmt.Errorf("请求参数校验失败: %v", err)
}
// 加密参数
jsonData, err := json.Marshal(req.Params)
if err != nil {
return nil, fmt.Errorf("参数序列化失败: %v", err)
}
encryptedData, err := c.encrypt(string(jsonData))
if err != nil {
return nil, fmt.Errorf("数据加密失败: %v", err)
}
// 构建请求体
requestBody := map[string]interface{}{
"data": encryptedData,
}
// 添加选项
if req.Options != nil {
requestBody["options"] = req.Options
} else {
// 默认选项
defaultOptions := &ApiCallOptions{
Json: true,
}
requestBody["options"] = defaultOptions
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("请求体序列化失败: %v", err)
}
// 创建HTTP请求
url := fmt.Sprintf("%s/api/v1/%s", c.baseURL, req.InterfaceName)
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBodyBytes))
if err != nil {
return nil, fmt.Errorf("创建HTTP请求失败: %v", err)
}
// 设置请求头
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Access-Id", c.accessID)
httpReq.Header.Set("User-Agent", "TianyuanAPI-Go-SDK/1.0.0")
// 发送请求
resp, err := c.client.Do(httpReq)
if err != nil {
endTime := time.Now()
requestTime := endTime.Sub(startTime).Milliseconds()
return &Response{
Success: false,
Message: "请求失败",
Error: err.Error(),
Timeout: requestTime,
}, nil
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
endTime := time.Now()
requestTime := endTime.Sub(startTime).Milliseconds()
return &Response{
Success: false,
Message: "读取响应失败",
Error: err.Error(),
Timeout: requestTime,
}, nil
}
// 解析HTTP API响应
var apiResp ApiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
endTime := time.Now()
requestTime := endTime.Sub(startTime).Milliseconds()
return &Response{
Success: false,
Message: "响应解析失败",
Error: err.Error(),
Timeout: requestTime,
}, nil
}
// 计算请求耗时
endTime := time.Now()
requestTime := endTime.Sub(startTime).Milliseconds()
// 构建Call方法的响应
response := &Response{
Code: apiResp.Code,
Message: apiResp.Message,
Success: apiResp.Code == 0,
TransactionID: apiResp.TransactionID,
Timeout: requestTime,
}
// 如果有加密数据,尝试解密
if apiResp.Data != "" {
decryptedData, err := c.decrypt(apiResp.Data)
if err == nil {
var decryptedMap interface{}
if json.Unmarshal([]byte(decryptedData), &decryptedMap) == nil {
response.Data = decryptedMap
}
}
}
// 根据响应码返回对应的错误
if apiResp.Code != 0 {
err := GetErrorByCode(apiResp.Code)
return nil, err
}
return response, nil
}
// CallInterface 简化接口调用方法
func (c *Client) CallInterface(interfaceName string, params map[string]interface{}, options ...*ApiCallOptions) (*Response, error) {
var opts *ApiCallOptions
if len(options) > 0 {
opts = options[0]
}
req := Request{
InterfaceName: interfaceName,
Params: params,
Timeout: 60000,
Options: opts,
}
return c.Call(req)
}
// validateRequest 校验请求参数
func (c *Client) validateRequest(req Request) error {
if req.InterfaceName == "" {
return fmt.Errorf("interfaceName不能为空")
}
if req.Params == nil {
return fmt.Errorf("params不能为空")
}
return nil
}
// encrypt AES CBC加密
func (c *Client) encrypt(plainText string) (string, error) {
keyBytes, err := hex.DecodeString(c.key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
// 生成随机IV
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
// 填充数据
paddedData := c.pkcs7Pad([]byte(plainText), aes.BlockSize)
// 加密
ciphertext := make([]byte, len(iv)+len(paddedData))
copy(ciphertext, iv)
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext[len(iv):], paddedData)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decrypt AES CBC解密
func (c *Client) decrypt(encryptedText string) (string, error) {
keyBytes, err := hex.DecodeString(c.key)
if err != nil {
return "", err
}
ciphertext, err := base64.StdEncoding.DecodeString(encryptedText)
if err != nil {
return "", err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
if len(ciphertext) < aes.BlockSize {
return "", fmt.Errorf("密文太短")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
if len(ciphertext)%aes.BlockSize != 0 {
return "", fmt.Errorf("密文长度不是块大小的倍数")
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
// 去除填充
unpaddedData, err := c.pkcs7Unpad(ciphertext)
if err != nil {
return "", err
}
return string(unpaddedData), nil
}
// pkcs7Pad PKCS7填充
func (c *Client) pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
// pkcs7Unpad PKCS7去除填充
func (c *Client) pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, fmt.Errorf("数据为空")
}
unpadding := int(data[length-1])
if unpadding > length {
return nil, fmt.Errorf("无效的填充")
}
return data[:length-unpadding], nil
}
// GetErrorByCode 根据错误码获取错误
func GetErrorByCode(code int) error {
// 对于有多个错误对应同一错误码的情况,返回第一个
switch code {
case 1000:
return ErrQueryEmpty
case 1001:
return ErrSystem
case 1002:
return ErrDecryptFail
case 1003:
return ErrRequestParam
case 1004:
return ErrInvalidIP
case 1005:
return ErrMissingAccessId
case 1006:
return ErrInvalidAccessId
case 1007:
return ErrFrozenAccount
case 1008:
return ErrProductNotFound
case 2001:
return ErrBusiness
default:
return fmt.Errorf("未知错误码: %d", code)
}
}
// GetCodeByError 根据错误获取错误码
func GetCodeByError(err error) int {
if code, exists := ErrorCodeMap[err]; exists {
return code
}
return -1
}

View File

@@ -0,0 +1,296 @@
package service
import (
"context"
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/model"
"ycc-server/common/ctxdata"
jwtx "ycc-server/common/jwt"
"ycc-server/common/xerr"
"database/sql"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/zeromicro/go-zero/core/stores/sqlx"
)
type UserService struct {
Config *config.Config
userModel model.UserModel
userAuthModel model.UserAuthModel
userTempModel model.UserTempModel
agentModel model.AgentModel
}
// NewUserService 创建UserService实例
func NewUserService(config *config.Config, userModel model.UserModel, userAuthModel model.UserAuthModel, userTempModel model.UserTempModel, agentModel model.AgentModel) *UserService {
return &UserService{
Config: config,
userModel: userModel,
userAuthModel: userAuthModel,
userTempModel: userTempModel,
agentModel: agentModel,
}
}
// GenerateUUIDUserId 生成UUID用户ID
func (s *UserService) GenerateUUIDUserId(ctx context.Context) (string, error) {
id := uuid.NewString()
return id, nil
}
// RegisterUUIDUser 注册UUID用户返回用户ID
func (s *UserService) RegisterUUIDUser(ctx context.Context) (int64, error) {
// 生成UUID
uuidStr, err := s.GenerateUUIDUserId(ctx)
if err != nil {
return 0, err
}
var userId int64
err = s.userModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
// 创建用户记录
user := &model.User{}
result, err := s.userModel.Insert(ctx, session, user)
if err != nil {
return err
}
userId, err = result.LastInsertId()
if err != nil {
return err
}
// 创建用户认证记录
userAuth := &model.UserAuth{
UserId: userId,
AuthType: model.UserAuthTypeUUID,
AuthKey: uuidStr,
}
_, err = s.userAuthModel.Insert(ctx, session, userAuth)
return err
})
if err != nil {
return 0, err
}
return userId, nil
}
// generalUserToken 生成用户token
func (s *UserService) GeneralUserToken(ctx context.Context, userID int64, userType int64) (string, error) {
platform, err := ctxdata.GetPlatformFromCtx(ctx)
if err != nil {
return "", err
}
var isAgent int64
var agentID int64
if userType == model.UserTypeNormal {
agent, err := s.agentModel.FindOneByUserId(ctx, userID)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return "", err
}
if agent != nil {
agentID = agent.Id
isAgent = model.AgentStatusYes
}
} else {
userTemp, err := s.userTempModel.FindOne(ctx, userID)
if err != nil {
return "", err
}
if userTemp != nil {
userID = userTemp.Id
}
}
token, generaErr := jwtx.GenerateJwtToken(jwtx.JwtClaims{
UserId: userID,
AgentId: agentID,
Platform: platform,
UserType: userType,
IsAgent: isAgent,
}, s.Config.JwtAuth.AccessSecret, s.Config.JwtAuth.AccessExpire)
if generaErr != nil {
return "", errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "更新token, 生成token失败 : %d", userID)
}
return token, nil
}
// RegisterUser 注册用户返回用户ID
// 传入手机号自动注册如果ctx存在临时用户则临时用户转为正式用户
func (s *UserService) RegisterUser(ctx context.Context, mobile string) (int64, error) {
claims, err := ctxdata.GetClaimsFromCtx(ctx)
if err != nil && !errors.Is(err, ctxdata.ErrNoInCtx) {
return 0, err
}
user, err := s.userModel.FindOneByMobile(ctx, sql.NullString{String: mobile, Valid: true})
if err != nil && !errors.Is(err, model.ErrNotFound) {
return 0, err
}
if user != nil {
return 0, errors.New("用户已注册")
}
// 普通注册
if claims == nil {
var userId int64
err = s.userModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
user := &model.User{
Mobile: sql.NullString{String: mobile, Valid: true},
}
result, err := s.userModel.Insert(ctx, session, user)
if err != nil {
return err
}
userId, err = result.LastInsertId()
if err != nil {
return err
}
s.userAuthModel.Insert(ctx, session, &model.UserAuth{
UserId: userId,
AuthType: model.UserAuthTypeMobile,
AuthKey: mobile,
})
return nil
})
if err != nil {
return 0, err
}
return userId, nil
}
// 双重判断是否已经注册
if claims.UserType == model.UserTypeNormal {
return 0, errors.New("用户已注册")
}
var userId int64
// 临时转正式注册
err = s.userModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
user := &model.User{
Mobile: sql.NullString{String: mobile, Valid: true},
}
result, err := s.userModel.Insert(ctx, session, user)
if err != nil {
return err
}
userId, err = result.LastInsertId()
if err != nil {
return err
}
_, err = s.userAuthModel.Insert(ctx, session, &model.UserAuth{
UserId: userId,
AuthType: model.UserAuthTypeMobile,
AuthKey: mobile,
})
if err != nil {
return err
}
err = s.TempUserBindUser(ctx, session, userId)
if err != nil {
return err
}
return nil
})
if err != nil {
return 0, err
}
return userId, nil
}
// TempUserBindUser 临时用户绑定用户
func (s *UserService) TempUserBindUser(ctx context.Context, session sqlx.Session, normalUserID int64) error {
claims, err := ctxdata.GetClaimsFromCtx(ctx)
if err != nil && !errors.Is(err, ctxdata.ErrNoInCtx) {
return err
}
if claims == nil || claims.UserType != model.UserTypeTemp {
return errors.New("无临时用户")
}
// 使用事务上下文查询临时用户
userTemp, err := s.userTempModel.FindOne(ctx, claims.UserId)
if err != nil {
return err
}
// 检查是否已经注册过
userAuth, err := s.userAuthModel.FindOneByAuthTypeAuthKey(ctx, userTemp.AuthType, userTemp.AuthKey)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}
if userAuth != nil {
return errors.New("临时用户已注册")
}
if session == nil {
err := s.userAuthModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
_, err = s.userAuthModel.Insert(ctx, session, &model.UserAuth{
UserId: normalUserID,
AuthType: userTemp.AuthType,
AuthKey: userTemp.AuthKey,
})
if err != nil {
return err
}
// 重新获取最新的userTemp数据确保版本号是最新的
latestUserTemp, err := s.userTempModel.FindOne(ctx, claims.UserId)
if err != nil {
return err
}
err = s.userTempModel.DeleteSoft(ctx, session, latestUserTemp)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
} else {
_, err = s.userAuthModel.Insert(ctx, session, &model.UserAuth{
UserId: normalUserID,
AuthType: userTemp.AuthType,
AuthKey: userTemp.AuthKey,
})
if err != nil {
return err
}
// 重新获取最新的userTemp数据确保版本号是最新的
latestUserTemp, err := s.userTempModel.FindOne(ctx, claims.UserId)
if err != nil {
return err
}
err = s.userTempModel.DeleteSoft(ctx, session, latestUserTemp)
if err != nil {
return err
}
return nil
}
}
// _bak_RegisterUUIDUser 注册UUID用户返回用户ID
func (s *UserService) _bak_RegisterUUIDUser(ctx context.Context) error {
// 生成UUID
uuidStr, err := s.GenerateUUIDUserId(ctx)
if err != nil {
return err
}
err = s.userTempModel.Trans(ctx, func(ctx context.Context, session sqlx.Session) error {
// 创建用户临时记录
userTemp := &model.UserTemp{
AuthType: model.UserAuthTypeUUID,
AuthKey: uuidStr,
}
_, err := s.userTempModel.Insert(ctx, session, userTemp)
return err
})
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,152 @@
package service
import (
"ycc-server/app/main/api/internal/config"
tianyuanapi "ycc-server/app/main/api/internal/service/tianyuanapi_sdk"
"encoding/json"
"fmt"
"github.com/tidwall/gjson"
)
type VerificationService struct {
c config.Config
tianyuanapi *tianyuanapi.Client
apiRequestService *ApiRequestService
}
func NewVerificationService(c config.Config, tianyuanapi *tianyuanapi.Client, apiRequestService *ApiRequestService) *VerificationService {
return &VerificationService{
c: c,
tianyuanapi: tianyuanapi,
apiRequestService: apiRequestService,
}
}
// 二要素
type TwoFactorVerificationRequest struct {
Name string
IDCard string
}
type TwoFactorVerificationResp struct {
Msg string `json:"msg"`
Success bool `json:"success"`
Code int `json:"code"`
Data *TwoFactorVerificationData `json:"data"` //
}
type TwoFactorVerificationData struct {
Birthday string `json:"birthday"`
Result int `json:"result"`
Address string `json:"address"`
OrderNo string `json:"orderNo"`
Sex string `json:"sex"`
Desc string `json:"desc"`
}
// 三要素
type ThreeFactorVerificationRequest struct {
Name string
IDCard string
Mobile string
}
// VerificationResult 定义校验结果结构体
type VerificationResult struct {
Passed bool
Err error
}
// ValidationError 定义校验错误类型
type ValidationError struct {
Message string
}
func (e *ValidationError) Error() string {
return e.Message
}
func (r *VerificationService) TwoFactorVerification(request TwoFactorVerificationRequest) (*VerificationResult, error) {
resp, err := r.tianyuanapi.CallInterface("YYSYBE08", map[string]interface{}{
"name": request.Name,
"id_card": request.IDCard,
})
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, fmt.Errorf("转换响应失败: %v", err)
}
// 使用gjson获取resultCode
resultCode := gjson.GetBytes(respBytes, "ctidRequest.ctidAuth.resultCode")
if !resultCode.Exists() {
return &VerificationResult{
Passed: false,
Err: &ValidationError{Message: "获取resultCode失败"},
}, nil
}
// 获取resultCode的第一个字符
resultCodeStr := resultCode.String()
if len(resultCodeStr) == 0 {
return &VerificationResult{
Passed: false,
Err: &ValidationError{Message: "resultCode为空"},
}, nil
}
firstChar := string(resultCodeStr[0])
if firstChar != "0" && firstChar != "5" {
return &VerificationResult{
Passed: false,
Err: &ValidationError{Message: "姓名与身份证不一致"},
}, nil
}
return &VerificationResult{Passed: true, Err: nil}, nil
}
func (r *VerificationService) ThreeFactorVerification(request ThreeFactorVerificationRequest) (*VerificationResult, error) {
resp, err := r.tianyuanapi.CallInterface("YYSY09CD", map[string]interface{}{
"name": request.Name,
"id_card": request.IDCard,
"mobile_no": request.Mobile,
})
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, fmt.Errorf("转换响应失败: %v", err)
}
// 解析data.code
code := gjson.GetBytes(respBytes, "code")
if !code.Exists() {
return &VerificationResult{
Passed: false,
Err: &ValidationError{Message: "身份信息异常"},
}, nil
}
codeStr := code.String()
switch codeStr {
case "1000":
// 一致
return &VerificationResult{Passed: true, Err: nil}, nil
case "1001":
// 不一致
return &VerificationResult{
Passed: false,
Err: &ValidationError{Message: "姓名、证件号、手机号信息不一致"},
}, nil
default:
// 其他异常
return &VerificationResult{
Passed: false,
Err: &ValidationError{Message: "身份信息异常"},
}, nil
}
}

View File

@@ -0,0 +1,377 @@
package service
import (
"context"
"ycc-server/app/main/api/internal/config"
"ycc-server/app/main/model"
"ycc-server/common/ctxdata"
"ycc-server/pkg/lzkit/lzUtils"
"fmt"
"net/http"
"strconv"
"time"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/app"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
"github.com/zeromicro/go-zero/core/logx"
)
const (
TradeStateSuccess = "SUCCESS" // 支付成功
TradeStateRefund = "REFUND" // 转入退款
TradeStateNotPay = "NOTPAY" // 未支付
TradeStateClosed = "CLOSED" // 已关闭
TradeStateRevoked = "REVOKED" // 已撤销(付款码支付)
TradeStateUserPaying = "USERPAYING" // 用户支付中(付款码支付)
TradeStatePayError = "PAYERROR" // 支付失败(其他原因,如银行返回失败)
)
// InitType 初始化类型
type InitType string
const (
InitTypePlatformCert InitType = "platform_cert" // 平台证书初始化
InitTypeWxPayPubKey InitType = "wxpay_pubkey" // 微信支付公钥初始化
)
type WechatPayService struct {
config config.Config
wechatClient *core.Client
notifyHandler *notify.Handler
userAuthModel model.UserAuthModel
}
// NewWechatPayService 创建微信支付服务实例
func NewWechatPayService(c config.Config, userAuthModel model.UserAuthModel, initType InitType) *WechatPayService {
switch initType {
case InitTypePlatformCert:
return newWechatPayServiceWithPlatformCert(c, userAuthModel)
case InitTypeWxPayPubKey:
return newWechatPayServiceWithWxPayPubKey(c, userAuthModel)
default:
logx.Errorf("不支持的初始化类型: %s", initType)
panic(fmt.Sprintf("初始化失败,服务停止: %s", initType))
}
}
// newWechatPayServiceWithPlatformCert 使用平台证书初始化微信支付服务
func newWechatPayServiceWithPlatformCert(c config.Config, userAuthModel model.UserAuthModel) *WechatPayService {
// 从配置中加载商户信息
mchID := c.Wxpay.MchID
mchCertificateSerialNumber := c.Wxpay.MchCertificateSerialNumber
mchAPIv3Key := c.Wxpay.MchApiv3Key
// 从文件中加载商户私钥
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(c.Wxpay.MchPrivateKeyPath)
if err != nil {
logx.Errorf("加载商户私钥失败: %v", err)
panic(fmt.Sprintf("初始化失败,服务停止: %v", err)) // 记录错误并停止程序
}
// 使用商户私钥和其他参数初始化微信支付客户端
opts := []core.ClientOption{
option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
}
client, err := core.NewClient(context.Background(), opts...)
if err != nil {
logx.Errorf("创建微信支付客户端失败: %v", err)
panic(fmt.Sprintf("初始化失败,服务停止: %v", err)) // 记录错误并停止程序
}
// 在初始化时获取证书访问器并创建 notifyHandler
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
notifyHandler, err := notify.NewRSANotifyHandler(mchAPIv3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
if err != nil {
logx.Errorf("获取证书访问器失败: %v", err)
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
logx.Infof("微信支付客户端初始化成功(平台证书方式)")
return &WechatPayService{
config: c,
wechatClient: client,
notifyHandler: notifyHandler,
userAuthModel: userAuthModel,
}
}
// newWechatPayServiceWithWxPayPubKey 使用微信支付公钥初始化微信支付服务
func newWechatPayServiceWithWxPayPubKey(c config.Config, userAuthModel model.UserAuthModel) *WechatPayService {
// 从配置中加载商户信息
mchID := c.Wxpay.MchID
mchCertificateSerialNumber := c.Wxpay.MchCertificateSerialNumber
mchAPIv3Key := c.Wxpay.MchApiv3Key
mchPrivateKeyPath := c.Wxpay.MchPrivateKeyPath
mchPublicKeyID := c.Wxpay.MchPublicKeyID
mchPublicKeyPath := c.Wxpay.MchPublicKeyPath
// 从文件中加载商户私钥
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(mchPrivateKeyPath)
if err != nil {
logx.Errorf("加载商户私钥失败: %v", err)
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 从文件中加载微信支付平台证书
mchPublicKey, err := utils.LoadPublicKeyWithPath(mchPublicKeyPath)
if err != nil {
logx.Errorf("加载微信支付平台证书失败: %v", err)
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 使用商户私钥和其他参数初始化微信支付客户端
opts := []core.ClientOption{
option.WithWechatPayPublicKeyAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchPublicKeyID, mchPublicKey),
}
client, err := core.NewClient(context.Background(), opts...)
if err != nil {
logx.Errorf("创建微信支付客户端失败: %v", err)
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 初始化 notify.Handler
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
notifyHandler := notify.NewNotifyHandler(
mchAPIv3Key,
verifiers.NewSHA256WithRSACombinedVerifier(certificateVisitor, mchPublicKeyID, *mchPublicKey))
logx.Infof("微信支付客户端初始化成功(微信支付公钥方式)")
return &WechatPayService{
config: c,
wechatClient: client,
notifyHandler: notifyHandler,
userAuthModel: userAuthModel,
}
}
// CreateWechatAppOrder 创建微信APP支付订单
func (w *WechatPayService) CreateWechatAppOrder(ctx context.Context, amount float64, description string, outTradeNo string) (string, error) {
totalAmount := lzUtils.ToWechatAmount(amount)
// 构建支付请求参数
payRequest := app.PrepayRequest{
Appid: core.String(w.config.Wxpay.AppID),
Mchid: core.String(w.config.Wxpay.MchID),
Description: core.String(description),
OutTradeNo: core.String(outTradeNo),
NotifyUrl: core.String(w.config.Wxpay.NotifyUrl),
Amount: &app.Amount{
Total: core.Int64(totalAmount),
},
}
// 初始化 AppApiService
svc := app.AppApiService{Client: w.wechatClient}
// 发起预支付请求
resp, result, err := svc.Prepay(ctx, payRequest)
if err != nil {
return "", fmt.Errorf("微信支付订单创建失败: %v, 状态码: %d", err, result.Response.StatusCode)
}
// 返回预支付交易会话标识
return *resp.PrepayId, nil
}
// CreateWechatMiniProgramOrder 创建微信小程序支付订单
func (w *WechatPayService) CreateWechatMiniProgramOrder(ctx context.Context, amount float64, description string, outTradeNo string, openid string) (interface{}, error) {
totalAmount := lzUtils.ToWechatAmount(amount)
// 构建支付请求参数
payRequest := jsapi.PrepayRequest{
Appid: core.String(w.config.WechatMini.AppID),
Mchid: core.String(w.config.Wxpay.MchID),
Description: core.String(description),
OutTradeNo: core.String(outTradeNo),
NotifyUrl: core.String(w.config.Wxpay.NotifyUrl),
Amount: &jsapi.Amount{
Total: core.Int64(totalAmount),
},
Payer: &jsapi.Payer{
Openid: core.String(openid), // 用户的 OpenID通过前端传入
}}
// 初始化 AppApiService
svc := jsapi.JsapiApiService{Client: w.wechatClient}
// 发起预支付请求
resp, result, err := svc.PrepayWithRequestPayment(ctx, payRequest)
if err != nil {
return "", fmt.Errorf("微信支付订单创建失败: %v, 状态码: %d", err, result.Response.StatusCode)
}
// 返回预支付交易会话标识
return resp, nil
}
// CreateWechatH5Order 创建微信H5支付订单
func (w *WechatPayService) CreateWechatH5Order(ctx context.Context, amount float64, description string, outTradeNo string, openid string) (interface{}, error) {
totalAmount := lzUtils.ToWechatAmount(amount)
// 构建支付请求参数
payRequest := jsapi.PrepayRequest{
Appid: core.String(w.config.WechatH5.AppID),
Mchid: core.String(w.config.Wxpay.MchID),
Description: core.String(description),
OutTradeNo: core.String(outTradeNo),
NotifyUrl: core.String(w.config.Wxpay.NotifyUrl),
Amount: &jsapi.Amount{
Total: core.Int64(totalAmount),
},
Payer: &jsapi.Payer{
Openid: core.String(openid), // 用户的 OpenID通过前端传入
}}
// 初始化 AppApiService
svc := jsapi.JsapiApiService{Client: w.wechatClient}
// 发起预支付请求
resp, result, err := svc.PrepayWithRequestPayment(ctx, payRequest)
logx.Infof("微信h5支付订单resp: %+v, result: %+v, err: %+v", resp, result, err)
if err != nil {
return "", fmt.Errorf("微信支付订单创建失败: %v, 状态码: %d", err, result.Response.StatusCode)
}
// 返回预支付交易会话标识
return resp, nil
}
// CreateWechatOrder 创建微信支付订单(集成 APP、H5、小程序
func (w *WechatPayService) CreateWechatOrder(ctx context.Context, amount float64, description string, outTradeNo string) (interface{}, error) {
// 根据 ctx 中的 platform 判断平台
platform := ctx.Value("platform").(string)
var prepayData interface{}
var err error
switch platform {
case model.PlatformWxMini:
userID, getUidErr := ctxdata.GetUidFromCtx(ctx)
if getUidErr != nil {
return "", getUidErr
}
userAuthModel, findAuthModelErr := w.userAuthModel.FindOneByUserIdAuthType(ctx, userID, model.UserAuthTypeWxMiniOpenID)
if findAuthModelErr != nil {
return "", findAuthModelErr
}
prepayData, err = w.CreateWechatMiniProgramOrder(ctx, amount, description, outTradeNo, userAuthModel.AuthKey)
if err != nil {
return "", err
}
case model.PlatformWxH5:
userID, getUidErr := ctxdata.GetUidFromCtx(ctx)
if getUidErr != nil {
return "", getUidErr
}
userAuthModel, findAuthModelErr := w.userAuthModel.FindOneByUserIdAuthType(ctx, userID, model.UserAuthTypeWxh5OpenID)
if findAuthModelErr != nil {
return "", findAuthModelErr
}
prepayData, err = w.CreateWechatH5Order(ctx, amount, description, outTradeNo, userAuthModel.AuthKey)
if err != nil {
return "", err
}
case model.PlatformApp:
// 如果是 APP 平台,调用 APP 支付订单创建
prepayData, err = w.CreateWechatAppOrder(ctx, amount, description, outTradeNo)
default:
return "", fmt.Errorf("不支持的支付平台: %s", platform)
}
// 如果创建支付订单失败,返回错误
if err != nil {
return "", fmt.Errorf("支付订单创建失败: %v", err)
}
// 返回预支付ID
return prepayData, nil
}
// HandleWechatPayNotification 处理微信支付回调
func (w *WechatPayService) HandleWechatPayNotification(ctx context.Context, req *http.Request) (*payments.Transaction, error) {
transaction := new(payments.Transaction)
_, err := w.notifyHandler.ParseNotifyRequest(ctx, req, transaction)
if err != nil {
return nil, fmt.Errorf("微信支付通知处理失败: %v", err)
}
// 返回交易信息
return transaction, nil
}
// HandleRefundNotification 处理微信退款回调
func (w *WechatPayService) HandleRefundNotification(ctx context.Context, req *http.Request) (*refunddomestic.Refund, error) {
refund := new(refunddomestic.Refund)
_, err := w.notifyHandler.ParseNotifyRequest(ctx, req, refund)
if err != nil {
return nil, fmt.Errorf("微信退款回调通知处理失败: %v", err)
}
return refund, nil
}
// QueryOrderStatus 主动查询订单状态
func (w *WechatPayService) QueryOrderStatus(ctx context.Context, transactionID string) (*payments.Transaction, error) {
svc := jsapi.JsapiApiService{Client: w.wechatClient}
// 调用 QueryOrderById 方法查询订单状态
resp, result, err := svc.QueryOrderById(ctx, jsapi.QueryOrderByIdRequest{
TransactionId: core.String(transactionID),
Mchid: core.String(w.config.Wxpay.MchID),
})
if err != nil {
return nil, fmt.Errorf("订单查询失败: %v, 状态码: %d", err, result.Response.StatusCode)
}
return resp, nil
}
// WeChatRefund 申请微信退款
func (w *WechatPayService) WeChatRefund(ctx context.Context, outTradeNo string, refundAmount float64, totalAmount float64) error {
// 生成唯一的退款单号
outRefundNo := fmt.Sprintf("%s-refund", outTradeNo)
// 初始化退款服务
svc := refunddomestic.RefundsApiService{Client: w.wechatClient}
// 创建退款请求
resp, result, err := svc.Create(ctx, refunddomestic.CreateRequest{
OutTradeNo: core.String(outTradeNo),
OutRefundNo: core.String(outRefundNo),
NotifyUrl: core.String(w.config.Wxpay.RefundNotifyUrl),
Amount: &refunddomestic.AmountReq{
Currency: core.String("CNY"),
Refund: core.Int64(lzUtils.ToWechatAmount(refundAmount)),
Total: core.Int64(lzUtils.ToWechatAmount(totalAmount)),
},
})
if err != nil {
return fmt.Errorf("微信订单申请退款错误: %v", err)
}
// 打印退款结果
logx.Infof("退款申请成功,状态码=%d退款单号=%s微信退款单号=%s", result.Response.StatusCode, *resp.OutRefundNo, *resp.RefundId)
return nil
}
// GenerateOutTradeNo 生成唯一订单号
func (w *WechatPayService) GenerateOutTradeNo() string {
length := 16
timestamp := time.Now().UnixNano()
timeStr := strconv.FormatInt(timestamp, 10)
randomPart := strconv.Itoa(int(timestamp % 1e6))
combined := timeStr + randomPart
if len(combined) >= length {
return combined[:length]
}
for len(combined) < length {
combined += strconv.Itoa(int(timestamp % 10))
}
return combined
}