t
This commit is contained in:
211
app/main/api/internal/service/adminPromotionLinkStatsService.go
Normal file
211
app/main/api/internal/service/adminPromotionLinkStatsService.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"bd-server/app/main/model"
|
||||
"bd-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
|
||||
})
|
||||
}
|
||||
379
app/main/api/internal/service/agentService.go
Normal file
379
app/main/api/internal/service/agentService.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/model"
|
||||
"bd-server/common/globalkey"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
"github.com/zeromicro/go-zero/core/stores/sqlx"
|
||||
)
|
||||
|
||||
type AgentService struct {
|
||||
config config.Config
|
||||
OrderModel model.OrderModel
|
||||
AgentModel model.AgentModel
|
||||
AgentAuditModel model.AgentAuditModel
|
||||
AgentCommissionModel model.AgentCommissionModel
|
||||
AgentWalletModel model.AgentWalletModel
|
||||
AgentLinkModel model.AgentLinkModel
|
||||
AgentOrderModel model.AgentOrderModel
|
||||
AgentProductConfigModel model.AgentProductConfigModel
|
||||
AgentWithdrawalModel model.AgentWithdrawalModel
|
||||
AgentWalletTransactionModel model.AgentWalletTransactionModel
|
||||
AgentConfigModel model.AgentConfigModel
|
||||
AsynqService *AsynqService
|
||||
}
|
||||
|
||||
func NewAgentService(c config.Config, orderModel model.OrderModel, agentModel model.AgentModel, agentAuditModel model.AgentAuditModel,
|
||||
agentCommissionModel model.AgentCommissionModel, agentWalletModel model.AgentWalletModel, agentLinkModel model.AgentLinkModel,
|
||||
agentOrderModel model.AgentOrderModel, agentProductConfigModel model.AgentProductConfigModel,
|
||||
agentWithdrawalModel model.AgentWithdrawalModel, agentWalletTransactionModel model.AgentWalletTransactionModel,
|
||||
agentConfigModel model.AgentConfigModel, asynqService *AsynqService) *AgentService {
|
||||
|
||||
return &AgentService{
|
||||
config: c,
|
||||
OrderModel: orderModel,
|
||||
AgentModel: agentModel,
|
||||
AgentAuditModel: agentAuditModel,
|
||||
AgentCommissionModel: agentCommissionModel,
|
||||
AgentWalletModel: agentWalletModel,
|
||||
AgentLinkModel: agentLinkModel,
|
||||
AgentOrderModel: agentOrderModel,
|
||||
AgentProductConfigModel: agentProductConfigModel,
|
||||
AgentWithdrawalModel: agentWithdrawalModel,
|
||||
AgentWalletTransactionModel: agentWalletTransactionModel,
|
||||
AgentConfigModel: agentConfigModel,
|
||||
AsynqService: asynqService,
|
||||
}
|
||||
}
|
||||
|
||||
// AgentProcess 推广单成功
|
||||
func (l *AgentService) AgentProcess(ctx context.Context, order *model.Order) error {
|
||||
agentOrderModel, err := l.AgentOrderModel.FindOneByOrderId(ctx, order.Id)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, model.ErrNotFound) || agentOrderModel == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
transErr := l.AgentWalletModel.Trans(ctx, func(transCtx context.Context, session sqlx.Session) error {
|
||||
agentID := agentOrderModel.AgentId
|
||||
|
||||
productConfig, findProductConfigErr := l.AgentProductConfigModel.FindOneByProductId(transCtx, order.ProductId)
|
||||
if findProductConfigErr != nil {
|
||||
return findProductConfigErr
|
||||
}
|
||||
|
||||
commission := order.Amount - productConfig.CostPrice
|
||||
if commission < 0 {
|
||||
commission = 0
|
||||
}
|
||||
|
||||
agentWallet, findWalletErr := l.AgentWalletModel.FindOneByAgentId(transCtx, agentID)
|
||||
if findWalletErr != nil {
|
||||
return findWalletErr
|
||||
}
|
||||
|
||||
balanceBefore := agentWallet.Balance
|
||||
frozenBalanceBefore := agentWallet.FrozenBalance
|
||||
|
||||
var commissionStatus int64
|
||||
if l.AgentConfigModel.IsCommissionSafeMode(ctx) {
|
||||
agentWallet.FrozenBalance += commission
|
||||
commissionStatus = 1
|
||||
} else {
|
||||
agentWallet.Balance += commission
|
||||
commissionStatus = 0
|
||||
}
|
||||
agentWallet.TotalEarnings += commission
|
||||
|
||||
agentCommission := model.AgentCommission{
|
||||
AgentId: agentID,
|
||||
OrderId: order.Id,
|
||||
Amount: commission,
|
||||
ProductId: order.ProductId,
|
||||
Status: commissionStatus,
|
||||
}
|
||||
insertResult, insertCommissionErr := l.AgentCommissionModel.Insert(transCtx, session, &agentCommission)
|
||||
if insertCommissionErr != nil {
|
||||
return insertCommissionErr
|
||||
}
|
||||
|
||||
updateWalletErr := l.AgentWalletModel.UpdateWithVersion(transCtx, session, agentWallet)
|
||||
if updateWalletErr != nil {
|
||||
return updateWalletErr
|
||||
}
|
||||
|
||||
commissionID, _ := insertResult.LastInsertId()
|
||||
commissionIDStr := fmt.Sprintf("%d", commissionID)
|
||||
|
||||
transErr := l.CreateWalletTransaction(
|
||||
transCtx,
|
||||
session,
|
||||
agentID,
|
||||
model.WalletTransactionTypeCommission,
|
||||
commission,
|
||||
balanceBefore,
|
||||
agentWallet.Balance,
|
||||
frozenBalanceBefore,
|
||||
agentWallet.FrozenBalance,
|
||||
order.OrderNo,
|
||||
0,
|
||||
fmt.Sprintf("订单佣金收入,佣金记录ID: %s", commissionIDStr),
|
||||
)
|
||||
if transErr != nil {
|
||||
return transErr
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if transErr != nil {
|
||||
return transErr
|
||||
}
|
||||
|
||||
if l.AsynqService != nil && l.AgentConfigModel.IsCommissionSafeMode(ctx) {
|
||||
builder := l.AgentCommissionModel.SelectBuilder().
|
||||
Where("order_id = ?", order.Id).
|
||||
Where("status = ?", 1).
|
||||
Where("del_state = ?", globalkey.DelStateNo)
|
||||
|
||||
commissions, findErr := l.AgentCommissionModel.FindAll(ctx, builder, "")
|
||||
if findErr != nil {
|
||||
logx.Errorf("查询刚创建的佣金记录失败,订单ID: %d, 错误: %v", order.Id, findErr)
|
||||
return findErr
|
||||
}
|
||||
|
||||
if len(commissions) > 0 {
|
||||
for _, commission := range commissions {
|
||||
sendTaskErr := l.AsynqService.SendUnfreezeCommissionTask(commission.Id)
|
||||
if sendTaskErr != nil {
|
||||
logx.Errorf("发送佣金解冻任务失败,佣金ID: %d, 错误: %v", commission.Id, sendTaskErr)
|
||||
} else {
|
||||
logx.Infof("已发送佣金解冻任务,佣金ID: %d, 代理ID: %d, 金额: %.2f",
|
||||
commission.Id, commission.AgentId, commission.Amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAgentProcessStatus 检查代理处理事务是否已成功
|
||||
func (l *AgentService) CheckAgentProcessStatus(ctx context.Context, orderID int64) (bool, error) {
|
||||
_, err := l.AgentOrderModel.FindOneByOrderId(ctx, orderID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
selectBuilder := l.AgentCommissionModel.SelectBuilder()
|
||||
selectBuilder = selectBuilder.Where("order_id = ?", orderID)
|
||||
selectBuilder = selectBuilder.Where("del_state = ?", 0)
|
||||
|
||||
commissions, err := l.AgentCommissionModel.FindAll(ctx, selectBuilder, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return len(commissions) > 0, nil
|
||||
}
|
||||
|
||||
// RetryAgentProcess 重新执行代理处理事务
|
||||
func (l *AgentService) RetryAgentProcess(ctx context.Context, orderID int64) error {
|
||||
order, err := l.OrderModel.FindOne(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if order.Status != "paid" {
|
||||
return errors.New("订单状态不是已支付,无法执行代理处理")
|
||||
}
|
||||
|
||||
alreadyProcessed, err := l.CheckAgentProcessStatus(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if alreadyProcessed {
|
||||
return errors.New("代理处理已经成功,无需重新执行")
|
||||
}
|
||||
|
||||
return l.AgentProcess(ctx, order)
|
||||
}
|
||||
|
||||
// CreateWalletTransaction 创建代理钱包流水记录
|
||||
func (l *AgentService) CreateWalletTransaction(ctx context.Context, session sqlx.Session,
|
||||
agentID int64, transactionType string, amount float64,
|
||||
balanceBefore, balanceAfter, frozenBalanceBefore, frozenBalanceAfter float64,
|
||||
transactionID string, relatedUserID int64, remark string) error {
|
||||
|
||||
var transactionIDField sql.NullString
|
||||
if transactionID != "" {
|
||||
transactionIDField = sql.NullString{String: transactionID, Valid: true}
|
||||
}
|
||||
|
||||
var relatedUserIDField sql.NullInt64
|
||||
if relatedUserID > 0 {
|
||||
relatedUserIDField = sql.NullInt64{Int64: relatedUserID, Valid: true}
|
||||
}
|
||||
|
||||
var remarkField sql.NullString
|
||||
if remark != "" {
|
||||
remarkField = sql.NullString{String: remark, Valid: true}
|
||||
}
|
||||
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
AgentId: agentID,
|
||||
TransactionType: transactionType,
|
||||
Amount: amount,
|
||||
BalanceBefore: balanceBefore,
|
||||
BalanceAfter: balanceAfter,
|
||||
FrozenBalanceBefore: frozenBalanceBefore,
|
||||
FrozenBalanceAfter: frozenBalanceAfter,
|
||||
TransactionId: transactionIDField,
|
||||
RelatedUserId: relatedUserIDField,
|
||||
Remark: remarkField,
|
||||
}
|
||||
|
||||
_, err := l.AgentWalletTransactionModel.Insert(ctx, session, transaction)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "创建代理钱包流水记录失败,agentID: %d, type: %s, amount: %.2f", agentID, transactionType, amount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleOrderRefundDeduction 处理订单退款后的佣金扣款流程
|
||||
// 简化版:只扣减推广代理佣金,不再处理上级抽佣和奖励
|
||||
func (l *AgentService) HandleOrderRefundDeduction(ctx context.Context, session sqlx.Session, order *model.Order, refundAmount float64) {
|
||||
refundAmount = roundRefundMoney(refundAmount)
|
||||
if refundAmount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
remainRefundAmount := refundAmount
|
||||
|
||||
agentOrder, err := l.AgentOrderModel.FindOneByOrderId(ctx, order.Id)
|
||||
if err != nil || agentOrder == nil {
|
||||
logx.Errorf("退款扣款:查询代理订单失败,订单ID: %d, 错误: %v", order.Id, err)
|
||||
return
|
||||
}
|
||||
promoterAgentId := agentOrder.AgentId
|
||||
|
||||
// 扣减推广代理佣金
|
||||
promoterCommissions, _ := l.AgentCommissionModel.FindAll(ctx,
|
||||
l.AgentCommissionModel.SelectBuilder().Where(squirrel.And{
|
||||
squirrel.Eq{"order_id": order.Id},
|
||||
squirrel.Eq{"agent_id": promoterAgentId},
|
||||
squirrel.NotEq{"status": 2},
|
||||
}), "")
|
||||
|
||||
for _, commission := range promoterCommissions {
|
||||
if remainRefundAmount <= 0 {
|
||||
break
|
||||
}
|
||||
available := roundRefundMoney(commission.Amount - commission.RefundedAmount)
|
||||
if available <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
currentRefund := available
|
||||
if currentRefund > remainRefundAmount {
|
||||
currentRefund = remainRefundAmount
|
||||
}
|
||||
currentRefund = roundRefundMoney(currentRefund)
|
||||
|
||||
commission.RefundedAmount = roundRefundMoney(commission.RefundedAmount + currentRefund)
|
||||
if commission.RefundedAmount >= roundRefundMoney(commission.Amount) {
|
||||
commission.Status = 2
|
||||
}
|
||||
|
||||
if err := l.AgentCommissionModel.UpdateWithVersion(ctx, session, commission); err != nil {
|
||||
logx.Errorf("退款扣款:更新推广佣金失败,佣金ID: %d, 错误: %v", commission.Id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
deductFromAgentWallet(ctx, l, session, promoterAgentId, currentRefund,
|
||||
fmt.Sprintf("订单退款,推广佣金扣除,订单号: %s", order.OrderNo))
|
||||
|
||||
remainRefundAmount = roundRefundMoney(remainRefundAmount - currentRefund)
|
||||
}
|
||||
|
||||
// 不足部分从推广代理钱包扣
|
||||
if remainRefundAmount > 0 {
|
||||
deductFromAgentWallet(ctx, l, session, promoterAgentId, remainRefundAmount,
|
||||
fmt.Sprintf("订单退款,不足部分从钱包扣除,订单号: %s", order.OrderNo))
|
||||
}
|
||||
}
|
||||
|
||||
// roundRefundMoney 四舍五入到分
|
||||
func roundRefundMoney(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
// deductFromAgentWallet 从代理钱包扣除金额(先冻结后余额)
|
||||
func deductFromAgentWallet(ctx context.Context, l *AgentService, session sqlx.Session, agentId int64, amount float64, remark string) {
|
||||
amount = roundRefundMoney(amount)
|
||||
if amount <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
wallet, err := l.AgentWalletModel.FindOneByAgentId(ctx, agentId)
|
||||
if err != nil {
|
||||
logx.Errorf("退款扣款:查询代理钱包失败,代理ID: %d, 错误: %v", agentId, err)
|
||||
return
|
||||
}
|
||||
|
||||
balanceBefore := wallet.Balance
|
||||
frozenBalanceBefore := wallet.FrozenBalance
|
||||
|
||||
if wallet.FrozenBalance >= amount {
|
||||
wallet.FrozenBalance = roundRefundMoney(wallet.FrozenBalance - amount)
|
||||
} else {
|
||||
remaining := roundRefundMoney(amount - wallet.FrozenBalance)
|
||||
wallet.FrozenBalance = 0
|
||||
wallet.Balance = roundRefundMoney(wallet.Balance - remaining)
|
||||
}
|
||||
|
||||
balanceAfter := roundRefundMoney(wallet.Balance)
|
||||
frozenBalanceAfter := roundRefundMoney(wallet.FrozenBalance)
|
||||
|
||||
var updateErr error
|
||||
if session != nil {
|
||||
updateErr = l.AgentWalletModel.UpdateWithVersion(ctx, session, wallet)
|
||||
} else {
|
||||
updateErr = l.AgentWalletModel.UpdateWithVersion(ctx, nil, wallet)
|
||||
}
|
||||
if updateErr != nil {
|
||||
logx.Errorf("退款扣款:更新代理钱包失败,代理ID: %d, 错误: %v", agentId, updateErr)
|
||||
return
|
||||
}
|
||||
|
||||
transErr := l.CreateWalletTransaction(
|
||||
ctx,
|
||||
session,
|
||||
agentId,
|
||||
model.WalletTransactionTypeRefund,
|
||||
roundRefundMoney(-amount),
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
frozenBalanceBefore,
|
||||
frozenBalanceAfter,
|
||||
"",
|
||||
0,
|
||||
remark,
|
||||
)
|
||||
if transErr != nil {
|
||||
logx.Errorf("退款扣款:创建钱包流水失败,代理ID: %d, 错误: %v", agentId, transErr)
|
||||
}
|
||||
}
|
||||
265
app/main/api/internal/service/alipayService.go
Normal file
265
app/main/api/internal/service/alipayService.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/model"
|
||||
"bd-server/pkg/lzkit/lzUtils"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"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,
|
||||
TransferSceneName: "业务结算",
|
||||
TransferSceneReportInfo: []*alipay.TransferSceneReportInfo{
|
||||
{
|
||||
InfoType: "结算款项名称",
|
||||
InfoContent: "向代理商转账支付代理费用",
|
||||
},
|
||||
},
|
||||
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
|
||||
}
|
||||
223
app/main/api/internal/service/apiRegistryService.go
Normal file
223
app/main/api/internal/service/apiRegistryService.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"bd-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
|
||||
}
|
||||
1491
app/main/api/internal/service/apirequestService.go
Normal file
1491
app/main/api/internal/service/apirequestService.go
Normal file
File diff suppressed because it is too large
Load Diff
169
app/main/api/internal/service/applepayService.go
Normal file
169
app/main/api/internal/service/applepayService.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"bd-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
|
||||
}
|
||||
92
app/main/api/internal/service/asynqService.go
Normal file
92
app/main/api/internal/service/asynqService.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// asynq_service.go
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/api/internal/types"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// SendUnfreezeCommissionTask 发送佣金解冻任务
|
||||
func (s *AsynqService) SendUnfreezeCommissionTask(commissionID int64) error {
|
||||
// 准备任务的 payload
|
||||
payload := types.MsgUnfreezeCommissionPayload{
|
||||
CommissionID: commissionID,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
logx.Errorf("发送佣金解冻任务失败 (无法编码 payload): %v, 佣金ID: %d", err, commissionID)
|
||||
return err
|
||||
}
|
||||
|
||||
options := []asynq.Option{
|
||||
asynq.ProcessIn(time.Duration(s.config.ExtensionTime) * time.Hour), // 10小时后执行
|
||||
asynq.MaxRetry(5), // 设置最大重试次数
|
||||
}
|
||||
task := asynq.NewTask(types.MsgUnfreezeCommission, payloadBytes, options...)
|
||||
|
||||
// 将任务加入队列并获取任务信息
|
||||
info, err := s.client.Enqueue(task)
|
||||
if err != nil {
|
||||
logx.Errorf("发送佣金解冻任务失败 (加入队列失败): %+v, 佣金ID: %d", err, commissionID)
|
||||
return err
|
||||
}
|
||||
|
||||
// 记录成功日志,带上任务 ID 和队列信息
|
||||
logx.Infof("发送佣金解冻任务成功,任务ID: %s, 队列: %s, 佣金ID: %d", info.ID, info.Queue, commissionID)
|
||||
return nil
|
||||
}
|
||||
460
app/main/api/internal/service/authorizationService.go
Normal file
460
app/main/api/internal/service/authorizationService.go
Normal file
@@ -0,0 +1,460 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/model"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
absStoragePath := determineStoragePath()
|
||||
|
||||
return &AuthorizationService{
|
||||
config: c,
|
||||
authDocModel: authDocModel,
|
||||
fileStoragePath: absStoragePath,
|
||||
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)
|
||||
}
|
||||
|
||||
// ResolveFilePath 根据存储的路径信息解析出本地文件的绝对路径
|
||||
func (s *AuthorizationService) ResolveFilePath(filePath string, relativePath string) string {
|
||||
candidates := []string{}
|
||||
|
||||
cleanRelative := func(p string) string {
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
p = strings.TrimPrefix(p, "\\")
|
||||
normalized := filepath.ToSlash(p)
|
||||
if strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://") {
|
||||
if parsed, err := url.Parse(normalized); err == nil {
|
||||
normalized = filepath.ToSlash(strings.TrimPrefix(parsed.Path, "/"))
|
||||
}
|
||||
}
|
||||
normalized = strings.TrimPrefix(normalized, "data/authorization_docs/")
|
||||
normalized = strings.TrimPrefix(normalized, "authorization_docs/")
|
||||
normalized = strings.TrimPrefix(normalized, "./")
|
||||
normalized = strings.TrimPrefix(normalized, "../")
|
||||
return normalized
|
||||
}
|
||||
|
||||
if filePath != "" {
|
||||
candidates = append(candidates, filePath)
|
||||
}
|
||||
if relativePath != "" {
|
||||
candidates = append(candidates, filepath.Join(s.fileStoragePath, filepath.FromSlash(cleanRelative(relativePath))))
|
||||
}
|
||||
if filePath != "" {
|
||||
cleaned := filepath.Clean(filePath)
|
||||
if strings.HasPrefix(cleaned, s.fileStoragePath) {
|
||||
candidates = append(candidates, cleaned)
|
||||
} else {
|
||||
trimmed := strings.TrimPrefix(cleaned, "data"+string(os.PathSeparator)+"authorization_docs"+string(os.PathSeparator))
|
||||
trimmed = strings.TrimPrefix(trimmed, "data/authorization_docs/")
|
||||
if trimmed != cleaned {
|
||||
candidates = append(candidates, filepath.Join(s.fileStoragePath, filepath.FromSlash(cleanRelative(trimmed))))
|
||||
}
|
||||
if !filepath.IsAbs(cleaned) {
|
||||
candidates = append(candidates, filepath.Join(s.fileStoragePath, filepath.FromSlash(cleanRelative(cleaned))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
pathCandidate := candidate
|
||||
if !filepath.IsAbs(pathCandidate) {
|
||||
if absPath, err := filepath.Abs(pathCandidate); err == nil {
|
||||
pathCandidate = absPath
|
||||
}
|
||||
}
|
||||
if statErr := checkFileExists(pathCandidate); statErr == nil {
|
||||
return pathCandidate
|
||||
}
|
||||
}
|
||||
|
||||
logx.Errorf("授权书文件路径解析失败 filePath=%s fileUrl=%s candidates=%v", filePath, relativePath, candidates)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func determineStoragePath() string {
|
||||
candidatePaths := []string{
|
||||
"data/authorization_docs",
|
||||
"../data/authorization_docs",
|
||||
"../../data/authorization_docs",
|
||||
"../../../data/authorization_docs",
|
||||
}
|
||||
|
||||
for _, candidate := range candidatePaths {
|
||||
absPath, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
logx.Errorf("解析授权书存储路径失败: %s, err=%v", candidate, err)
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(absPath); err == nil && info.IsDir() {
|
||||
logx.Infof("授权书存储路径选择: %s", absPath)
|
||||
return absPath
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有现成的目录,使用第一个候选的绝对路径
|
||||
absPath, err := filepath.Abs(candidatePaths[0])
|
||||
if err != nil {
|
||||
logx.Errorf("解析默认授权书存储路径失败,使用相对路径: %s, err=%v", candidatePaths[0], err)
|
||||
return candidatePaths[0]
|
||||
}
|
||||
logx.Infof("授权书存储路径创建: %s", absPath)
|
||||
return absPath
|
||||
}
|
||||
|
||||
func checkFileExists(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("path %s is directory", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 构建授权书内容(与前端 Authorization.vue 保持一致)
|
||||
content := fmt.Sprintf(`沈阳知讯科技有限公司:
|
||||
本人(姓名:%s/身份证号码:%s)拟向贵司申请业务,贵司需要了解本人相关状况,用于查询大数据分析报告,因此本人特同意并不可撤销的授权:
|
||||
|
||||
(一)贵司向依法成立的第三方服务商(包括但不限于天津津北数字产业发展集团有限公司)根据本人提交的信息进行核实;并有权通过前述第三方服务机构查询、使用本人的身份信息、电话号码等,查询本人信息(包括但不限于学历、婚姻、资产状况及对信息主体产生负面影响的不良信息),出具相关报告。
|
||||
(二)第三方服务商应当在上述处理目的、处理方式和个人信息的种类等范围内处理个人信息。变更原先的处理目的、处理方式的,应当依法重新取得您的同意。
|
||||
|
||||
本人在此声明已充分理解上述授权条款含义,知晓并自愿承担上述因收集等本人数据可能会给本人的生活行为(评分)结果产生不利影响,以及该等数据被使用者依法提供给第三方后被他人不当利用的风险,但本人仍同意上述授权。
|
||||
|
||||
特别提示:
|
||||
为了保障您的合法权益,请您务必阅读并充分理解与遵守本授权书;若您不接受本授权书的任何条款,请您立即终止授权。贵司已经对上述事宜及其风险向本人做了充分说明,本人已知晓并同意。
|
||||
你通过“知讯数据”APP或代理商推广查询模式,自愿支付相应费用,用于购买沈阳知讯科技有限公司的大数据报告产品。
|
||||
你向沈阳知讯科技有限公司的支付方式为:沈阳知讯科技有限公司及其关联公司的支付宝及微信账户。
|
||||
本授权书一经本人在网上点击勾选同意即完成签署。本授权书是本人真实意思表示,本人同意承担由此带来的一切法律后果。
|
||||
|
||||
授权人:%s
|
||||
身份证号:%s
|
||||
签署时间:%s`, name, idCard, 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 ""
|
||||
}
|
||||
|
||||
// generatePDFFromTemplate 从模板文件生成PDF内容
|
||||
func (s *AuthorizationService) generatePDFFromTemplate(templatePath string, data map[string]string) ([]byte, error) {
|
||||
// 查找模板文件(与字体文件使用相同的路径策略)
|
||||
tmplPaths := []string{
|
||||
templatePath,
|
||||
"/app/" + templatePath,
|
||||
"app/main/api/" + templatePath,
|
||||
"../../" + templatePath, // 从 internal/service/ 向上
|
||||
"../../../" + templatePath, // 从 api/internal/service/ 向上
|
||||
"../../../../" + templatePath, // 从 app/main/api/internal/service/ 向上
|
||||
"../../../../../" + templatePath, // 从 bd-server/app/main/api/internal/service/ 向上
|
||||
"../../static/" + filepath.Base(templatePath), // 从 internal/service/ 找 static/
|
||||
"../../../static/" + filepath.Base(templatePath), // 从 api/internal/service/ 找 static/
|
||||
"../../../../static/" + filepath.Base(templatePath), // 从 app/main/api/internal/service/ 找 static/
|
||||
}
|
||||
|
||||
var tmplFile string
|
||||
for _, p := range tmplPaths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
tmplFile = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tmplFile == "" {
|
||||
return nil, fmt.Errorf("未找到模板文件: %s", templatePath)
|
||||
}
|
||||
|
||||
// 解析模板文件
|
||||
tmpl, err := template.ParseFiles(tmplFile)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "解析模板文件失败: %s", tmplFile)
|
||||
}
|
||||
|
||||
// 渲染模板
|
||||
var contentBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&contentBuf, data); err != nil {
|
||||
return nil, errors.Wrapf(err, "渲染模板失败")
|
||||
}
|
||||
renderedContent := contentBuf.String()
|
||||
|
||||
// 创建PDF文档
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
|
||||
// 加载中文字体 - 与 generatePDFContent 保持一致的路径策略,并增加更多 fallback
|
||||
fontPaths := []string{
|
||||
"static/SIMHEI.TTF", // 相对于工作目录的路径
|
||||
"/app/static/SIMHEI.TTF", // Docker容器内
|
||||
"app/main/api/static/SIMHEI.TTF", // 开发环境备用
|
||||
"../../static/SIMHEI.TTF", // 从 internal/service/ 向上
|
||||
"../../../static/SIMHEI.TTF", // 从 api/internal/service/ 向上
|
||||
"../../../../static/SIMHEI.TTF", // 从 app/main/api/internal/service/ 向上
|
||||
}
|
||||
|
||||
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", "", 11)
|
||||
logx.Errorf("未找到中文字体文件,使用默认Arial字体,可能无法正确显示中文")
|
||||
} else {
|
||||
pdf.SetFont("ChineseFont", "", 11)
|
||||
}
|
||||
|
||||
// 设置每页Footer回调,用于在每页添加水印
|
||||
// companyName 从模板变量中获取,若无则使用默认值
|
||||
companyName := data["CompanyName"]
|
||||
if companyName == "" {
|
||||
companyName = "沈阳知讯科技有限公司"
|
||||
}
|
||||
capturedFontAdded := fontAdded
|
||||
capturedCompanyName := companyName
|
||||
pdf.SetFooterFunc(func() {
|
||||
addWatermarkToPage(pdf, capturedFontAdded, capturedCompanyName)
|
||||
})
|
||||
|
||||
pdf.AddPage()
|
||||
|
||||
// 写入渲染后的内容
|
||||
pdf.MultiCell(0, 6, renderedContent, "", "", false)
|
||||
|
||||
// 输出PDF字节
|
||||
var pdfBuf bytes.Buffer
|
||||
if err := pdf.Output(&pdfBuf); err != nil {
|
||||
return nil, errors.Wrapf(err, "生成PDF字节数组失败")
|
||||
}
|
||||
|
||||
return pdfBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
// addWatermarkToPage 给当前页面添加水印(红色公司名,居中单个水印)
|
||||
func addWatermarkToPage(pdf *gofpdf.Fpdf, fontAdded bool, companyName string) {
|
||||
if fontAdded {
|
||||
pdf.SetFont("ChineseFont", "", 40)
|
||||
} else {
|
||||
pdf.SetFont("Arial", "", 40)
|
||||
}
|
||||
pdf.SetTextColor(255, 200, 200) // 浅红色
|
||||
|
||||
// A4页面尺寸 210x297mm,水印放在页面正中央
|
||||
pageWidth, pageHeight := pdf.GetPageSize()
|
||||
x := pageWidth / 2
|
||||
y := pageHeight / 2
|
||||
|
||||
pdf.TransformBegin()
|
||||
pdf.TransformRotate(45, x, y)
|
||||
pdf.Text(x, y, companyName)
|
||||
pdf.TransformEnd()
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
}
|
||||
|
||||
// GenerateIVYZ4Y27AuthorizationBase64 生成IVYZ4Y27专用授权书PDF并返回Base64编码字符串
|
||||
func (s *AuthorizationService) GenerateIVYZ4Y27AuthorizationBase64(userInfo map[string]interface{}) (string, error) {
|
||||
name := getUserInfoString(userInfo, "name")
|
||||
idCard := getUserInfoString(userInfo, "id_card")
|
||||
if name == "" || idCard == "" {
|
||||
return "", fmt.Errorf("缺少必要的用户信息(name或id_card)")
|
||||
}
|
||||
|
||||
// 构建模板变量
|
||||
data := map[string]string{
|
||||
"CompanyName": "沈阳知讯科技有限公司",
|
||||
"Name": name,
|
||||
"IdCard": idCard,
|
||||
"Mobile": getUserInfoString(userInfo, "mobile"),
|
||||
"Date": time.Now().Format("2006年1月2日"),
|
||||
}
|
||||
|
||||
// 从模板生成PDF
|
||||
pdfBytes, err := s.generatePDFFromTemplate("static/authorization_ivyz4y27.tmpl", data)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "生成IVYZ4Y27授权书PDF失败")
|
||||
}
|
||||
|
||||
// Base64编码
|
||||
return base64.StdEncoding.EncodeToString(pdfBytes), nil
|
||||
}
|
||||
773
app/main/api/internal/service/authorizationService_test.go
Normal file
773
app/main/api/internal/service/authorizationService_test.go
Normal file
@@ -0,0 +1,773 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/model"
|
||||
|
||||
"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": "110101199001011234",
|
||||
"mobile": "13800138000",
|
||||
}
|
||||
|
||||
// 生成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)
|
||||
}
|
||||
|
||||
// TestGenerateIVYZ4Y27AuthorizationBase64 测试生成IVYZ4Y27专用授权书
|
||||
func TestGenerateIVYZ4Y27AuthorizationBase64(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": "430101199501011234",
|
||||
"mobile": "13800138000",
|
||||
}
|
||||
|
||||
base64Str, err := service.GenerateIVYZ4Y27AuthorizationBase64(userInfo)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, base64Str)
|
||||
|
||||
// 解码验证是合法的PDF
|
||||
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, len(decoded) > 0)
|
||||
assert.Contains(t, string(decoded[:10]), "%PDF")
|
||||
|
||||
t.Logf("Base64编码长度: %d 字符", len(base64Str))
|
||||
t.Logf("PDF原始大小: %d 字节", len(decoded))
|
||||
|
||||
// 保存到文件以便手动查看
|
||||
outputDir := filepath.Join("data", "authorization_docs", "test")
|
||||
os.MkdirAll(outputDir, 0755)
|
||||
outputPath := filepath.Join(outputDir, "ivyz4y27_test.pdf")
|
||||
os.WriteFile(outputPath, decoded, 0644)
|
||||
absPath, _ := filepath.Abs(outputPath)
|
||||
t.Logf("测试PDF已保存到: %s", absPath)
|
||||
}
|
||||
|
||||
// TestGenerateIVYZ4Y27AuthorizationBase64_WithMobile 测试带手机号生成授权书
|
||||
func TestGenerateIVYZ4Y27AuthorizationBase64_WithMobile(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
|
||||
userInfo map[string]interface{}
|
||||
}{
|
||||
{"带手机号", map[string]interface{}{"name": "李四", "id_card": "110101199001011235", "mobile": "13900139000"}},
|
||||
{"不带手机号", map[string]interface{}{"name": "王五", "id_card": "320101199201011236"}},
|
||||
{"空手机号", map[string]interface{}{"name": "赵六", "id_card": "440101199301011237", "mobile": ""}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
base64Str, err := service.GenerateIVYZ4Y27AuthorizationBase64(tt.userInfo)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, base64Str)
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(decoded[:10]), "%PDF")
|
||||
assert.Greater(t, len(decoded), 1000)
|
||||
|
||||
t.Logf("[%s] PDF大小: %d 字节, Base64长度: %d 字符", tt.name, len(decoded), len(base64Str))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateIVYZ4Y27AuthorizationBase64_MissingParams 测试缺少参数
|
||||
func TestGenerateIVYZ4Y27AuthorizationBase64_MissingParams(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
|
||||
userInfo map[string]interface{}
|
||||
}{
|
||||
{"缺少name", map[string]interface{}{"id_card": "430101199501011234"}},
|
||||
{"缺少id_card", map[string]interface{}{"name": "张三"}},
|
||||
{"都缺少", map[string]interface{}{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := service.GenerateIVYZ4Y27AuthorizationBase64(tt.userInfo)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "缺少必要的用户信息")
|
||||
})
|
||||
}
|
||||
}
|
||||
47
app/main/api/internal/service/dictService.go
Normal file
47
app/main/api/internal/service/dictService.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bd-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
|
||||
}
|
||||
173
app/main/api/internal/service/imageService.go
Normal file
173
app/main/api/internal/service/imageService.go
Normal 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
|
||||
}
|
||||
416
app/main/api/internal/service/tianyuanapi_sdk/client.go
Normal file
416
app/main/api/internal/service/tianyuanapi_sdk/client.go
Normal 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
|
||||
}
|
||||
305
app/main/api/internal/service/userService.go
Normal file
305
app/main/api/internal/service/userService.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/model"
|
||||
"bd-server/common/ctxdata"
|
||||
jwtx "bd-server/common/jwt"
|
||||
"bd-server/common/xerr"
|
||||
"context"
|
||||
"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 {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.USER_TEMP_INVALID), "临时用户不存在, userId: %d", claims.UserId)
|
||||
}
|
||||
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 {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.USER_TEMP_INVALID), "临时用户不存在, userId: %d", claims.UserId)
|
||||
}
|
||||
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 {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.USER_TEMP_INVALID), "临时用户不存在, userId: %d", claims.UserId)
|
||||
}
|
||||
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
|
||||
}
|
||||
152
app/main/api/internal/service/verificationService.go
Normal file
152
app/main/api/internal/service/verificationService.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bd-server/app/main/api/internal/config"
|
||||
tianyuanapi "bd-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
|
||||
}
|
||||
}
|
||||
445
app/main/api/internal/service/wechatpayService.go
Normal file
445
app/main/api/internal/service/wechatpayService.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bd-server/app/main/api/internal/config"
|
||||
"bd-server/app/main/model"
|
||||
"bd-server/common/ctxdata"
|
||||
"bd-server/pkg/lzkit/lzUtils"
|
||||
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
// randomNonceWechat 生成微信调起支付用的随机串
|
||||
func randomNonceWechat(n int) (string, error) {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = letters[int(b[i])%len(letters)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CreateWechatAppOrder 创建微信 APP 支付:统一下单后按 V3「APP 调起支付」规则签名,供原生 SDK / uni.requestPayment(wxpay) 使用
|
||||
func (w *WechatPayService) CreateWechatAppOrder(ctx context.Context, amount float64, description string, outTradeNo string) (map[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),
|
||||
},
|
||||
}
|
||||
|
||||
svc := app.AppApiService{Client: w.wechatClient}
|
||||
resp, result, err := svc.Prepay(ctx, payRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("微信支付订单创建失败: %v, 状态码: %d", err, result.Response.StatusCode)
|
||||
}
|
||||
|
||||
prepayID := *resp.PrepayId
|
||||
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(w.config.Wxpay.MchPrivateKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载商户私钥失败: %w", err)
|
||||
}
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonceStr, err := randomNonceWechat(32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appID := w.config.Wxpay.AppID
|
||||
msg := fmt.Sprintf("%s\n%s\n%s\n%s\n", appID, ts, nonceStr, prepayID)
|
||||
sum := sha256.Sum256([]byte(msg))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, mchPrivateKey, crypto.SHA256, sum[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("APP 调起支付签名失败: %w", err)
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"appid": appID,
|
||||
"partnerid": w.config.Wxpay.MchID,
|
||||
"prepayid": prepayID,
|
||||
"package": "Sign=WXPay",
|
||||
"noncestr": nonceStr,
|
||||
"timestamp": ts,
|
||||
"sign": base64.StdEncoding.EncodeToString(sig),
|
||||
}, 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)
|
||||
}
|
||||
// 显式转为 map,确保小程序 uni.requestPayment 能正确解析(避免指针序列化问题)
|
||||
return jsapiRespToMap(resp), nil
|
||||
}
|
||||
|
||||
// jsapiRespToMap 将 PrepayWithRequestPaymentResponse 转为 map,供小程序/JSAPI 调起支付
|
||||
func jsapiRespToMap(resp *jsapi.PrepayWithRequestPaymentResponse) map[string]string {
|
||||
m := make(map[string]string)
|
||||
if resp == nil {
|
||||
return m
|
||||
}
|
||||
if resp.Appid != nil {
|
||||
m["appId"] = *resp.Appid
|
||||
}
|
||||
if resp.TimeStamp != nil {
|
||||
m["timeStamp"] = *resp.TimeStamp
|
||||
}
|
||||
if resp.NonceStr != nil {
|
||||
m["nonceStr"] = *resp.NonceStr
|
||||
}
|
||||
if resp.Package != nil {
|
||||
m["package"] = *resp.Package
|
||||
}
|
||||
if resp.SignType != nil {
|
||||
m["signType"] = *resp.SignType
|
||||
}
|
||||
if resp.PaySign != nil {
|
||||
m["paySign"] = *resp.PaySign
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 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 jsapiRespToMap(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
|
||||
}
|
||||
Reference in New Issue
Block a user