This commit is contained in:
2025-12-19 17:05:09 +08:00
parent cc3472ff40
commit 39c46937ea
307 changed files with 87686 additions and 129 deletions

View File

@@ -239,6 +239,9 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
&productEntities.Subscription{},
&productEntities.ProductDocumentation{},
&productEntities.ProductApiConfig{},
&productEntities.ComponentReportDownload{},
&productEntities.UIComponent{},
&productEntities.ProductUIComponent{},
// 文章域
&articleEntities.Article{},
@@ -281,6 +284,7 @@ func createLogger(cfg *config.Config) (*zap.Logger, error) {
if cfg.Logger.Format == "" {
config.Encoding = "json"
}
if cfg.Logger.Output == "" {
config.OutputPaths = []string{"stdout"}
}

View File

@@ -3,11 +3,8 @@ package finance
import (
"context"
"fmt"
"github.com/shopspring/decimal"
"github.com/smartwalle/alipay/v3"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"go.uber.org/zap"
"net/http"
"strings"
"time"
"tyapi-server/internal/application/finance/dto/commands"
"tyapi-server/internal/application/finance/dto/queries"
@@ -16,11 +13,17 @@ import (
finance_entities "tyapi-server/internal/domains/finance/entities"
finance_repositories "tyapi-server/internal/domains/finance/repositories"
finance_services "tyapi-server/internal/domains/finance/services"
product_repositories "tyapi-server/internal/domains/product/repositories"
user_repositories "tyapi-server/internal/domains/user/repositories"
"tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/export"
"tyapi-server/internal/shared/interfaces"
"tyapi-server/internal/shared/payment"
"github.com/shopspring/decimal"
"github.com/smartwalle/alipay/v3"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"go.uber.org/zap"
)
// FinanceApplicationServiceImpl 财务应用服务实现
@@ -33,6 +36,7 @@ type FinanceApplicationServiceImpl struct {
alipayOrderRepo finance_repositories.AlipayOrderRepository
wechatOrderRepo finance_repositories.WechatOrderRepository
rechargeRecordRepo finance_repositories.RechargeRecordRepository
componentReportRepo product_repositories.ComponentReportRepository
userRepo user_repositories.UserRepository
txManager *database.TransactionManager
exportManager *export.ExportManager
@@ -50,6 +54,7 @@ func NewFinanceApplicationService(
alipayOrderRepo finance_repositories.AlipayOrderRepository,
wechatOrderRepo finance_repositories.WechatOrderRepository,
rechargeRecordRepo finance_repositories.RechargeRecordRepository,
componentReportRepo product_repositories.ComponentReportRepository,
userRepo user_repositories.UserRepository,
txManager *database.TransactionManager,
logger *zap.Logger,
@@ -65,6 +70,7 @@ func NewFinanceApplicationService(
alipayOrderRepo: alipayOrderRepo,
wechatOrderRepo: wechatOrderRepo,
rechargeRecordRepo: rechargeRecordRepo,
componentReportRepo: componentReportRepo,
userRepo: userRepo,
txManager: txManager,
exportManager: exportManager,
@@ -815,6 +821,8 @@ func (s *FinanceApplicationServiceImpl) batchGetCompanyNamesForRechargeRecords(c
// HandleAlipayCallback 处理支付宝回调
func (s *FinanceApplicationServiceImpl) HandleAlipayCallback(ctx context.Context, r *http.Request) error {
s.logger.Info("========== 开始处理支付宝支付回调 ==========")
// 解析并验证支付宝回调通知
notification, err := s.aliPayClient.HandleAliPaymentNotification(r)
if err != nil {
@@ -834,14 +842,25 @@ func (s *FinanceApplicationServiceImpl) HandleAlipayCallback(ctx context.Context
// 检查交易状态
if !s.aliPayClient.IsAlipayPaymentSuccess(notification) {
s.logger.Warn("支付宝交易未成功",
s.logger.Warn("支付宝交易未成功,跳过处理",
zap.String("out_trade_no", notification.OutTradeNo),
zap.String("trade_status", string(notification.TradeStatus)),
)
return nil // 不返回错误,因为这是正常的业务状态
}
// 使用公共方法处理支付成功逻辑
s.logger.Info("支付宝支付成功,开始处理业务逻辑",
zap.String("out_trade_no", notification.OutTradeNo),
zap.String("trade_no", notification.TradeNo),
)
// 先检查是否是组件报告下载的支付订单
s.logger.Info("步骤1: 检查是否是组件报告下载订单",
zap.String("out_trade_no", notification.OutTradeNo),
)
// 使用公共方法处理支付成功逻辑(包括更新充值记录状态)
// 无论是组件报告下载订单还是普通充值订单,都需要更新充值记录状态
err = s.processAlipayPaymentSuccess(ctx, notification.OutTradeNo, notification.TradeNo, notification.TotalAmount, notification.BuyerId, notification.SellerId)
if err != nil {
s.logger.Error("处理支付宝支付成功失败",
@@ -851,6 +870,7 @@ func (s *FinanceApplicationServiceImpl) HandleAlipayCallback(ctx context.Context
return err
}
s.logger.Info("========== 支付宝支付回调处理完成 ==========")
return nil
}
@@ -868,6 +888,7 @@ func (s *FinanceApplicationServiceImpl) processAlipayPaymentSuccess(ctx context.
// 直接调用充值记录服务处理支付成功逻辑
// 该服务内部会处理所有必要的检查、事务和更新操作
// 如果是组件报告下载订单,服务会自动跳过钱包余额增加
err = s.rechargeRecordService.HandleAlipayPaymentSuccess(ctx, outTradeNo, amount, tradeNo)
if err != nil {
s.logger.Error("处理支付宝支付成功失败",
@@ -877,6 +898,9 @@ func (s *FinanceApplicationServiceImpl) processAlipayPaymentSuccess(ctx context.
return err
}
// 检查并更新组件报告下载记录状态(如果存在)
s.updateComponentReportDownloadStatus(ctx, outTradeNo)
s.logger.Info("支付宝支付成功处理完成",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", tradeNo),
@@ -1398,6 +1422,8 @@ func (s *FinanceApplicationServiceImpl) updateWechatOrderStatus(ctx context.Cont
// HandleWechatPayCallback 处理微信支付回调
func (s *FinanceApplicationServiceImpl) HandleWechatPayCallback(ctx context.Context, r *http.Request) error {
s.logger.Info("========== 开始处理微信支付回调 ==========")
if s.wechatPayService == nil {
s.logger.Error("微信支付服务未初始化")
return fmt.Errorf("微信支付服务未初始化")
@@ -1439,14 +1465,42 @@ func (s *FinanceApplicationServiceImpl) HandleWechatPayCallback(ctx context.Cont
// 检查交易状态
if tradeState != payment.TradeStateSuccess {
s.logger.Warn("微信支付交易未成功",
s.logger.Warn("微信支付交易未成功,跳过处理",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_state", tradeState),
)
return nil // 不返回错误,因为这是正常的业务状态
}
// 处理支付成功逻辑
s.logger.Info("微信支付成功,开始处理业务逻辑",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
)
// 先检查是否是组件报告下载的支付订单
s.logger.Info("步骤1: 检查是否是组件报告下载订单",
zap.String("out_trade_no", outTradeNo),
)
// 检查组件报告下载记录
download, err := s.componentReportRepo.GetDownloadByPaymentOrderID(ctx, outTradeNo)
if err == nil && download != nil {
s.logger.Info("步骤2: 发现组件报告下载订单,直接更新下载记录状态",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
zap.String("product_id", download.ProductID),
zap.String("current_status", download.PaymentStatus),
)
s.updateComponentReportDownloadStatus(ctx, outTradeNo)
s.logger.Info("========== 组件报告下载订单处理完成 ==========")
return nil
}
s.logger.Info("步骤3: 不是组件报告下载订单,按充值流程处理",
zap.String("out_trade_no", outTradeNo),
)
// 处理支付成功逻辑(充值流程)
err = s.processWechatPaymentSuccess(ctx, outTradeNo, transactionID, totalAmount)
if err != nil {
s.logger.Error("处理微信支付成功失败",
@@ -1458,6 +1512,7 @@ func (s *FinanceApplicationServiceImpl) HandleWechatPayCallback(ctx context.Cont
return err
}
s.logger.Info("========== 微信支付回调处理完成 ==========")
return nil
}
@@ -1491,6 +1546,15 @@ func (s *FinanceApplicationServiceImpl) processWechatPaymentSuccess(ctx context.
return fmt.Errorf("查找充值记录失败: %w", err)
}
s.logger.Info("步骤4: 检查充值记录备注,判断是否为组件报告下载订单",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", rechargeRecord.ID),
zap.String("notes", rechargeRecord.Notes),
)
// 检查是否是组件报告下载订单(通过备注判断)
isComponentReportOrder := strings.Contains(rechargeRecord.Notes, "购买") && strings.Contains(rechargeRecord.Notes, "报告示例")
// 检查订单和充值记录状态,如果都已成功则跳过(只记录一次日志)
if wechatOrder.Status == finance_entities.WechatOrderStatusSuccess && rechargeRecord.Status == finance_entities.RechargeStatusSuccess {
s.logger.Info("微信支付订单已处理成功,跳过重复处理",
@@ -1498,7 +1562,12 @@ func (s *FinanceApplicationServiceImpl) processWechatPaymentSuccess(ctx context.
zap.String("transaction_id", transactionID),
zap.String("order_id", wechatOrder.ID),
zap.String("recharge_id", rechargeRecord.ID),
zap.Bool("is_component_report", isComponentReportOrder),
)
// 如果是组件报告下载订单,确保更新下载记录状态
if isComponentReportOrder {
s.updateComponentReportDownloadStatus(ctx, outTradeNo)
}
return nil
}
@@ -1538,9 +1607,8 @@ func (s *FinanceApplicationServiceImpl) processWechatPaymentSuccess(ctx context.
return err
}
// 更新充值记录状态为成功
rechargeRecord.MarkSuccess()
err = s.rechargeRecordRepo.Update(txCtx, *rechargeRecord)
// 更新充值记录状态为成功使用UpdateStatus方法直接更新状态字段
err = s.rechargeRecordRepo.UpdateStatus(txCtx, rechargeRecord.ID, finance_entities.RechargeStatusSuccess)
if err != nil {
s.logger.Error("更新充值记录状态失败",
zap.String("out_trade_no", outTradeNo),
@@ -1570,17 +1638,33 @@ func (s *FinanceApplicationServiceImpl) processWechatPaymentSuccess(ctx context.
)
}
// 充值到钱包(包含赠送金额
totalRechargeAmount := amount.Add(bonusAmount)
err = s.walletService.Recharge(txCtx, rechargeRecord.UserID, totalRechargeAmount)
if err != nil {
s.logger.Error("充值到钱包失败",
// 检查是否是组件报告下载订单(通过备注判断
isComponentReportOrder := strings.Contains(rechargeRecord.Notes, "购买") && strings.Contains(rechargeRecord.Notes, "报告示例")
if isComponentReportOrder {
s.logger.Info("步骤5: 检测到组件报告下载订单,不增加钱包余额",
zap.String("out_trade_no", outTradeNo),
zap.String("user_id", rechargeRecord.UserID),
zap.String("total_amount", totalRechargeAmount.String()),
zap.Error(err),
zap.String("recharge_id", rechargeRecord.ID),
zap.String("notes", rechargeRecord.Notes),
)
return err
// 组件报告下载订单不增加钱包余额,只更新订单和充值记录状态
} else {
s.logger.Info("步骤5: 普通充值订单,增加钱包余额",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", rechargeRecord.ID),
)
// 充值到钱包(包含赠送金额)
totalRechargeAmount := amount.Add(bonusAmount)
err = s.walletService.Recharge(txCtx, rechargeRecord.UserID, totalRechargeAmount)
if err != nil {
s.logger.Error("充值到钱包失败",
zap.String("out_trade_no", outTradeNo),
zap.String("user_id", rechargeRecord.UserID),
zap.String("total_amount", totalRechargeAmount.String()),
zap.Error(err),
)
return err
}
}
return nil
@@ -1596,17 +1680,107 @@ func (s *FinanceApplicationServiceImpl) processWechatPaymentSuccess(ctx context.
return err
}
// 如果是组件报告下载订单,更新下载记录状态
if isComponentReportOrder {
s.logger.Info("步骤6: 更新组件报告下载记录状态",
zap.String("out_trade_no", outTradeNo),
)
s.updateComponentReportDownloadStatus(ctx, outTradeNo)
}
s.logger.Info("微信支付成功处理完成",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("amount", amount.String()),
zap.String("bonus_amount", bonusAmount.String()),
zap.String("user_id", rechargeRecord.UserID),
zap.Bool("is_component_report", isComponentReportOrder),
)
return nil
}
// updateComponentReportDownloadStatus 更新组件报告下载记录状态
func (s *FinanceApplicationServiceImpl) updateComponentReportDownloadStatus(ctx context.Context, outTradeNo string) {
s.logger.Info("========== 开始更新组件报告下载记录状态 ==========",
zap.String("out_trade_no", outTradeNo),
)
if s.componentReportRepo == nil {
s.logger.Warn("组件报告下载Repository未初始化跳过更新")
return
}
// 根据支付订单号查找组件报告下载记录
download, err := s.componentReportRepo.GetDownloadByPaymentOrderID(ctx, outTradeNo)
if err != nil {
s.logger.Info("未找到组件报告下载记录,可能不是组件报告下载订单",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return
}
if download == nil {
s.logger.Info("组件报告下载记录为空,跳过更新",
zap.String("out_trade_no", outTradeNo),
)
return
}
s.logger.Info("步骤1: 找到组件报告下载记录",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
zap.String("product_id", download.ProductID),
zap.String("current_status", download.PaymentStatus),
)
// 如果已经是成功状态,跳过
if download.PaymentStatus == "success" {
s.logger.Info("组件报告下载记录已是成功状态,跳过更新",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
)
return
}
s.logger.Info("步骤2: 更新支付状态为成功",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
)
// 更新支付状态为成功
download.PaymentStatus = "success"
// 设置过期时间30天后
expiresAt := time.Now().Add(30 * 24 * time.Hour)
download.ExpiresAt = &expiresAt
s.logger.Info("步骤3: 保存更新后的下载记录",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
zap.String("expires_at", expiresAt.Format("2006-01-02 15:04:05")),
)
// 更新记录
err = s.componentReportRepo.UpdateDownload(ctx, download)
if err != nil {
s.logger.Error("更新组件报告下载记录状态失败",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
zap.Error(err),
)
return
}
s.logger.Info("========== 组件报告下载记录状态更新成功 ==========",
zap.String("out_trade_no", outTradeNo),
zap.String("download_id", download.ID),
zap.String("product_id", download.ProductID),
zap.String("payment_status", download.PaymentStatus),
)
}
// HandleWechatRefundCallback 处理微信退款回调
func (s *FinanceApplicationServiceImpl) HandleWechatRefundCallback(ctx context.Context, r *http.Request) error {
if s.wechatPayService == nil {

View File

@@ -0,0 +1,686 @@
package product
import (
"context"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"strings"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
"github.com/shopspring/decimal"
)
// UIComponentApplicationService UI组件应用服务接口
type UIComponentApplicationService interface {
// 基本CRUD操作
CreateUIComponent(ctx context.Context, req CreateUIComponentRequest) (entities.UIComponent, error)
CreateUIComponentWithFile(ctx context.Context, req CreateUIComponentRequest, file *multipart.FileHeader) (entities.UIComponent, error)
CreateUIComponentWithFiles(ctx context.Context, req CreateUIComponentRequest, files []*multipart.FileHeader) (entities.UIComponent, error)
CreateUIComponentWithFilesAndPaths(ctx context.Context, req CreateUIComponentRequest, files []*multipart.FileHeader, paths []string) (entities.UIComponent, error)
GetUIComponentByID(ctx context.Context, id string) (*entities.UIComponent, error)
GetUIComponentByCode(ctx context.Context, code string) (*entities.UIComponent, error)
UpdateUIComponent(ctx context.Context, req UpdateUIComponentRequest) error
DeleteUIComponent(ctx context.Context, id string) error
ListUIComponents(ctx context.Context, req ListUIComponentsRequest) (ListUIComponentsResponse, error)
// 文件操作
UploadUIComponentFile(ctx context.Context, id string, file *multipart.FileHeader) (string, error)
UploadAndExtractUIComponentFile(ctx context.Context, id string, file *multipart.FileHeader) error
DownloadUIComponentFile(ctx context.Context, id string) (string, error)
GetUIComponentFolderContent(ctx context.Context, id string) ([]FileInfo, error)
DeleteUIComponentFolder(ctx context.Context, id string) error
// 产品关联操作
AssociateUIComponentToProduct(ctx context.Context, req AssociateUIComponentRequest) error
GetProductUIComponents(ctx context.Context, productID string) ([]entities.ProductUIComponent, error)
RemoveUIComponentFromProduct(ctx context.Context, productID, componentID string) error
}
// CreateUIComponentRequest 创建UI组件请求
type CreateUIComponentRequest struct {
ComponentCode string `json:"component_code" binding:"required"`
ComponentName string `json:"component_name" binding:"required"`
Description string `json:"description"`
Version string `json:"version"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
}
// UpdateUIComponentRequest 更新UI组件请求
type UpdateUIComponentRequest struct {
ID string `json:"id" binding:"required"`
ComponentCode string `json:"component_code"`
ComponentName string `json:"component_name"`
Description string `json:"description"`
Version string `json:"version"`
IsActive *bool `json:"is_active"`
SortOrder *int `json:"sort_order"`
}
// ListUIComponentsRequest 获取UI组件列表请求
type ListUIComponentsRequest struct {
Page int `form:"page,default=1"`
PageSize int `form:"page_size,default=10"`
Keyword string `form:"keyword"`
IsActive *bool `form:"is_active"`
SortBy string `form:"sort_by,default=sort_order"`
SortOrder string `form:"sort_order,default=asc"`
}
// ListUIComponentsResponse 获取UI组件列表响应
type ListUIComponentsResponse struct {
Components []entities.UIComponent `json:"components"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// AssociateUIComponentRequest 关联UI组件到产品请求
type AssociateUIComponentRequest struct {
ProductID string `json:"product_id" binding:"required"`
UIComponentID string `json:"ui_component_id" binding:"required"`
Price float64 `json:"price" binding:"required,min=0"`
IsEnabled bool `json:"is_enabled"`
}
// UIComponentApplicationServiceImpl UI组件应用服务实现
type UIComponentApplicationServiceImpl struct {
uiComponentRepo repositories.UIComponentRepository
productUIComponentRepo repositories.ProductUIComponentRepository
fileStorageService FileStorageService
fileService UIComponentFileService
}
// FileStorageService 文件存储服务接口
type FileStorageService interface {
StoreFile(ctx context.Context, file io.Reader, filename string) (string, error)
GetFileURL(ctx context.Context, filePath string) (string, error)
DeleteFile(ctx context.Context, filePath string) error
}
// NewUIComponentApplicationService 创建UI组件应用服务
func NewUIComponentApplicationService(
uiComponentRepo repositories.UIComponentRepository,
productUIComponentRepo repositories.ProductUIComponentRepository,
fileStorageService FileStorageService,
fileService UIComponentFileService,
) UIComponentApplicationService {
return &UIComponentApplicationServiceImpl{
uiComponentRepo: uiComponentRepo,
productUIComponentRepo: productUIComponentRepo,
fileStorageService: fileStorageService,
fileService: fileService,
}
}
// CreateUIComponent 创建UI组件
func (s *UIComponentApplicationServiceImpl) CreateUIComponent(ctx context.Context, req CreateUIComponentRequest) (entities.UIComponent, error) {
// 检查编码是否已存在
existing, _ := s.uiComponentRepo.GetByCode(ctx, req.ComponentCode)
if existing != nil {
return entities.UIComponent{}, ErrComponentCodeAlreadyExists
}
component := entities.UIComponent{
ComponentCode: req.ComponentCode,
ComponentName: req.ComponentName,
Description: req.Description,
Version: req.Version,
IsActive: req.IsActive,
SortOrder: req.SortOrder,
}
return s.uiComponentRepo.Create(ctx, component)
}
// CreateUIComponentWithFile 创建UI组件并上传文件
func (s *UIComponentApplicationServiceImpl) CreateUIComponentWithFile(ctx context.Context, req CreateUIComponentRequest, file *multipart.FileHeader) (entities.UIComponent, error) {
// 检查编码是否已存在
existing, _ := s.uiComponentRepo.GetByCode(ctx, req.ComponentCode)
if existing != nil {
return entities.UIComponent{}, ErrComponentCodeAlreadyExists
}
// 创建组件
component := entities.UIComponent{
ComponentCode: req.ComponentCode,
ComponentName: req.ComponentName,
Description: req.Description,
Version: req.Version,
IsActive: req.IsActive,
SortOrder: req.SortOrder,
}
createdComponent, err := s.uiComponentRepo.Create(ctx, component)
if err != nil {
return entities.UIComponent{}, err
}
// 如果有文件,则上传并处理文件
if file != nil {
// 打开上传的文件
src, err := file.Open()
if err != nil {
// 删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, fmt.Errorf("打开上传文件失败: %w", err)
}
defer src.Close()
// 上传并解压文件
if err := s.fileService.UploadAndExtract(ctx, createdComponent.ID, createdComponent.ComponentCode, src, file.Filename); err != nil {
// 删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, err
}
// 获取文件类型
fileType := strings.ToLower(filepath.Ext(file.Filename))
// 更新组件信息
folderPath := "resources/Pure Component/src/ui"
createdComponent.FolderPath = &folderPath
createdComponent.FileType = &fileType
// 仅对ZIP文件设置已解压标记
if fileType == ".zip" {
createdComponent.IsExtracted = true
}
// 更新组件信息
err = s.uiComponentRepo.Update(ctx, createdComponent)
if err != nil {
// 尝试删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, err
}
return createdComponent, nil
}
return createdComponent, nil
}
// CreateUIComponentWithFiles 创建UI组件并上传多个文件
func (s *UIComponentApplicationServiceImpl) CreateUIComponentWithFiles(ctx context.Context, req CreateUIComponentRequest, files []*multipart.FileHeader) (entities.UIComponent, error) {
// 检查编码是否已存在
existing, _ := s.uiComponentRepo.GetByCode(ctx, req.ComponentCode)
if existing != nil {
return entities.UIComponent{}, ErrComponentCodeAlreadyExists
}
// 创建组件
component := entities.UIComponent{
ComponentCode: req.ComponentCode,
ComponentName: req.ComponentName,
Description: req.Description,
Version: req.Version,
IsActive: req.IsActive,
SortOrder: req.SortOrder,
}
createdComponent, err := s.uiComponentRepo.Create(ctx, component)
if err != nil {
return entities.UIComponent{}, err
}
// 如果有文件,则上传并处理文件
if len(files) > 0 {
// 处理每个文件
var extractedFiles []string
for _, fileHeader := range files {
// 打开上传的文件
src, err := fileHeader.Open()
if err != nil {
// 删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, fmt.Errorf("打开上传文件失败: %w", err)
}
// 上传并解压文件
if err := s.fileService.UploadAndExtract(ctx, createdComponent.ID, createdComponent.ComponentCode, src, fileHeader.Filename); err != nil {
src.Close()
// 删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, err
}
src.Close()
// 记录已处理的文件,用于日志
extractedFiles = append(extractedFiles, fileHeader.Filename)
}
// 更新组件信息
folderPath := "resources/Pure Component/src/ui"
createdComponent.FolderPath = &folderPath
// 检查是否有ZIP文件
hasZipFile := false
for _, fileHeader := range files {
if strings.HasSuffix(strings.ToLower(fileHeader.Filename), ".zip") {
hasZipFile = true
break
}
}
// 如果有ZIP文件则标记为已解压
if hasZipFile {
createdComponent.IsExtracted = true
}
// 更新组件信息
err = s.uiComponentRepo.Update(ctx, createdComponent)
if err != nil {
// 尝试删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, err
}
}
return createdComponent, nil
}
// CreateUIComponentWithFilesAndPaths 创建UI组件并上传带路径的文件
func (s *UIComponentApplicationServiceImpl) CreateUIComponentWithFilesAndPaths(ctx context.Context, req CreateUIComponentRequest, files []*multipart.FileHeader, paths []string) (entities.UIComponent, error) {
// 检查编码是否已存在
existing, _ := s.uiComponentRepo.GetByCode(ctx, req.ComponentCode)
if existing != nil {
return entities.UIComponent{}, ErrComponentCodeAlreadyExists
}
// 创建组件
component := entities.UIComponent{
ComponentCode: req.ComponentCode,
ComponentName: req.ComponentName,
Description: req.Description,
Version: req.Version,
IsActive: req.IsActive,
SortOrder: req.SortOrder,
}
createdComponent, err := s.uiComponentRepo.Create(ctx, component)
if err != nil {
return entities.UIComponent{}, err
}
// 如果有文件,则上传并处理文件
if len(files) > 0 {
// 打开所有文件
var readers []io.Reader
var filenames []string
var filePaths []string
for i, fileHeader := range files {
// 打开上传的文件
src, err := fileHeader.Open()
if err != nil {
// 关闭已打开的文件
for _, r := range readers {
if closer, ok := r.(io.Closer); ok {
closer.Close()
}
}
// 删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, fmt.Errorf("打开上传文件失败: %w", err)
}
readers = append(readers, src)
filenames = append(filenames, fileHeader.Filename)
// 确定文件路径
var path string
if i < len(paths) && paths[i] != "" {
path = paths[i]
} else {
path = fileHeader.Filename
}
filePaths = append(filePaths, path)
}
// 使用新的批量上传方法
if err := s.fileService.UploadMultipleFiles(ctx, createdComponent.ID, createdComponent.ComponentCode, readers, filenames, filePaths); err != nil {
// 关闭已打开的文件
for _, r := range readers {
if closer, ok := r.(io.Closer); ok {
closer.Close()
}
}
// 删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, err
}
// 关闭所有文件
for _, r := range readers {
if closer, ok := r.(io.Closer); ok {
closer.Close()
}
}
// 更新组件信息
folderPath := "resources/Pure Component/src/ui"
createdComponent.FolderPath = &folderPath
// 检查是否有ZIP文件
hasZipFile := false
for _, fileHeader := range files {
if strings.HasSuffix(strings.ToLower(fileHeader.Filename), ".zip") {
hasZipFile = true
break
}
}
// 如果有ZIP文件则标记为已解压
if hasZipFile {
createdComponent.IsExtracted = true
}
// 更新组件信息
err = s.uiComponentRepo.Update(ctx, createdComponent)
if err != nil {
// 尝试删除已创建的组件记录
_ = s.uiComponentRepo.Delete(ctx, createdComponent.ID)
return entities.UIComponent{}, err
}
}
return createdComponent, nil
}
// GetUIComponentByID 根据ID获取UI组件
func (s *UIComponentApplicationServiceImpl) GetUIComponentByID(ctx context.Context, id string) (*entities.UIComponent, error) {
return s.uiComponentRepo.GetByID(ctx, id)
}
// GetUIComponentByCode 根据编码获取UI组件
func (s *UIComponentApplicationServiceImpl) GetUIComponentByCode(ctx context.Context, code string) (*entities.UIComponent, error) {
return s.uiComponentRepo.GetByCode(ctx, code)
}
// UpdateUIComponent 更新UI组件
func (s *UIComponentApplicationServiceImpl) UpdateUIComponent(ctx context.Context, req UpdateUIComponentRequest) error {
component, err := s.uiComponentRepo.GetByID(ctx, req.ID)
if err != nil {
return err
}
if component == nil {
return ErrComponentNotFound
}
// 如果更新编码,检查是否与其他组件冲突
if req.ComponentCode != "" && req.ComponentCode != component.ComponentCode {
existing, _ := s.uiComponentRepo.GetByCode(ctx, req.ComponentCode)
if existing != nil && existing.ID != req.ID {
return ErrComponentCodeAlreadyExists
}
component.ComponentCode = req.ComponentCode
}
if req.ComponentName != "" {
component.ComponentName = req.ComponentName
}
if req.Description != "" {
component.Description = req.Description
}
if req.Version != "" {
component.Version = req.Version
}
if req.IsActive != nil {
component.IsActive = *req.IsActive
}
if req.SortOrder != nil {
component.SortOrder = *req.SortOrder
}
return s.uiComponentRepo.Update(ctx, *component)
}
// DeleteUIComponent 删除UI组件
func (s *UIComponentApplicationServiceImpl) DeleteUIComponent(ctx context.Context, id string) error {
component, err := s.uiComponentRepo.GetByID(ctx, id)
if err != nil {
return err
}
if component == nil {
return ErrComponentNotFound
}
// 删除关联的文件
if component.FilePath != nil {
_ = s.fileStorageService.DeleteFile(ctx, *component.FilePath)
}
return s.uiComponentRepo.Delete(ctx, id)
}
// ListUIComponents 获取UI组件列表
func (s *UIComponentApplicationServiceImpl) ListUIComponents(ctx context.Context, req ListUIComponentsRequest) (ListUIComponentsResponse, error) {
filters := make(map[string]interface{})
if req.Keyword != "" {
filters["keyword"] = req.Keyword
}
if req.IsActive != nil {
filters["is_active"] = *req.IsActive
}
filters["page"] = req.Page
filters["page_size"] = req.PageSize
filters["sort_by"] = req.SortBy
filters["sort_order"] = req.SortOrder
components, total, err := s.uiComponentRepo.List(ctx, filters)
if err != nil {
return ListUIComponentsResponse{}, err
}
return ListUIComponentsResponse{
Components: components,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// UploadUIComponentFile 上传UI组件文件
func (s *UIComponentApplicationServiceImpl) UploadUIComponentFile(ctx context.Context, id string, file *multipart.FileHeader) (string, error) {
component, err := s.uiComponentRepo.GetByID(ctx, id)
if err != nil {
return "", err
}
if component == nil {
return "", ErrComponentNotFound
}
// 检查文件大小100MB
if file.Size > 100*1024*1024 {
return "", ErrInvalidFileType // 复用此错误表示文件太大
}
// 打开上传的文件
src, err := file.Open()
if err != nil {
return "", err
}
defer src.Close()
// 生成文件路径
filePath := filepath.Join("ui-components", id+"_"+file.Filename)
// 存储文件
storedPath, err := s.fileStorageService.StoreFile(ctx, src, filePath)
if err != nil {
return "", err
}
// 删除旧文件
if component.FilePath != nil {
_ = s.fileStorageService.DeleteFile(ctx, *component.FilePath)
}
// 获取文件类型
fileType := strings.ToLower(filepath.Ext(file.Filename))
// 更新组件信息
component.FilePath = &storedPath
component.FileSize = &file.Size
component.FileType = &fileType
if err := s.uiComponentRepo.Update(ctx, *component); err != nil {
// 如果更新失败,尝试删除已上传的文件
_ = s.fileStorageService.DeleteFile(ctx, storedPath)
return "", err
}
return storedPath, nil
}
// DownloadUIComponentFile 下载UI组件文件
func (s *UIComponentApplicationServiceImpl) DownloadUIComponentFile(ctx context.Context, id string) (string, error) {
component, err := s.uiComponentRepo.GetByID(ctx, id)
if err != nil {
return "", err
}
if component == nil {
return "", ErrComponentNotFound
}
if component.FilePath == nil {
return "", ErrComponentFileNotFound
}
return s.fileStorageService.GetFileURL(ctx, *component.FilePath)
}
// AssociateUIComponentToProduct 关联UI组件到产品
func (s *UIComponentApplicationServiceImpl) AssociateUIComponentToProduct(ctx context.Context, req AssociateUIComponentRequest) error {
// 检查组件是否存在
component, err := s.uiComponentRepo.GetByID(ctx, req.UIComponentID)
if err != nil {
return err
}
if component == nil {
return ErrComponentNotFound
}
// 创建关联
relation := entities.ProductUIComponent{
ProductID: req.ProductID,
UIComponentID: req.UIComponentID,
Price: decimal.NewFromFloat(req.Price),
IsEnabled: req.IsEnabled,
}
_, err = s.productUIComponentRepo.Create(ctx, relation)
return err
}
// GetProductUIComponents 获取产品的UI组件列表
func (s *UIComponentApplicationServiceImpl) GetProductUIComponents(ctx context.Context, productID string) ([]entities.ProductUIComponent, error) {
return s.productUIComponentRepo.GetByProductID(ctx, productID)
}
// RemoveUIComponentFromProduct 从产品中移除UI组件
func (s *UIComponentApplicationServiceImpl) RemoveUIComponentFromProduct(ctx context.Context, productID, componentID string) error {
// 查找关联记录
relations, err := s.productUIComponentRepo.GetByProductID(ctx, productID)
if err != nil {
return err
}
// 找到要删除的关联记录
var relationID string
for _, relation := range relations {
if relation.UIComponentID == componentID {
relationID = relation.ID
break
}
}
if relationID == "" {
return ErrProductComponentRelationNotFound
}
return s.productUIComponentRepo.Delete(ctx, relationID)
}
// UploadAndExtractUIComponentFile 上传并解压UI组件文件
func (s *UIComponentApplicationServiceImpl) UploadAndExtractUIComponentFile(ctx context.Context, id string, file *multipart.FileHeader) error {
// 获取组件信息
component, err := s.uiComponentRepo.GetByID(ctx, id)
if err != nil {
return err
}
if component == nil {
return ErrComponentNotFound
}
// 打开上传的文件
src, err := file.Open()
if err != nil {
return fmt.Errorf("打开上传文件失败: %w", err)
}
defer src.Close()
// 上传并解压文件
if err := s.fileService.UploadAndExtract(ctx, id, component.ComponentCode, src, file.Filename); err != nil {
return err
}
// 获取文件类型
fileType := strings.ToLower(filepath.Ext(file.Filename))
// 更新组件信息
folderPath := "resources/Pure Component/src/ui"
component.FolderPath = &folderPath
component.FileType = &fileType
// 仅对ZIP文件设置已解压标记
if fileType == ".zip" {
component.IsExtracted = true
}
return s.uiComponentRepo.Update(ctx, *component)
}
// GetUIComponentFolderContent 获取UI组件文件夹内容
func (s *UIComponentApplicationServiceImpl) GetUIComponentFolderContent(ctx context.Context, id string) ([]FileInfo, error) {
// 获取组件信息
component, err := s.uiComponentRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if component == nil {
return nil, ErrComponentNotFound
}
// 如果没有文件夹路径,返回空
if component.FolderPath == nil {
return []FileInfo{}, nil
}
// 获取文件夹内容
return s.fileService.GetFolderContent(*component.FolderPath)
}
// DeleteUIComponentFolder 删除UI组件文件夹
func (s *UIComponentApplicationServiceImpl) DeleteUIComponentFolder(ctx context.Context, id string) error {
// 获取组件信息
component, err := s.uiComponentRepo.GetByID(ctx, id)
if err != nil {
return err
}
if component == nil {
return ErrComponentNotFound
}
// 注意我们不再删除整个UI目录因为所有组件共享同一个目录
// 这里只更新组件信息,标记为未上传状态
// 更新组件信息
component.FolderPath = nil
component.IsExtracted = false
return s.uiComponentRepo.Update(ctx, *component)
}

View File

@@ -0,0 +1,21 @@
package product
import "errors"
// UI组件相关错误定义
var (
// ErrComponentNotFound UI组件不存在
ErrComponentNotFound = errors.New("UI组件不存在")
// ErrComponentCodeAlreadyExists UI组件编码已存在
ErrComponentCodeAlreadyExists = errors.New("UI组件编码已存在")
// ErrComponentFileNotFound UI组件文件不存在
ErrComponentFileNotFound = errors.New("UI组件文件不存在")
// ErrInvalidFileType 无效的文件类型
ErrInvalidFileType = errors.New("无效的文件类型仅支持ZIP文件")
// ErrProductComponentRelationNotFound 产品UI组件关联不存在
ErrProductComponentRelationNotFound = errors.New("产品UI组件关联不存在")
)

View File

@@ -0,0 +1,341 @@
package product
import (
"archive/zip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
)
// UIComponentFileService UI组件文件服务接口
type UIComponentFileService interface {
// 上传并解压UI组件文件
UploadAndExtract(ctx context.Context, componentID, componentCode string, file io.Reader, filename string) error
// 批量上传UI组件文件支持文件夹结构
UploadMultipleFiles(ctx context.Context, componentID, componentCode string, files []io.Reader, filenames []string, paths []string) error
// 根据组件编码创建文件夹
CreateFolderByCode(componentCode string) (string, error)
// 删除组件文件夹
DeleteFolder(folderPath string) error
// 检查文件夹是否存在
FolderExists(folderPath string) bool
// 获取文件夹内容
GetFolderContent(folderPath string) ([]FileInfo, error)
}
// FileInfo 文件信息
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Type string `json:"type"` // "file" or "folder"
Modified time.Time `json:"modified"`
}
// UIComponentFileServiceImpl UI组件文件服务实现
type UIComponentFileServiceImpl struct {
basePath string
logger *zap.Logger
}
// NewUIComponentFileService 创建UI组件文件服务
func NewUIComponentFileService(basePath string, logger *zap.Logger) UIComponentFileService {
// 确保基础路径存在
if err := os.MkdirAll(basePath, 0755); err != nil {
logger.Error("创建基础存储目录失败", zap.Error(err), zap.String("path", basePath))
}
return &UIComponentFileServiceImpl{
basePath: basePath,
logger: logger,
}
}
// UploadAndExtract 上传并解压UI组件文件
func (s *UIComponentFileServiceImpl) UploadAndExtract(ctx context.Context, componentID, componentCode string, file io.Reader, filename string) error {
// 直接使用基础路径作为文件夹路径,不再创建组件编码子文件夹
folderPath := s.basePath
// 确保基础目录存在
if err := os.MkdirAll(folderPath, 0755); err != nil {
return fmt.Errorf("创建基础目录失败: %w", err)
}
// 保存上传的文件
filePath := filepath.Join(folderPath, filename)
savedFile, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer savedFile.Close()
// 复制文件内容
if _, err := io.Copy(savedFile, file); err != nil {
// 删除部分写入的文件
_ = os.Remove(filePath)
return fmt.Errorf("保存文件失败: %w", err)
}
// 仅对ZIP文件执行解压逻辑
if strings.HasSuffix(strings.ToLower(filename), ".zip") {
// 解压文件到基础目录
if err := s.extractZipFile(filePath, folderPath); err != nil {
// 删除ZIP文件
_ = os.Remove(filePath)
return fmt.Errorf("解压文件失败: %w", err)
}
// 删除ZIP文件
_ = os.Remove(filePath)
s.logger.Info("UI组件文件上传并解压成功",
zap.String("componentID", componentID),
zap.String("componentCode", componentCode),
zap.String("folderPath", folderPath))
} else {
s.logger.Info("UI组件文件上传成功未解压",
zap.String("componentID", componentID),
zap.String("componentCode", componentCode),
zap.String("filePath", filePath))
}
return nil
}
// UploadMultipleFiles 批量上传UI组件文件支持文件夹结构
func (s *UIComponentFileServiceImpl) UploadMultipleFiles(ctx context.Context, componentID, componentCode string, files []io.Reader, filenames []string, paths []string) error {
// 直接使用基础路径作为文件夹路径,不再创建组件编码子文件夹
folderPath := s.basePath
// 确保基础目录存在
if err := os.MkdirAll(folderPath, 0755); err != nil {
return fmt.Errorf("创建基础目录失败: %w", err)
}
// 处理每个文件
for i, file := range files {
filename := filenames[i]
path := paths[i]
// 如果有路径信息,创建对应的子文件夹
if path != "" && path != filename {
// 获取文件所在目录
dir := filepath.Dir(path)
if dir != "." {
// 创建子文件夹
subDirPath := filepath.Join(folderPath, dir)
if err := os.MkdirAll(subDirPath, 0755); err != nil {
return fmt.Errorf("创建子文件夹失败: %w", err)
}
}
}
// 确定文件保存路径
var filePath string
if path != "" && path != filename {
// 有路径信息,使用完整路径
filePath = filepath.Join(folderPath, path)
} else {
// 没有路径信息,直接保存在根目录
filePath = filepath.Join(folderPath, filename)
}
// 保存上传的文件
savedFile, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer savedFile.Close()
// 复制文件内容
if _, err := io.Copy(savedFile, file); err != nil {
// 删除部分写入的文件
_ = os.Remove(filePath)
return fmt.Errorf("保存文件失败: %w", err)
}
// 对ZIP文件执行解压逻辑
if strings.HasSuffix(strings.ToLower(filename), ".zip") {
// 确定解压目录
var extractDir string
if path != "" && path != filename {
// 有路径信息,解压到对应目录
dir := filepath.Dir(path)
if dir != "." {
extractDir = filepath.Join(folderPath, dir)
} else {
extractDir = folderPath
}
} else {
// 没有路径信息,解压到根目录
extractDir = folderPath
}
// 解压文件
if err := s.extractZipFile(filePath, extractDir); err != nil {
// 删除ZIP文件
_ = os.Remove(filePath)
return fmt.Errorf("解压文件失败: %w", err)
}
// 删除ZIP文件
_ = os.Remove(filePath)
s.logger.Info("UI组件文件上传并解压成功",
zap.String("componentID", componentID),
zap.String("componentCode", componentCode),
zap.String("filePath", filePath),
zap.String("extractDir", extractDir))
} else {
s.logger.Info("UI组件文件上传成功未解压",
zap.String("componentID", componentID),
zap.String("componentCode", componentCode),
zap.String("filePath", filePath))
}
}
return nil
}
// CreateFolderByCode 根据组件编码创建文件夹
func (s *UIComponentFileServiceImpl) CreateFolderByCode(componentCode string) (string, error) {
folderPath := filepath.Join(s.basePath, componentCode)
// 创建文件夹(如果不存在)
if err := os.MkdirAll(folderPath, 0755); err != nil {
return "", fmt.Errorf("创建文件夹失败: %w", err)
}
return folderPath, nil
}
// DeleteFolder 删除组件文件夹
func (s *UIComponentFileServiceImpl) DeleteFolder(folderPath string) error {
if !s.FolderExists(folderPath) {
return nil // 文件夹不存在,不视为错误
}
if err := os.RemoveAll(folderPath); err != nil {
return fmt.Errorf("删除文件夹失败: %w", err)
}
s.logger.Info("删除组件文件夹成功", zap.String("folderPath", folderPath))
return nil
}
// FolderExists 检查文件夹是否存在
func (s *UIComponentFileServiceImpl) FolderExists(folderPath string) bool {
info, err := os.Stat(folderPath)
if err != nil {
return false
}
return info.IsDir()
}
// GetFolderContent 获取文件夹内容
func (s *UIComponentFileServiceImpl) GetFolderContent(folderPath string) ([]FileInfo, error) {
var files []FileInfo
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 跳过根目录
if path == folderPath {
return nil
}
// 获取相对路径
relPath, err := filepath.Rel(folderPath, path)
if err != nil {
return err
}
fileType := "file"
if info.IsDir() {
fileType = "folder"
}
files = append(files, FileInfo{
Name: info.Name(),
Path: relPath,
Size: info.Size(),
Type: fileType,
Modified: info.ModTime(),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("扫描文件夹失败: %w", err)
}
return files, nil
}
// extractZipFile 解压ZIP文件
func (s *UIComponentFileServiceImpl) extractZipFile(zipPath, destPath string) error {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("打开ZIP文件失败: %w", err)
}
defer reader.Close()
for _, file := range reader.File {
path := filepath.Join(destPath, file.Name)
// 防止路径遍历攻击
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(destPath)+string(os.PathSeparator)) {
return fmt.Errorf("无效的文件路径: %s", file.Name)
}
if file.FileInfo().IsDir() {
// 创建目录
if err := os.MkdirAll(path, file.Mode()); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
continue
}
// 创建文件
fileReader, err := file.Open()
if err != nil {
return fmt.Errorf("打开ZIP内文件失败: %w", err)
}
// 确保父目录存在
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
fileReader.Close()
return fmt.Errorf("创建父目录失败: %w", err)
}
destFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
if err != nil {
fileReader.Close()
return fmt.Errorf("创建目标文件失败: %w", err)
}
_, err = io.Copy(destFile, fileReader)
fileReader.Close()
destFile.Close()
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
}
return nil
}

View File

@@ -55,6 +55,7 @@ import (
asynq "tyapi-server/internal/infrastructure/task/implementations/asynq"
task_interfaces "tyapi-server/internal/infrastructure/task/interfaces"
task_repositories "tyapi-server/internal/infrastructure/task/repositories"
component_report "tyapi-server/internal/shared/component_report"
shared_database "tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/esign"
shared_events "tyapi-server/internal/shared/events"
@@ -565,6 +566,21 @@ func NewContainer() *Container {
product_repo.NewGormProductDocumentationRepository,
fx.As(new(domain_product_repo.ProductDocumentationRepository)),
),
// 组件报告下载记录仓储
fx.Annotate(
product_repo.NewGormComponentReportRepository,
fx.As(new(domain_product_repo.ComponentReportRepository)),
),
// UI组件仓储 - 同时注册具体类型和接口类型
fx.Annotate(
product_repo.NewGormUIComponentRepository,
fx.As(new(domain_product_repo.UIComponentRepository)),
),
// 产品UI组件关联仓储 - 同时注册具体类型和接口类型
fx.Annotate(
product_repo.NewGormProductUIComponentRepository,
fx.As(new(domain_product_repo.ProductUIComponentRepository)),
),
),
// 仓储层 - 文章域
@@ -882,6 +898,7 @@ func NewContainer() *Container {
logger *zap.Logger,
config *config.Config,
exportManager *export.ExportManager,
componentReportRepo domain_product_repo.ComponentReportRepository,
) finance.FinanceApplicationService {
return finance.NewFinanceApplicationService(
aliPayClient,
@@ -892,6 +909,7 @@ func NewContainer() *Container {
alipayOrderRepo,
wechatOrderRepo,
rechargeRecordRepo,
componentReportRepo,
userRepo,
txManager,
logger,
@@ -1028,6 +1046,27 @@ func NewContainer() *Container {
},
fx.As(new(statistics.StatisticsApplicationService)),
),
// UI组件应用服务 - 绑定到接口
fx.Annotate(
func(
uiComponentRepo domain_product_repo.UIComponentRepository,
productUIComponentRepo domain_product_repo.ProductUIComponentRepository,
fileStorageService *storage.LocalFileStorageService,
logger *zap.Logger,
) product.UIComponentApplicationService {
// 创建UI组件文件服务
basePath := "resources/Pure Component/src/ui"
fileService := product.NewUIComponentFileService(basePath, logger)
return product.NewUIComponentApplicationService(
uiComponentRepo,
productUIComponentRepo,
fileStorageService,
fileService,
)
},
fx.As(new(product.UIComponentApplicationService)),
),
),
// PDF查找服务
@@ -1086,6 +1125,24 @@ func NewContainer() *Container {
return cacheManager, nil
},
),
// 本地文件存储服务
fx.Provide(
func(logger *zap.Logger) *storage.LocalFileStorageService {
// 使用默认配置基础存储目录在项目根目录下的storage目录
basePath := "storage"
// 可以通过环境变量覆盖
if envBasePath := os.Getenv("FILE_STORAGE_BASE_PATH"); envBasePath != "" {
basePath = envBasePath
}
logger.Info("本地文件存储服务已初始化",
zap.String("base_path", basePath),
)
return storage.NewLocalFileStorageService(basePath, logger)
},
),
// HTTP处理器
fx.Provide(
// 用户HTTP处理器
@@ -1120,6 +1177,30 @@ func NewContainer() *Container {
) *handlers.AnnouncementHandler {
return handlers.NewAnnouncementHandler(appService, responseBuilder, validator, logger)
},
// 组件报告处理器
func(
productRepo domain_product_repo.ProductRepository,
docRepo domain_product_repo.ProductDocumentationRepository,
apiConfigRepo domain_product_repo.ProductApiConfigRepository,
componentReportRepo domain_product_repo.ComponentReportRepository,
rechargeRecordRepo domain_finance_repo.RechargeRecordRepository,
alipayOrderRepo domain_finance_repo.AlipayOrderRepository,
wechatOrderRepo domain_finance_repo.WechatOrderRepository,
aliPayService *payment.AliPayService,
wechatPayService *payment.WechatPayService,
logger *zap.Logger,
) *component_report.ComponentReportHandler {
return component_report.NewComponentReportHandler(productRepo, docRepo, apiConfigRepo, componentReportRepo, rechargeRecordRepo, alipayOrderRepo, wechatOrderRepo, aliPayService, wechatPayService, logger)
},
// UI组件HTTP处理器
func(
uiComponentAppService product.UIComponentApplicationService,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
logger *zap.Logger,
) *handlers.UIComponentHandler {
return handlers.NewUIComponentHandler(uiComponentAppService, responseBuilder, validator, logger)
},
),
// 路由注册
@@ -1134,6 +1215,8 @@ func NewContainer() *Container {
routes.NewProductRoutes,
// 产品管理员路由
routes.NewProductAdminRoutes,
// UI组件路由
routes.NewUIComponentRoutes,
// 文章路由
routes.NewArticleRoutes,
// 公告路由
@@ -1248,10 +1331,13 @@ func RegisterRoutes(
financeRoutes *routes.FinanceRoutes,
productRoutes *routes.ProductRoutes,
productAdminRoutes *routes.ProductAdminRoutes,
uiComponentRoutes *routes.UIComponentRoutes,
articleRoutes *routes.ArticleRoutes,
announcementRoutes *routes.AnnouncementRoutes,
apiRoutes *routes.ApiRoutes,
statisticsRoutes *routes.StatisticsRoutes,
jwtAuth *middleware.JWTAuthMiddleware,
adminAuth *middleware.AdminAuthMiddleware,
cfg *config.Config,
logger *zap.Logger,
) {
@@ -1266,6 +1352,13 @@ func RegisterRoutes(
financeRoutes.Register(router)
productRoutes.Register(router)
productAdminRoutes.Register(router)
// UI组件路由需要特殊处理因为它需要管理员中间件
engine := router.GetEngine()
adminGroup := engine.Group("/api/v1/admin")
adminGroup.Use(adminAuth.Handle())
uiComponentRoutes.RegisterRoutes(adminGroup, adminAuth)
articleRoutes.Register(router)
announcementRoutes.Register(router)
statisticsRoutes.Register(router)

View File

@@ -33,8 +33,8 @@ func ProcessIVYZ81NCRequest(ctx context.Context, params []byte, deps *processors
reqData := map[string]interface{}{
"data": map[string]interface{}{
"name": encryptedName,
"idcard": encryptedIDCard,
"name": encryptedName,
"idcard": encryptedIDCard,
},
}
@@ -62,4 +62,4 @@ func ProcessIVYZ81NCRequest(ctx context.Context, params []byte, deps *processors
}
return respBytes, nil
}
}

View File

@@ -165,23 +165,35 @@ func (r *RechargeRecord) SetTransferOrderID(orderID string) {
// NewAlipayRechargeRecord 工厂方法 - 创建支付宝充值记录
func NewAlipayRechargeRecord(userID string, amount decimal.Decimal, alipayOrderID string) *RechargeRecord {
return NewAlipayRechargeRecordWithNotes(userID, amount, alipayOrderID, "")
}
// NewAlipayRechargeRecordWithNotes 工厂方法 - 创建支付宝充值记录(带备注)
func NewAlipayRechargeRecordWithNotes(userID string, amount decimal.Decimal, alipayOrderID, notes string) *RechargeRecord {
return &RechargeRecord{
UserID: userID,
Amount: amount,
RechargeType: RechargeTypeAlipay,
Status: RechargeStatusPending,
AlipayOrderID: &alipayOrderID,
Notes: notes,
}
}
// NewWechatRechargeRecord 工厂方法 - 创建微信充值记录
func NewWechatRechargeRecord(userID string, amount decimal.Decimal, wechatOrderID string) *RechargeRecord {
return NewWechatRechargeRecordWithNotes(userID, amount, wechatOrderID, "")
}
// NewWechatRechargeRecordWithNotes 工厂方法 - 创建微信充值记录(带备注)
func NewWechatRechargeRecordWithNotes(userID string, amount decimal.Decimal, wechatOrderID, notes string) *RechargeRecord {
return &RechargeRecord{
UserID: userID,
Amount: amount,
RechargeType: RechargeTypeWechat,
Status: RechargeStatusPending,
WechatOrderID: &wechatOrderID,
Notes: notes,
}
}

View File

@@ -3,6 +3,7 @@ package services
import (
"context"
"fmt"
"strings"
"github.com/shopspring/decimal"
"go.uber.org/zap"
@@ -295,8 +296,21 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
return nil
}
// 计算充值赠送金额
bonusAmount := calculateAlipayRechargeBonus(amount, &s.cfg.Wallet)
// 检查是否是组件报告下载订单(通过备注判断)
isComponentReportOrder := strings.Contains(rechargeRecord.Notes, "购买") && strings.Contains(rechargeRecord.Notes, "报告示例")
s.logger.Info("处理支付宝支付成功回调",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", rechargeRecord.ID),
zap.String("notes", rechargeRecord.Notes),
zap.Bool("is_component_report", isComponentReportOrder),
)
// 计算充值赠送金额(组件报告下载订单不需要赠送)
bonusAmount := decimal.Zero
if !isComponentReportOrder {
bonusAmount = calculateAlipayRechargeBonus(amount, &s.cfg.Wallet)
}
totalAmount := amount.Add(bonusAmount)
// 在事务中执行所有更新操作
@@ -309,14 +323,22 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
return err
}
// 更新充值记录状态为成功
rechargeRecord.MarkSuccess()
err = s.rechargeRecordRepo.Update(txCtx, rechargeRecord)
// 更新充值记录状态为成功使用UpdateStatus方法直接更新状态字段
err = s.rechargeRecordRepo.UpdateStatus(txCtx, rechargeRecord.ID, entities.RechargeStatusSuccess)
if err != nil {
s.logger.Error("更新充值记录状态失败", zap.Error(err))
return err
}
// 如果是组件报告下载订单,不增加钱包余额,不创建赠送记录
if isComponentReportOrder {
s.logger.Info("组件报告下载订单,跳过钱包余额增加和赠送",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", rechargeRecord.ID),
)
return nil
}
// 如果有赠送金额,创建赠送充值记录
if bonusAmount.GreaterThan(decimal.Zero) {
giftRechargeRecord := entities.NewGiftRechargeRecord(rechargeRecord.UserID, bonusAmount, "充值活动赠送")
@@ -355,6 +377,10 @@ func (s *RechargeRecordServiceImpl) HandleAlipayPaymentSuccess(ctx context.Conte
zap.String("recharge_id", rechargeRecord.ID),
zap.String("order_id", alipayOrder.ID))
// 检查是否有组件报告下载记录需要更新
// 注意这里需要在调用方finance应用服务中处理因为这里没有组件报告下载的repository
// 但为了保持服务层的独立性,我们通过事件或回调来处理
return nil
}

View File

@@ -1,34 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ComponentReportCache 报告文件匹配缓存
type ComponentReportCache struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"缓存ID"`
ProductCode string `gorm:"type:varchar(50);not null;uniqueIndex" json:"product_code" comment:"产品编号"`
MatchedPath string `gorm:"type:varchar(500);not null" json:"matched_path" comment:"匹配到的文件夹/文件路径"`
FileType string `gorm:"type:varchar(20);not null" json:"file_type" comment:"文件类型folder, file"`
CacheKey string `gorm:"type:varchar(64);not null;uniqueIndex" json:"cache_key" comment:"缓存键"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"`
}
// TableName 指定数据库表名
func (ComponentReportCache) TableName() string {
return "component_report_cache"
}
// BeforeCreate GORM钩子创建前自动生成UUID
func (c *ComponentReportCache) BeforeCreate(tx *gorm.DB) error {
if c.ID == "" {
c.ID = uuid.New().String()
}
return nil
}

View File

@@ -10,30 +10,30 @@ import (
// ComponentReportDownload 组件报告下载记录
type ComponentReportDownload struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"下载记录ID"`
UserID string `gorm:"type:varchar(36);not null;index" json:"user_id" comment:"用户ID"`
ProductID string `gorm:"type:varchar(36);not null;index" json:"product_id" comment:"产品ID"`
ProductCode string `gorm:"type:varchar(50);not null;index" json:"product_code" comment:"产品编号"`
SubProductIDs string `gorm:"type:text" json:"sub_product_ids" comment:"子产品ID列表JSON数组"`
SubProductCodes string `gorm:"type:text" json:"sub_product_codes" comment:"子产品编号列表JSON数组"`
DownloadPrice decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"download_price" comment:"实际支付价格"`
OriginalPrice decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"original_price" comment:"原始总价"`
DiscountAmount decimal.Decimal `gorm:"type:decimal(10,2);default:0" json:"discount_amount" comment:"减免金额"`
PaymentOrderID *string `gorm:"type:varchar(64)" json:"payment_order_id,omitempty" comment:"支付订单号"`
PaymentType *string `gorm:"type:varchar(20)" json:"payment_type,omitempty" comment:"支付类型alipay, wechat"`
PaymentStatus string `gorm:"type:varchar(20);default:'pending';index" json:"payment_status" comment:"支付状态pending, success, failed"`
FilePath *string `gorm:"type:varchar(500)" json:"file_path,omitempty" comment:"生成的ZIP文件路径"`
FileHash *string `gorm:"type:varchar(64)" json:"file_hash,omitempty" comment:"文件哈希值"`
DownloadCount int `gorm:"default:0" json:"download_count" comment:"下载次数"`
LastDownloadAt *time.Time `json:"last_download_at,omitempty" comment:"最后下载时间"`
ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty" comment:"下载有效期"`
ID string `gorm:"primaryKey;type:varchar(36)" comment:"下载记录ID"`
UserID string `gorm:"type:varchar(36);not null;index" comment:"用户ID"`
ProductID string `gorm:"type:varchar(36);not null;index" comment:"产品ID"`
ProductCode string `gorm:"type:varchar(50);not null;index" comment:"产品编号"`
SubProductIDs string `gorm:"type:text" comment:"子产品ID列表JSON数组,组合包使用"`
SubProductCodes string `gorm:"type:text" comment:"子产品编号列表JSON数组"`
DownloadPrice decimal.Decimal `gorm:"type:decimal(10,2);not null" comment:"实际支付价格"`
OriginalPrice decimal.Decimal `gorm:"type:decimal(10,2);not null" comment:"原始总价"`
DiscountAmount decimal.Decimal `gorm:"type:decimal(10,2);default:0" comment:"减免金额"`
PaymentOrderID *string `gorm:"type:varchar(64);index" comment:"支付订单号(关联充值记录)"`
PaymentType *string `gorm:"type:varchar(20)" comment:"支付类型alipay, wechat"`
PaymentStatus string `gorm:"type:varchar(20);default:'pending';index" comment:"支付状态pending, success, failed"`
FilePath *string `gorm:"type:varchar(500)" comment:"生成的ZIP文件路径(用于二次下载)"`
FileHash *string `gorm:"type:varchar(64)" comment:"文件哈希值(用于缓存验证)"`
DownloadCount int `gorm:"default:0" comment:"下载次数"`
LastDownloadAt *time.Time `comment:"最后下载时间"`
ExpiresAt *time.Time `gorm:"index" comment:"下载有效期支付成功后30天"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"`
CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"`
}
// TableName 指定数据库表名
// TableName 指定表名
func (ComponentReportDownload) TableName() string {
return "component_report_downloads"
}
@@ -46,8 +46,8 @@ func (c *ComponentReportDownload) BeforeCreate(tx *gorm.DB) error {
return nil
}
// IsPaymentSuccess 检查是否支付成功
func (c *ComponentReportDownload) IsPaymentSuccess() bool {
// IsPaid 检查是否支付
func (c *ComponentReportDownload) IsPaid() bool {
return c.PaymentStatus == "success"
}
@@ -61,25 +61,5 @@ func (c *ComponentReportDownload) IsExpired() bool {
// CanDownload 检查是否可以下载
func (c *ComponentReportDownload) CanDownload() bool {
return c.IsPaymentSuccess() && !c.IsExpired()
return c.IsPaid() && !c.IsExpired()
}
// MarkPaymentSuccess 标记支付成功
func (c *ComponentReportDownload) MarkPaymentSuccess(orderID string, paymentType string) {
c.PaymentOrderID = &orderID
paymentTypeStr := paymentType
c.PaymentType = &paymentTypeStr
c.PaymentStatus = "success"
// 设置30天有效期
expiresAt := time.Now().Add(30 * 24 * time.Hour)
c.ExpiresAt = &expiresAt
}
// IncrementDownloadCount 增加下载次数
func (c *ComponentReportDownload) IncrementDownloadCount() {
c.DownloadCount++
now := time.Now()
c.LastDownloadAt = &now
}

View File

@@ -25,6 +25,9 @@ type Product struct {
IsPackage bool `gorm:"default:false" comment:"是否组合包"`
// 组合包相关关联
PackageItems []*ProductPackageItem `gorm:"foreignKey:PackageID" comment:"组合包项目列表"`
// UI组件相关字段
SellUIComponent bool `gorm:"default:false" comment:"是否出售UI组件"`
UIComponentPrice decimal.Decimal `gorm:"type:decimal(10,2);default:0" comment:"UI组件销售价格组合包使用"`
// SEO信息
SEOTitle string `gorm:"type:varchar(200)" comment:"SEO标题"`
SEODescription string `gorm:"type:text" comment:"SEO描述"`

View File

@@ -0,0 +1,36 @@
package entities
import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// ProductUIComponent 产品UI组件关联实体
type ProductUIComponent struct {
ID string `gorm:"primaryKey;type:varchar(36)" comment:"关联ID"`
ProductID string `gorm:"type:varchar(36);not null;index" comment:"产品ID"`
UIComponentID string `gorm:"type:varchar(36);not null;index" comment:"UI组件ID"`
Price decimal.Decimal `gorm:"type:decimal(10,2);not null;default:0" comment:"销售价格"`
IsEnabled bool `gorm:"default:true" comment:"是否启用销售"`
CreatedAt time.Time `gorm:"autoCreateTime" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" comment:"软删除时间"`
// 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
UIComponent *UIComponent `gorm:"foreignKey:UIComponentID" comment:"UI组件"`
}
func (ProductUIComponent) TableName() string {
return "product_ui_components"
}
func (p *ProductUIComponent) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = uuid.New().String()
}
return nil
}

View File

@@ -0,0 +1,39 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// UIComponent UI组件实体
type UIComponent struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"组件ID"`
ComponentCode string `gorm:"type:varchar(50);not null;uniqueIndex" json:"component_code" comment:"组件编码"`
ComponentName string `gorm:"type:varchar(100);not null" json:"component_name" comment:"组件名称"`
Description string `gorm:"type:text" json:"description" comment:"组件描述"`
FilePath *string `gorm:"type:varchar(500)" json:"file_path" comment:"组件文件路径"`
FileHash *string `gorm:"type:varchar(64)" json:"file_hash" comment:"文件哈希值"`
FileSize *int64 `gorm:"type:bigint" json:"file_size" comment:"文件大小"`
FileType *string `gorm:"type:varchar(50)" json:"file_type" comment:"文件类型"`
FolderPath *string `gorm:"type:varchar(500)" json:"folder_path" comment:"组件文件夹路径"`
IsExtracted bool `gorm:"default:false" json:"is_extracted" comment:"是否已解压"`
Version string `gorm:"type:varchar(20)" json:"version" comment:"组件版本"`
IsActive bool `gorm:"default:true" json:"is_active" comment:"是否启用"`
SortOrder int `gorm:"default:0" json:"sort_order" comment:"排序"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at" comment:"软删除时间"`
}
func (UIComponent) TableName() string {
return "ui_components"
}
func (u *UIComponent) BeforeCreate(tx *gorm.DB) error {
if u.ID == "" {
u.ID = uuid.New().String()
}
return nil
}

View File

@@ -2,23 +2,31 @@ package repositories
import (
"context"
"tyapi-server/internal/domains/product/entities"
)
// ComponentReportRepository 组件报告仓储接口
type ComponentReportRepository interface {
// 下载记录相关
// 创建下载记录
CreateDownload(ctx context.Context, download *entities.ComponentReportDownload) (*entities.ComponentReportDownload, error)
// 更新下载记录
UpdateDownload(ctx context.Context, download *entities.ComponentReportDownload) error
// 根据ID获取下载记录
GetDownloadByID(ctx context.Context, id string) (*entities.ComponentReportDownload, error)
// 获取用户的下载记录列表
GetUserDownloads(ctx context.Context, userID string, productID *string) ([]*entities.ComponentReportDownload, error)
// 检查用户是否已下载过指定产品编号的组件
HasUserDownloaded(ctx context.Context, userID string, productCode string) (bool, error)
// 获取用户已下载的产品编号列表
GetUserDownloadedProductCodes(ctx context.Context, userID string) ([]string, error)
// 根据支付订单号获取下载记录
GetDownloadByPaymentOrderID(ctx context.Context, orderID string) (*entities.ComponentReportDownload, error)
// 缓存相关
GetCacheByProductCode(ctx context.Context, productCode string) (*entities.ComponentReportCache, error)
CreateCache(ctx context.Context, cache *entities.ComponentReportCache) error
UpdateCache(ctx context.Context, cache *entities.ComponentReportCache) error
}

View File

@@ -0,0 +1,16 @@
package repositories
import (
"context"
"tyapi-server/internal/domains/product/entities"
)
// ProductUIComponentRepository 产品UI组件关联仓储接口
type ProductUIComponentRepository interface {
Create(ctx context.Context, relation entities.ProductUIComponent) (entities.ProductUIComponent, error)
GetByProductID(ctx context.Context, productID string) ([]entities.ProductUIComponent, error)
GetByUIComponentID(ctx context.Context, componentID string) ([]entities.ProductUIComponent, error)
Delete(ctx context.Context, id string) error
DeleteByProductID(ctx context.Context, productID string) error
BatchCreate(ctx context.Context, relations []entities.ProductUIComponent) error
}

View File

@@ -0,0 +1,17 @@
package repositories
import (
"context"
"tyapi-server/internal/domains/product/entities"
)
// UIComponentRepository UI组件仓储接口
type UIComponentRepository interface {
Create(ctx context.Context, component entities.UIComponent) (entities.UIComponent, error)
GetByID(ctx context.Context, id string) (*entities.UIComponent, error)
GetByCode(ctx context.Context, code string) (*entities.UIComponent, error)
List(ctx context.Context, filters map[string]interface{}) ([]entities.UIComponent, int64, error)
Update(ctx context.Context, component entities.UIComponent) error
Delete(ctx context.Context, id string) error
GetByCodes(ctx context.Context, codes []string) ([]entities.UIComponent, error)
}

View File

@@ -0,0 +1,130 @@
package repositories
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
"tyapi-server/internal/shared/database"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
ComponentReportDownloadsTable = "component_report_downloads"
)
type GormComponentReportRepository struct {
*database.CachedBaseRepositoryImpl
}
var _ repositories.ComponentReportRepository = (*GormComponentReportRepository)(nil)
func NewGormComponentReportRepository(db *gorm.DB, logger *zap.Logger) repositories.ComponentReportRepository {
return &GormComponentReportRepository{
CachedBaseRepositoryImpl: database.NewCachedBaseRepositoryImpl(db, logger, ComponentReportDownloadsTable),
}
}
func (r *GormComponentReportRepository) CreateDownload(ctx context.Context, download *entities.ComponentReportDownload) (*entities.ComponentReportDownload, error) {
err := r.CreateEntity(ctx, download)
if err != nil {
return nil, err
}
return download, nil
}
func (r *GormComponentReportRepository) UpdateDownload(ctx context.Context, download *entities.ComponentReportDownload) error {
return r.UpdateEntity(ctx, download)
}
func (r *GormComponentReportRepository) GetDownloadByID(ctx context.Context, id string) (*entities.ComponentReportDownload, error) {
var download entities.ComponentReportDownload
err := r.SmartGetByID(ctx, id, &download)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &download, nil
}
func (r *GormComponentReportRepository) GetUserDownloads(ctx context.Context, userID string, productID *string) ([]*entities.ComponentReportDownload, error) {
var downloads []entities.ComponentReportDownload
query := r.GetDB(ctx).Where("user_id = ? AND payment_status = ?", userID, "success")
if productID != nil && *productID != "" {
query = query.Where("product_id = ?", *productID)
}
err := query.Order("created_at DESC").Find(&downloads).Error
if err != nil {
return nil, err
}
result := make([]*entities.ComponentReportDownload, len(downloads))
for i := range downloads {
result[i] = &downloads[i]
}
return result, nil
}
func (r *GormComponentReportRepository) HasUserDownloaded(ctx context.Context, userID string, productCode string) (bool, error) {
var count int64
err := r.GetDB(ctx).Model(&entities.ComponentReportDownload{}).
Where("user_id = ? AND product_code = ? AND payment_status = ?", userID, productCode, "success").
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (r *GormComponentReportRepository) GetUserDownloadedProductCodes(ctx context.Context, userID string) ([]string, error) {
var downloads []entities.ComponentReportDownload
err := r.GetDB(ctx).
Select("DISTINCT sub_product_codes").
Where("user_id = ? AND payment_status = ?", userID, "success").
Find(&downloads).Error
if err != nil {
return nil, err
}
codesMap := make(map[string]bool)
for _, download := range downloads {
if download.SubProductCodes != "" {
var codes []string
if err := json.Unmarshal([]byte(download.SubProductCodes), &codes); err == nil {
for _, code := range codes {
codesMap[code] = true
}
}
}
// 也添加主产品编号
if download.ProductCode != "" {
codesMap[download.ProductCode] = true
}
}
codes := make([]string, 0, len(codesMap))
for code := range codesMap {
codes = append(codes, code)
}
return codes, nil
}
func (r *GormComponentReportRepository) GetDownloadByPaymentOrderID(ctx context.Context, orderID string) (*entities.ComponentReportDownload, error) {
var download entities.ComponentReportDownload
err := r.GetDB(ctx).Where("payment_order_id = ?", orderID).First(&download).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, gorm.ErrRecordNotFound
}
return nil, err
}
return &download, nil
}

View File

@@ -0,0 +1,80 @@
package repositories
import (
"context"
"fmt"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
"gorm.io/gorm"
)
// GormProductUIComponentRepository 产品UI组件关联仓储实现
type GormProductUIComponentRepository struct {
db *gorm.DB
}
// NewGormProductUIComponentRepository 创建产品UI组件关联仓储实例
func NewGormProductUIComponentRepository(db *gorm.DB) repositories.ProductUIComponentRepository {
return &GormProductUIComponentRepository{db: db}
}
// Create 创建产品UI组件关联
func (r *GormProductUIComponentRepository) Create(ctx context.Context, relation entities.ProductUIComponent) (entities.ProductUIComponent, error) {
if err := r.db.WithContext(ctx).Create(&relation).Error; err != nil {
return entities.ProductUIComponent{}, fmt.Errorf("创建产品UI组件关联失败: %w", err)
}
return relation, nil
}
// GetByProductID 根据产品ID获取UI组件关联列表
func (r *GormProductUIComponentRepository) GetByProductID(ctx context.Context, productID string) ([]entities.ProductUIComponent, error) {
var relations []entities.ProductUIComponent
if err := r.db.WithContext(ctx).
Preload("UIComponent").
Where("product_id = ?", productID).
Find(&relations).Error; err != nil {
return nil, fmt.Errorf("获取产品UI组件关联列表失败: %w", err)
}
return relations, nil
}
// GetByUIComponentID 根据UI组件ID获取产品关联列表
func (r *GormProductUIComponentRepository) GetByUIComponentID(ctx context.Context, componentID string) ([]entities.ProductUIComponent, error) {
var relations []entities.ProductUIComponent
if err := r.db.WithContext(ctx).
Preload("Product").
Where("ui_component_id = ?", componentID).
Find(&relations).Error; err != nil {
return nil, fmt.Errorf("获取UI组件产品关联列表失败: %w", err)
}
return relations, nil
}
// Delete 删除产品UI组件关联
func (r *GormProductUIComponentRepository) Delete(ctx context.Context, id string) error {
if err := r.db.WithContext(ctx).Delete(&entities.ProductUIComponent{}, id).Error; err != nil {
return fmt.Errorf("删除产品UI组件关联失败: %w", err)
}
return nil
}
// DeleteByProductID 根据产品ID删除所有关联
func (r *GormProductUIComponentRepository) DeleteByProductID(ctx context.Context, productID string) error {
if err := r.db.WithContext(ctx).Where("product_id = ?", productID).Delete(&entities.ProductUIComponent{}).Error; err != nil {
return fmt.Errorf("根据产品ID删除UI组件关联失败: %w", err)
}
return nil
}
// BatchCreate 批量创建产品UI组件关联
func (r *GormProductUIComponentRepository) BatchCreate(ctx context.Context, relations []entities.ProductUIComponent) error {
if len(relations) == 0 {
return nil
}
if err := r.db.WithContext(ctx).CreateInBatches(relations, 100).Error; err != nil {
return fmt.Errorf("批量创建产品UI组件关联失败: %w", err)
}
return nil
}

View File

@@ -0,0 +1,129 @@
package repositories
import (
"context"
"fmt"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
"gorm.io/gorm"
)
// GormUIComponentRepository UI组件仓储实现
type GormUIComponentRepository struct {
db *gorm.DB
}
// NewGormUIComponentRepository 创建UI组件仓储实例
func NewGormUIComponentRepository(db *gorm.DB) repositories.UIComponentRepository {
return &GormUIComponentRepository{db: db}
}
// Create 创建UI组件
func (r *GormUIComponentRepository) Create(ctx context.Context, component entities.UIComponent) (entities.UIComponent, error) {
if err := r.db.WithContext(ctx).Create(&component).Error; err != nil {
return entities.UIComponent{}, fmt.Errorf("创建UI组件失败: %w", err)
}
return component, nil
}
// GetByID 根据ID获取UI组件
func (r *GormUIComponentRepository) GetByID(ctx context.Context, id string) (*entities.UIComponent, error) {
var component entities.UIComponent
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&component).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, fmt.Errorf("获取UI组件失败: %w", err)
}
return &component, nil
}
// GetByCode 根据编码获取UI组件
func (r *GormUIComponentRepository) GetByCode(ctx context.Context, code string) (*entities.UIComponent, error) {
var component entities.UIComponent
if err := r.db.WithContext(ctx).Where("component_code = ?", code).First(&component).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, fmt.Errorf("获取UI组件失败: %w", err)
}
return &component, nil
}
// List 获取UI组件列表
func (r *GormUIComponentRepository) List(ctx context.Context, filters map[string]interface{}) ([]entities.UIComponent, int64, error) {
var components []entities.UIComponent
var total int64
query := r.db.WithContext(ctx).Model(&entities.UIComponent{})
// 应用过滤条件
if isActive, ok := filters["is_active"]; ok {
query = query.Where("is_active = ?", isActive)
}
if keyword, ok := filters["keyword"]; ok && keyword != "" {
query = query.Where("component_name LIKE ? OR component_code LIKE ? OR description LIKE ?",
"%"+keyword.(string)+"%", "%"+keyword.(string)+"%", "%"+keyword.(string)+"%")
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("获取UI组件总数失败: %w", err)
}
// 分页
if page, ok := filters["page"]; ok {
if pageSize, ok := filters["page_size"]; ok {
offset := (page.(int) - 1) * pageSize.(int)
query = query.Offset(offset).Limit(pageSize.(int))
}
}
// 排序
if sortBy, ok := filters["sort_by"]; ok {
if sortOrder, ok := filters["sort_order"]; ok {
query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
}
} else {
query = query.Order("sort_order ASC, created_at DESC")
}
// 获取数据
if err := query.Find(&components).Error; err != nil {
return nil, 0, fmt.Errorf("获取UI组件列表失败: %w", err)
}
return components, total, nil
}
// Update 更新UI组件
func (r *GormUIComponentRepository) Update(ctx context.Context, component entities.UIComponent) error {
if err := r.db.WithContext(ctx).Save(&component).Error; err != nil {
return fmt.Errorf("更新UI组件失败: %w", err)
}
return nil
}
// Delete 删除UI组件
func (r *GormUIComponentRepository) Delete(ctx context.Context, id string) error {
if err := r.db.WithContext(ctx).Delete(&entities.UIComponent{}, id).Error; err != nil {
return fmt.Errorf("删除UI组件失败: %w", err)
}
return nil
}
// GetByCodes 根据编码列表获取UI组件
func (r *GormUIComponentRepository) GetByCodes(ctx context.Context, codes []string) ([]entities.UIComponent, error) {
var components []entities.UIComponent
if len(codes) == 0 {
return components, nil
}
if err := r.db.WithContext(ctx).Where("component_code IN ?", codes).Find(&components).Error; err != nil {
return nil, fmt.Errorf("根据编码列表获取UI组件失败: %w", err)
}
return components, nil
}

View File

@@ -0,0 +1,115 @@
package storage
import (
"context"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"go.uber.org/zap"
)
// LocalFileStorageService 本地文件存储服务
type LocalFileStorageService struct {
basePath string
logger *zap.Logger
}
// LocalFileStorageConfig 本地文件存储配置
type LocalFileStorageConfig struct {
BasePath string `yaml:"base_path"`
}
// NewLocalFileStorageService 创建本地文件存储服务
func NewLocalFileStorageService(basePath string, logger *zap.Logger) *LocalFileStorageService {
// 确保基础路径存在
if err := os.MkdirAll(basePath, 0755); err != nil {
logger.Error("创建基础存储目录失败", zap.Error(err), zap.String("path", basePath))
}
return &LocalFileStorageService{
basePath: basePath,
logger: logger,
}
}
// StoreFile 存储文件
func (s *LocalFileStorageService) StoreFile(ctx context.Context, file io.Reader, filename string) (string, error) {
// 构建完整文件路径
fullPath := filepath.Join(s.basePath, filename)
// 确保目录存在
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
s.logger.Error("创建目录失败", zap.Error(err), zap.String("dir", dir))
return "", fmt.Errorf("创建目录失败: %w", err)
}
// 创建文件
dst, err := os.Create(fullPath)
if err != nil {
s.logger.Error("创建文件失败", zap.Error(err), zap.String("path", fullPath))
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dst.Close()
// 复制文件内容
if _, err := io.Copy(dst, file); err != nil {
s.logger.Error("写入文件失败", zap.Error(err), zap.String("path", fullPath))
// 删除部分写入的文件
_ = os.Remove(fullPath)
return "", fmt.Errorf("写入文件失败: %w", err)
}
s.logger.Info("文件存储成功", zap.String("path", fullPath))
return fullPath, nil
}
// StoreMultipartFile 存储multipart文件
func (s *LocalFileStorageService) StoreMultipartFile(ctx context.Context, file *multipart.FileHeader, filename string) (string, error) {
src, err := file.Open()
if err != nil {
return "", fmt.Errorf("打开上传文件失败: %w", err)
}
defer src.Close()
return s.StoreFile(ctx, src, filename)
}
// GetFileURL 获取文件URL
func (s *LocalFileStorageService) GetFileURL(ctx context.Context, filePath string) (string, error) {
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return "", fmt.Errorf("文件不存在: %s", filePath)
}
// 返回文件路径在实际应用中这里应该返回可访问的URL
return filePath, nil
}
// DeleteFile 删除文件
func (s *LocalFileStorageService) DeleteFile(ctx context.Context, filePath string) error {
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
// 文件不存在,不视为错误
return nil
}
s.logger.Error("删除文件失败", zap.Error(err), zap.String("path", filePath))
return fmt.Errorf("删除文件失败: %w", err)
}
s.logger.Info("文件删除成功", zap.String("path", filePath))
return nil
}
// GetFileReader 获取文件读取器
func (s *LocalFileStorageService) GetFileReader(ctx context.Context, filePath string) (io.ReadCloser, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
return file, nil
}

View File

@@ -0,0 +1,110 @@
package storage
import (
"context"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"go.uber.org/zap"
)
// LocalFileStorageServiceImpl 本地文件存储服务实现
type LocalFileStorageServiceImpl struct {
basePath string
logger *zap.Logger
}
// NewLocalFileStorageServiceImpl 创建本地文件存储服务实现
func NewLocalFileStorageServiceImpl(basePath string, logger *zap.Logger) *LocalFileStorageServiceImpl {
// 确保基础路径存在
if err := os.MkdirAll(basePath, 0755); err != nil {
logger.Error("创建基础存储目录失败", zap.Error(err), zap.String("path", basePath))
}
return &LocalFileStorageServiceImpl{
basePath: basePath,
logger: logger,
}
}
// StoreFile 存储文件
func (s *LocalFileStorageServiceImpl) StoreFile(ctx context.Context, file io.Reader, filename string) (string, error) {
// 构建完整文件路径
fullPath := filepath.Join(s.basePath, filename)
// 确保目录存在
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
s.logger.Error("创建目录失败", zap.Error(err), zap.String("dir", dir))
return "", fmt.Errorf("创建目录失败: %w", err)
}
// 创建文件
dst, err := os.Create(fullPath)
if err != nil {
s.logger.Error("创建文件失败", zap.Error(err), zap.String("path", fullPath))
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dst.Close()
// 复制文件内容
if _, err := io.Copy(dst, file); err != nil {
s.logger.Error("写入文件失败", zap.Error(err), zap.String("path", fullPath))
// 删除部分写入的文件
_ = os.Remove(fullPath)
return "", fmt.Errorf("写入文件失败: %w", err)
}
s.logger.Info("文件存储成功", zap.String("path", fullPath))
return fullPath, nil
}
// StoreMultipartFile 存储multipart文件
func (s *LocalFileStorageServiceImpl) StoreMultipartFile(ctx context.Context, file *multipart.FileHeader, filename string) (string, error) {
src, err := file.Open()
if err != nil {
return "", fmt.Errorf("打开上传文件失败: %w", err)
}
defer src.Close()
return s.StoreFile(ctx, src, filename)
}
// GetFileURL 获取文件URL
func (s *LocalFileStorageServiceImpl) GetFileURL(ctx context.Context, filePath string) (string, error) {
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return "", fmt.Errorf("文件不存在: %s", filePath)
}
// 返回文件路径在实际应用中这里应该返回可访问的URL
return filePath, nil
}
// DeleteFile 删除文件
func (s *LocalFileStorageServiceImpl) DeleteFile(ctx context.Context, filePath string) error {
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
// 文件不存在,不视为错误
return nil
}
s.logger.Error("删除文件失败", zap.Error(err), zap.String("path", filePath))
return fmt.Errorf("删除文件失败: %w", err)
}
s.logger.Info("文件删除成功", zap.String("path", filePath))
return nil
}
// GetFileReader 获取文件读取器
func (s *LocalFileStorageServiceImpl) GetFileReader(ctx context.Context, filePath string) (io.ReadCloser, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
return file, nil
}

View File

@@ -0,0 +1,92 @@
package handlers
import (
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"tyapi-server/internal/application/product"
"tyapi-server/internal/shared/interfaces"
)
// FileDownloadHandler 文件下载处理器
type FileDownloadHandler struct {
uiComponentAppService product.UIComponentApplicationService
responseBuilder interfaces.ResponseBuilder
logger *zap.Logger
}
// NewFileDownloadHandler 创建文件下载处理器
func NewFileDownloadHandler(
uiComponentAppService product.UIComponentApplicationService,
responseBuilder interfaces.ResponseBuilder,
logger *zap.Logger,
) *FileDownloadHandler {
return &FileDownloadHandler{
uiComponentAppService: uiComponentAppService,
responseBuilder: responseBuilder,
logger: logger,
}
}
// DownloadUIComponentFile 下载UI组件文件
// @Summary 下载UI组件文件
// @Description 下载UI组件文件
// @Tags 文件下载
// @Accept json
// @Produce application/octet-stream
// @Param id path string true "UI组件ID"
// @Success 200 {file} file "文件内容"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在或文件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/ui-components/{id}/download [get]
func (h *FileDownloadHandler) DownloadUIComponentFile(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
// 获取UI组件信息
component, err := h.uiComponentAppService.GetUIComponentByID(c.Request.Context(), id)
if err != nil {
h.logger.Error("获取UI组件失败", zap.Error(err), zap.String("id", id))
h.responseBuilder.InternalError(c, "获取UI组件失败")
return
}
if component == nil {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
if component.FilePath == nil {
h.responseBuilder.NotFound(c, "UI组件文件不存在")
return
}
// 获取文件路径
filePath, err := h.uiComponentAppService.DownloadUIComponentFile(c.Request.Context(), id)
if err != nil {
h.logger.Error("获取UI组件文件路径失败", zap.Error(err), zap.String("id", id))
h.responseBuilder.InternalError(c, "获取UI组件文件路径失败")
return
}
// 设置下载文件名
fileName := component.ComponentName
if !strings.HasSuffix(strings.ToLower(fileName), ".zip") {
fileName += ".zip"
}
// 设置响应头
c.Header("Content-Description", "File Transfer")
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Content-Disposition", "attachment; filename="+fileName)
c.Header("Content-Type", "application/octet-stream")
// 发送文件
c.File(filePath)
}

View File

@@ -0,0 +1,551 @@
package handlers
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"tyapi-server/internal/application/product"
"tyapi-server/internal/shared/interfaces"
)
// UIComponentHandler UI组件HTTP处理器
type UIComponentHandler struct {
uiComponentAppService product.UIComponentApplicationService
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
}
// NewUIComponentHandler 创建UI组件HTTP处理器
func NewUIComponentHandler(
uiComponentAppService product.UIComponentApplicationService,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
logger *zap.Logger,
) *UIComponentHandler {
return &UIComponentHandler{
uiComponentAppService: uiComponentAppService,
responseBuilder: responseBuilder,
validator: validator,
logger: logger,
}
}
// CreateUIComponent 创建UI组件
// @Summary 创建UI组件
// @Description 管理员创建新的UI组件
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param request body product.CreateUIComponentRequest true "创建UI组件请求"
// @Success 200 {object} interfaces.Response{data=entities.UIComponent} "创建成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components [post]
func (h *UIComponentHandler) CreateUIComponent(c *gin.Context) {
var req product.CreateUIComponentRequest
// 一次性读取请求体并绑定到结构体
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("验证创建UI组件请求失败", zap.Error(err))
h.responseBuilder.BadRequest(c, fmt.Sprintf("请求参数错误: %v", err))
return
}
// 使用结构体数据记录日志
h.logger.Info("创建UI组件请求数据",
zap.String("component_code", req.ComponentCode),
zap.String("component_name", req.ComponentName),
zap.String("description", req.Description),
zap.String("version", req.Version),
zap.Bool("is_active", req.IsActive),
zap.Int("sort_order", req.SortOrder))
component, err := h.uiComponentAppService.CreateUIComponent(c.Request.Context(), req)
if err != nil {
h.logger.Error("创建UI组件失败", zap.Error(err), zap.String("component_code", req.ComponentCode))
if err == product.ErrComponentCodeAlreadyExists {
h.responseBuilder.BadRequest(c, "UI组件编码已存在")
return
}
h.responseBuilder.InternalError(c, fmt.Sprintf("创建UI组件失败: %v", err))
return
}
h.responseBuilder.Success(c, component)
}
// CreateUIComponentWithFile 创建UI组件并上传文件
// @Summary 创建UI组件并上传文件
// @Description 管理员创建新的UI组件并同时上传文件
// @Tags UI组件管理
// @Accept multipart/form-data
// @Produce json
// @Param component_code formData string true "组件编码"
// @Param component_name formData string true "组件名称"
// @Param description formData string false "组件描述"
// @Param version formData string false "组件版本"
// @Param is_active formData bool false "是否启用"
// @Param sort_order formData int false "排序"
// @Param file formData file true "组件文件"
// @Success 200 {object} interfaces.Response{data=entities.UIComponent} "创建成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/create-with-file [post]
func (h *UIComponentHandler) CreateUIComponentWithFile(c *gin.Context) {
// 创建请求结构体
var req product.CreateUIComponentRequest
// 从表单数据中获取组件信息
req.ComponentCode = c.PostForm("component_code")
req.ComponentName = c.PostForm("component_name")
req.Description = c.PostForm("description")
req.Version = c.PostForm("version")
req.IsActive = c.PostForm("is_active") == "true"
if sortOrderStr := c.PostForm("sort_order"); sortOrderStr != "" {
if sortOrder, err := strconv.Atoi(sortOrderStr); err == nil {
req.SortOrder = sortOrder
}
}
// 验证必需字段
if req.ComponentCode == "" {
h.responseBuilder.BadRequest(c, "组件编码不能为空")
return
}
if req.ComponentName == "" {
h.responseBuilder.BadRequest(c, "组件名称不能为空")
return
}
// 获取上传的文件
form, err := c.MultipartForm()
if err != nil {
h.logger.Error("获取表单数据失败", zap.Error(err))
h.responseBuilder.BadRequest(c, "获取表单数据失败")
return
}
files := form.File["files"]
if len(files) == 0 {
h.responseBuilder.BadRequest(c, "请上传组件文件")
return
}
// 检查文件大小100MB
for _, fileHeader := range files {
if fileHeader.Size > 100*1024*1024 {
h.responseBuilder.BadRequest(c, fmt.Sprintf("文件 %s 大小不能超过100MB", fileHeader.Filename))
return
}
}
// 获取路径信息
paths := c.PostFormArray("paths")
// 记录请求日志
h.logger.Info("创建UI组件并上传文件请求",
zap.String("component_code", req.ComponentCode),
zap.String("component_name", req.ComponentName),
zap.String("description", req.Description),
zap.String("version", req.Version),
zap.Bool("is_active", req.IsActive),
zap.Int("sort_order", req.SortOrder),
zap.Int("files_count", len(files)),
zap.Strings("paths", paths))
// 调用应用服务创建组件并上传文件
component, err := h.uiComponentAppService.CreateUIComponentWithFilesAndPaths(c.Request.Context(), req, files, paths)
if err != nil {
h.logger.Error("创建UI组件并上传文件失败", zap.Error(err), zap.String("component_code", req.ComponentCode))
if err == product.ErrComponentCodeAlreadyExists {
h.responseBuilder.BadRequest(c, "UI组件编码已存在")
return
}
h.responseBuilder.InternalError(c, fmt.Sprintf("创建UI组件并上传文件失败: %v", err))
return
}
h.responseBuilder.Success(c, component)
}
// GetUIComponent 获取UI组件详情
// @Summary 获取UI组件详情
// @Description 根据ID获取UI组件详情
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param id path string true "UI组件ID"
// @Success 200 {object} interfaces.Response{data=entities.UIComponent} "获取成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id} [get]
func (h *UIComponentHandler) GetUIComponent(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
component, err := h.uiComponentAppService.GetUIComponentByID(c.Request.Context(), id)
if err != nil {
h.logger.Error("获取UI组件失败", zap.Error(err), zap.String("id", id))
h.responseBuilder.InternalError(c, "获取UI组件失败")
return
}
if component == nil {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
h.responseBuilder.Success(c, component)
}
// UpdateUIComponent 更新UI组件
// @Summary 更新UI组件
// @Description 更新UI组件信息
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param id path string true "UI组件ID"
// @Param request body product.UpdateUIComponentRequest true "更新UI组件请求"
// @Success 200 {object} interfaces.Response "更新成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id} [put]
func (h *UIComponentHandler) UpdateUIComponent(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
var req product.UpdateUIComponentRequest
// 设置ID
req.ID = id
// 验证请求
if err := h.validator.Validate(c, &req); err != nil {
h.logger.Error("验证更新UI组件请求失败", zap.Error(err))
return
}
err := h.uiComponentAppService.UpdateUIComponent(c.Request.Context(), req)
if err != nil {
h.logger.Error("更新UI组件失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
if err == product.ErrComponentCodeAlreadyExists {
h.responseBuilder.BadRequest(c, "UI组件编码已存在")
return
}
h.responseBuilder.InternalError(c, "更新UI组件失败")
return
}
h.responseBuilder.Success(c, nil)
}
// DeleteUIComponent 删除UI组件
// @Summary 删除UI组件
// @Description 删除UI组件
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param id path string true "UI组件ID"
// @Success 200 {object} interfaces.Response "删除成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id} [delete]
func (h *UIComponentHandler) DeleteUIComponent(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
err := h.uiComponentAppService.DeleteUIComponent(c.Request.Context(), id)
if err != nil {
h.logger.Error("删除UI组件失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
h.responseBuilder.InternalError(c, "删除UI组件失败")
return
}
h.responseBuilder.Success(c, nil)
}
// ListUIComponents 获取UI组件列表
// @Summary 获取UI组件列表
// @Description 获取UI组件列表支持分页和筛选
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param keyword query string false "关键词搜索"
// @Param is_active query bool false "是否启用"
// @Param sort_by query string false "排序字段" default(sort_order)
// @Param sort_order query string false "排序方向" default(asc)
// @Success 200 {object} interfaces.Response{data=product.ListUIComponentsResponse} "获取成功"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components [get]
func (h *UIComponentHandler) ListUIComponents(c *gin.Context) {
// 解析查询参数
req := product.ListUIComponentsRequest{}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
if pageSize, err := strconv.Atoi(pageSizeStr); err == nil {
req.PageSize = pageSize
}
}
req.Keyword = c.Query("keyword")
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
req.SortBy = c.DefaultQuery("sort_by", "sort_order")
req.SortOrder = c.DefaultQuery("sort_order", "asc")
response, err := h.uiComponentAppService.ListUIComponents(c.Request.Context(), req)
if err != nil {
h.logger.Error("获取UI组件列表失败", zap.Error(err))
h.responseBuilder.InternalError(c, "获取UI组件列表失败")
return
}
h.responseBuilder.Success(c, response)
}
// UploadUIComponentFile 上传UI组件文件
// @Summary 上传UI组件文件
// @Description 上传UI组件文件
// @Tags UI组件管理
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "UI组件ID"
// @Param file formData file true "UI组件文件(ZIP格式)"
// @Success 200 {object} interfaces.Response{data=string} "上传成功,返回文件路径"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id}/upload [post]
func (h *UIComponentHandler) UploadUIComponentFile(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
h.logger.Error("获取上传文件失败", zap.Error(err))
h.responseBuilder.BadRequest(c, "获取上传文件失败")
return
}
// 检查文件大小100MB
if file.Size > 100*1024*1024 {
h.responseBuilder.BadRequest(c, "文件大小不能超过100MB")
return
}
filePath, err := h.uiComponentAppService.UploadUIComponentFile(c.Request.Context(), id, file)
if err != nil {
h.logger.Error("上传UI组件文件失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
if err == product.ErrInvalidFileType {
h.responseBuilder.BadRequest(c, "文件类型错误")
return
}
h.responseBuilder.InternalError(c, "上传UI组件文件失败")
return
}
h.responseBuilder.Success(c, filePath)
}
// UploadAndExtractUIComponentFile 上传并解压UI组件文件
// @Summary 上传并解压UI组件文件
// @Description 上传文件并自动解压到组件文件夹仅ZIP文件支持解压
// @Tags UI组件管理
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "UI组件ID"
// @Param file formData file true "UI组件文件(任意格式ZIP格式支持自动解压)"
// @Success 200 {object} interfaces.Response "上传成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id}/upload-extract [post]
func (h *UIComponentHandler) UploadAndExtractUIComponentFile(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
// 获取上传的文件
file, err := c.FormFile("file")
if err != nil {
h.logger.Error("获取上传文件失败", zap.Error(err))
h.responseBuilder.BadRequest(c, "获取上传文件失败")
return
}
// 检查文件大小100MB
if file.Size > 100*1024*1024 {
h.responseBuilder.BadRequest(c, "文件大小不能超过100MB")
return
}
err = h.uiComponentAppService.UploadAndExtractUIComponentFile(c.Request.Context(), id, file)
if err != nil {
h.logger.Error("上传并解压UI组件文件失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
if err == product.ErrInvalidFileType {
h.responseBuilder.BadRequest(c, "文件类型错误")
return
}
h.responseBuilder.InternalError(c, "上传并解压UI组件文件失败")
return
}
h.responseBuilder.Success(c, nil)
}
// GetUIComponentFolderContent 获取UI组件文件夹内容
// @Summary 获取UI组件文件夹内容
// @Description 获取UI组件文件夹内容
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param id path string true "UI组件ID"
// @Success 200 {object} interfaces.Response{data=[]FileInfo} "获取成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id}/folder-content [get]
func (h *UIComponentHandler) GetUIComponentFolderContent(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
files, err := h.uiComponentAppService.GetUIComponentFolderContent(c.Request.Context(), id)
if err != nil {
h.logger.Error("获取UI组件文件夹内容失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
h.responseBuilder.InternalError(c, "获取UI组件文件夹内容失败")
return
}
h.responseBuilder.Success(c, files)
}
// DeleteUIComponentFolder 删除UI组件文件夹
// @Summary 删除UI组件文件夹
// @Description 删除UI组件文件夹
// @Tags UI组件管理
// @Accept json
// @Produce json
// @Param id path string true "UI组件ID"
// @Success 200 {object} interfaces.Response "删除成功"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id}/folder [delete]
func (h *UIComponentHandler) DeleteUIComponentFolder(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
err := h.uiComponentAppService.DeleteUIComponentFolder(c.Request.Context(), id)
if err != nil {
h.logger.Error("删除UI组件文件夹失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
h.responseBuilder.InternalError(c, "删除UI组件文件夹失败")
return
}
h.responseBuilder.Success(c, nil)
}
// DownloadUIComponentFile 下载UI组件文件
// @Summary 下载UI组件文件
// @Description 下载UI组件文件
// @Tags UI组件管理
// @Accept json
// @Produce application/octet-stream
// @Param id path string true "UI组件ID"
// @Success 200 {file} file "文件内容"
// @Failure 400 {object} interfaces.Response "请求参数错误"
// @Failure 404 {object} interfaces.Response "UI组件不存在或文件不存在"
// @Failure 500 {object} interfaces.Response "服务器内部错误"
// @Router /api/v1/admin/ui-components/{id}/download [get]
func (h *UIComponentHandler) DownloadUIComponentFile(c *gin.Context) {
id := c.Param("id")
if id == "" {
h.responseBuilder.BadRequest(c, "UI组件ID不能为空")
return
}
filePath, err := h.uiComponentAppService.DownloadUIComponentFile(c.Request.Context(), id)
if err != nil {
h.logger.Error("下载UI组件文件失败", zap.Error(err), zap.String("id", id))
if err == product.ErrComponentNotFound {
h.responseBuilder.NotFound(c, "UI组件不存在")
return
}
if err == product.ErrComponentFileNotFound {
h.responseBuilder.NotFound(c, "UI组件文件不存在")
return
}
h.responseBuilder.InternalError(c, "下载UI组件文件失败")
return
}
// 这里应该实现文件下载逻辑,返回文件内容
// 由于我们使用的是本地文件存储,可以直接返回文件
c.File(filePath)
}

View File

@@ -2,6 +2,7 @@ package routes
import (
"tyapi-server/internal/infrastructure/http/handlers"
component_report "tyapi-server/internal/shared/component_report"
sharedhttp "tyapi-server/internal/shared/http"
"tyapi-server/internal/shared/middleware"
@@ -11,7 +12,7 @@ import (
// ProductRoutes 产品路由
type ProductRoutes struct {
productHandler *handlers.ProductHandler
componentReportHandler *handlers.ComponentReportHandler
componentReportHandler *component_report.ComponentReportHandler
auth *middleware.JWTAuthMiddleware
optionalAuth *middleware.OptionalAuthMiddleware
logger *zap.Logger
@@ -20,7 +21,7 @@ type ProductRoutes struct {
// NewProductRoutes 创建产品路由
func NewProductRoutes(
productHandler *handlers.ProductHandler,
componentReportHandler *handlers.ComponentReportHandler,
componentReportHandler *component_report.ComponentReportHandler,
auth *middleware.JWTAuthMiddleware,
optionalAuth *middleware.OptionalAuthMiddleware,
logger *zap.Logger,
@@ -58,19 +59,24 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
// 订阅产品(需要认证)
products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct)
}
// 组件报告相关路由(需要认证
componentReport := products.Group("/:id/component-report", r.auth.Handle())
{
// 获取报告下载信息
componentReport.GET("/info", r.componentReportHandler.GetReportDownloadInfo)
// 创建支付订单(暂时注释,后续实现)
// componentReport.POST("/create-order", r.componentReportHandler.CreateReportPaymentOrder)
// 下载报告文件
componentReport.GET("/download/:downloadId", r.componentReportHandler.DownloadReport)
}
// 组件报告 - 需要认证
componentReport := engine.Group("/api/v1/component-report", r.auth.Handle())
{
// 生成并下载 example.json 文件
componentReport.POST("/download-example-json", r.componentReportHandler.DownloadExampleJSON)
// 生成并下载示例报告ZIP文件
componentReport.POST("/generate-and-download", r.componentReportHandler.GenerateAndDownloadZip)
}
// 产品组件报告相关接口 - 需要认证
componentReportGroup := products.Group("/:id/component-report", r.auth.Handle())
{
componentReportGroup.GET("/check", r.componentReportHandler.CheckDownloadAvailability)
componentReportGroup.GET("/info", r.componentReportHandler.GetDownloadInfo)
componentReportGroup.POST("/create-order", r.componentReportHandler.CreatePaymentOrder)
componentReportGroup.GET("/check-payment/:orderId", r.componentReportHandler.CheckPaymentStatus)
}
// 分类 - 公开接口
@@ -103,9 +109,6 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
// 取消订阅
subscriptions.POST("/:id/cancel", r.productHandler.CancelMySubscription)
}
// 我的组件报告下载历史
my.GET("/component-reports", r.componentReportHandler.GetUserDownloadHistory)
}
r.logger.Info("产品路由注册完成")

View File

@@ -0,0 +1,48 @@
package routes
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"tyapi-server/internal/infrastructure/http/handlers"
"tyapi-server/internal/shared/interfaces"
)
// UIComponentRoutes UI组件路由
type UIComponentRoutes struct {
uiComponentHandler *handlers.UIComponentHandler
logger *zap.Logger
}
// NewUIComponentRoutes 创建UI组件路由
func NewUIComponentRoutes(
uiComponentHandler *handlers.UIComponentHandler,
logger *zap.Logger,
) *UIComponentRoutes {
return &UIComponentRoutes{
uiComponentHandler: uiComponentHandler,
logger: logger,
}
}
// RegisterRoutes 注册UI组件路由
func (r *UIComponentRoutes) RegisterRoutes(router *gin.RouterGroup, authMiddleware interfaces.Middleware) {
uiComponentGroup := router.Group("/ui-components")
uiComponentGroup.Use(authMiddleware.Handle())
{
// UI组件管理
uiComponentGroup.POST("", r.uiComponentHandler.CreateUIComponent) // 创建UI组件
uiComponentGroup.POST("/create-with-file", r.uiComponentHandler.CreateUIComponentWithFile) // 创建UI组件并上传文件
uiComponentGroup.GET("", r.uiComponentHandler.ListUIComponents) // 获取UI组件列表
uiComponentGroup.GET("/:id", r.uiComponentHandler.GetUIComponent) // 获取UI组件详情
uiComponentGroup.PUT("/:id", r.uiComponentHandler.UpdateUIComponent) // 更新UI组件
uiComponentGroup.DELETE("/:id", r.uiComponentHandler.DeleteUIComponent) // 删除UI组件
// 文件操作
uiComponentGroup.POST("/:id/upload", r.uiComponentHandler.UploadUIComponentFile) // 上传UI组件文件
uiComponentGroup.POST("/:id/upload-extract", r.uiComponentHandler.UploadAndExtractUIComponentFile) // 上传并解压UI组件文件
uiComponentGroup.GET("/:id/folder-content", r.uiComponentHandler.GetUIComponentFolderContent) // 获取UI组件文件夹内容
uiComponentGroup.DELETE("/:id/folder", r.uiComponentHandler.DeleteUIComponentFolder) // 删除UI组件文件夹
uiComponentGroup.GET("/:id/download", r.uiComponentHandler.DownloadUIComponentFile) // 下载UI组件文件
}
}

View File

@@ -0,0 +1,204 @@
# 组件报告生成服务
这个服务用于生成产品示例报告的 `example.json` 文件,并打包成 ZIP 文件供下载。
## 功能概述
1. **生成 example.json 文件**:根据组合包子产品的响应示例数据生成符合格式要求的 JSON 文件
2. **打包 ZIP 文件**:将生成的 `example.json` 文件打包成 ZIP 格式
3. **HTTP 接口**:提供 HTTP 接口用于生成和下载文件
## 文件结构
```
component_report/
├── example_json_generator.go # 示例JSON生成器
├── zip_generator.go # ZIP文件生成器
├── handler.go # HTTP处理器
└── README.md # 说明文档
```
## 使用方法
### 1. 直接使用生成器
```go
// 创建生成器
exampleJSONGenerator := component_report.NewExampleJSONGenerator(
productRepo,
docRepo,
apiConfigRepo,
logger,
)
// 生成 example.json
jsonData, err := exampleJSONGenerator.GenerateExampleJSON(
ctx,
productID, // 产品ID可以是组合包或单品
subProductCodes, // 子产品编号列表(可选,如果为空则处理所有子产品)
)
```
### 2. 生成 ZIP 文件
```go
// 创建ZIP生成器
zipGenerator := component_report.NewZipGenerator(logger)
// 生成ZIP文件
zipPath, err := zipGenerator.GenerateZipFile(
ctx,
productID,
subProductCodes,
exampleJSONGenerator,
outputPath, // 输出路径(可选,如果为空则使用默认路径)
)
```
### 3. 使用 HTTP 接口
#### 生成 example.json
```http
POST /api/v1/component-report/generate-example-json
Content-Type: application/json
{
"product_id": "产品ID",
"sub_product_codes": ["子产品编号1", "子产品编号2"] // 可选
}
```
响应:
```json
{
"product_id": "产品ID",
"json_content": "生成的JSON内容",
"json_size": 1234
}
```
#### 生成 ZIP 文件
```http
POST /api/v1/component-report/generate-zip
Content-Type: application/json
{
"product_id": "产品ID",
"sub_product_codes": ["子产品编号1", "子产品编号2"], // 可选
"output_path": "自定义输出路径" // 可选
}
```
响应:
```json
{
"code": 200,
"message": "ZIP文件生成成功",
"zip_path": "storage/component-reports/xxx_example.json.zip",
"file_size": 12345,
"file_name": "xxx_example.json.zip"
}
```
#### 生成并下载 ZIP 文件
```http
POST /api/v1/component-report/generate-and-download
Content-Type: application/json
{
"product_id": "产品ID",
"sub_product_codes": ["子产品编号1", "子产品编号2"] // 可选
}
```
响应:直接返回 ZIP 文件流
#### 下载已生成的 ZIP 文件
```http
GET /api/v1/component-report/download-zip/:product_id
```
响应:直接返回 ZIP 文件流
## example.json 格式
生成的 `example.json` 文件格式如下:
```json
[
{
"feature": {
"featureName": "产品名称",
"sort": 1
},
"data": {
"apiID": "产品编号",
"data": {
"code": 0,
"message": "success",
"data": { ... }
}
}
},
{
"feature": {
"featureName": "另一个产品名称",
"sort": 2
},
"data": {
"apiID": "另一个产品编号",
"data": { ... }
}
}
]
```
## 响应示例数据提取优先级
1. **产品文档的 `response_example` 字段**JSON格式
2. **产品文档的 `response_example` 字段**Markdown代码块中的JSON
3. **产品API配置的 `response_example` 字段**
4. **默认空对象** `{}`(如果都没有)
## ZIP 文件结构
生成的 ZIP 文件结构:
```
component-report.zip
└── public/
└── example.json
```
## 注意事项
1. 确保 `storage/component-reports` 目录存在且有写权限
2. 如果产品是组合包,会遍历所有子产品(或指定的子产品)生成响应示例
3. 如果某个子产品没有响应示例数据,会使用空对象 `{}` 作为默认值
4. ZIP 文件会保存在 `storage/component-reports` 目录下,文件名为 `{productID}_example.json.zip`
## 集成到路由
如果需要使用 HTTP 接口,需要在路由中注册:
```go
// 创建处理器
componentReportHandler := component_report.NewComponentReportHandler(
productRepo,
docRepo,
apiConfigRepo,
logger,
)
// 注册路由
router.POST("/api/v1/component-report/generate-example-json", componentReportHandler.GenerateExampleJSON)
router.POST("/api/v1/component-report/generate-zip", componentReportHandler.GenerateZip)
router.POST("/api/v1/component-report/generate-and-download", componentReportHandler.GenerateAndDownloadZip)
router.GET("/api/v1/component-report/download-zip/:product_id", componentReportHandler.DownloadZip)
```

View File

@@ -0,0 +1,286 @@
package component_report
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"go.uber.org/zap"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories"
)
// ExampleJSONGenerator 示例JSON生成器
type ExampleJSONGenerator struct {
productRepo repositories.ProductRepository
docRepo repositories.ProductDocumentationRepository
apiConfigRepo repositories.ProductApiConfigRepository
logger *zap.Logger
}
// NewExampleJSONGenerator 创建示例JSON生成器
func NewExampleJSONGenerator(
productRepo repositories.ProductRepository,
docRepo repositories.ProductDocumentationRepository,
apiConfigRepo repositories.ProductApiConfigRepository,
logger *zap.Logger,
) *ExampleJSONGenerator {
return &ExampleJSONGenerator{
productRepo: productRepo,
docRepo: docRepo,
apiConfigRepo: apiConfigRepo,
logger: logger,
}
}
// ExampleJSONItem example.json 中的单个项
type ExampleJSONItem struct {
Feature struct {
FeatureName string `json:"featureName"`
Sort int `json:"sort"`
} `json:"feature"`
Data struct {
APIID string `json:"apiID"`
Data interface{} `json:"data"`
} `json:"data"`
}
// GenerateExampleJSON 生成 example.json 文件内容
// productID: 产品ID可以是组合包或单品
// subProductCodes: 子产品编号列表(如果为空,则处理所有子产品)
func (g *ExampleJSONGenerator) GenerateExampleJSON(ctx context.Context, productID string, subProductCodes []string) ([]byte, error) {
// 1. 获取产品信息
product, err := g.productRepo.GetByID(ctx, productID)
if err != nil {
return nil, fmt.Errorf("获取产品信息失败: %w", err)
}
// 2. 构建 example.json 数组
var examples []ExampleJSONItem
if product.IsPackage {
// 组合包:遍历子产品
packageItems, err := g.productRepo.GetPackageItems(ctx, productID)
if err != nil {
return nil, fmt.Errorf("获取组合包子产品失败: %w", err)
}
for sort, item := range packageItems {
// 如果指定了子产品编号列表,只处理列表中的产品
if len(subProductCodes) > 0 {
found := false
for _, code := range subProductCodes {
if item.Product != nil && item.Product.Code == code {
found = true
break
}
}
if !found {
continue
}
}
// 获取子产品信息
var subProduct entities.Product
if item.Product != nil {
subProduct = *item.Product
} else {
subProduct, err = g.productRepo.GetByID(ctx, item.ProductID)
if err != nil {
g.logger.Warn("获取子产品信息失败",
zap.String("product_id", item.ProductID),
zap.Error(err),
)
continue
}
}
// 获取响应示例数据
responseData := g.extractResponseExample(ctx, &subProduct)
// 获取产品名称和编号
productName := subProduct.Name
productCode := subProduct.Code
// 构建示例项
example := ExampleJSONItem{
Feature: struct {
FeatureName string `json:"featureName"`
Sort int `json:"sort"`
}{
FeatureName: productName,
Sort: sort + 1,
},
Data: struct {
APIID string `json:"apiID"`
Data interface{} `json:"data"`
}{
APIID: productCode,
Data: responseData,
},
}
examples = append(examples, example)
}
} else {
// 单品
responseData := g.extractResponseExample(ctx, &product)
example := ExampleJSONItem{
Feature: struct {
FeatureName string `json:"featureName"`
Sort int `json:"sort"`
}{
FeatureName: product.Name,
Sort: 1,
},
Data: struct {
APIID string `json:"apiID"`
Data interface{} `json:"data"`
}{
APIID: product.Code,
Data: responseData,
},
}
examples = append(examples, example)
}
// 3. 序列化为JSON
jsonData, err := json.MarshalIndent(examples, "", " ")
if err != nil {
return nil, fmt.Errorf("序列化example.json失败: %w", err)
}
return jsonData, nil
}
// MatchProductCodeToPath 根据产品编码匹配 UI 组件路径返回路径和类型folder/file
func (g *ExampleJSONGenerator) MatchProductCodeToPath(ctx context.Context, productCode string) (string, string, error) {
basePath := filepath.Join("resources", "Pure Component", "src", "ui")
entries, err := os.ReadDir(basePath)
if err != nil {
return "", "", fmt.Errorf("读取组件目录失败: %w", err)
}
for _, entry := range entries {
name := entry.Name()
// 精确匹配
if name == productCode {
path := filepath.Join(basePath, name)
fileType := "folder"
if !entry.IsDir() {
fileType = "file"
}
return path, fileType, nil
}
// 模糊匹配:文件夹名称包含产品编号,或产品编号包含文件夹名称的核心部分
if strings.Contains(name, productCode) || strings.Contains(productCode, extractCoreCode(name)) {
path := filepath.Join(basePath, name)
fileType := "folder"
if !entry.IsDir() {
fileType = "file"
}
return path, fileType, nil
}
}
return "", "", fmt.Errorf("未找到匹配的组件文件: %s", productCode)
}
// extractCoreCode 提取文件名中的核心编码部分
func extractCoreCode(name string) string {
for i, r := range name {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return name[i:]
}
}
return name
}
// extractResponseExample 提取产品响应示例数据(优先级:文档 > API配置 > 默认值)
func (g *ExampleJSONGenerator) extractResponseExample(ctx context.Context, product *entities.Product) interface{} {
var responseData interface{}
// 1. 优先从产品文档中获取
doc, err := g.docRepo.FindByProductID(ctx, product.ID)
if err == nil && doc != nil && doc.ResponseExample != "" {
// 尝试直接解析为JSON
err := json.Unmarshal([]byte(doc.ResponseExample), &responseData)
if err == nil {
g.logger.Debug("从产品文档中提取响应示例成功",
zap.String("product_id", product.ID),
zap.String("product_code", product.Code),
)
return responseData
}
// 如果解析失败尝试从Markdown代码块中提取JSON
extractedData := extractJSONFromMarkdown(doc.ResponseExample)
if extractedData != nil {
g.logger.Debug("从Markdown代码块中提取响应示例成功",
zap.String("product_id", product.ID),
zap.String("product_code", product.Code),
)
return extractedData
}
}
// 2. 如果文档中没有尝试从产品API配置中获取
apiConfig, err := g.apiConfigRepo.FindByProductID(ctx, product.ID)
if err == nil && apiConfig != nil && apiConfig.ResponseExample != "" {
// API配置的响应示例通常是 JSON 字符串
err := json.Unmarshal([]byte(apiConfig.ResponseExample), &responseData)
if err == nil {
g.logger.Debug("从产品API配置中提取响应示例成功",
zap.String("product_id", product.ID),
zap.String("product_code", product.Code),
)
return responseData
}
}
// 3. 如果都没有,返回默认空对象
g.logger.Warn("未找到响应示例数据,使用默认空对象",
zap.String("product_id", product.ID),
zap.String("product_code", product.Code),
)
return map[string]interface{}{}
}
// extractJSONFromMarkdown 从Markdown代码块中提取JSON
func extractJSONFromMarkdown(markdown string) interface{} {
// 查找 ```json 代码块
re := regexp.MustCompile("(?s)```json\\s*(.*?)\\s*```")
matches := re.FindStringSubmatch(markdown)
if len(matches) > 1 {
var jsonData interface{}
err := json.Unmarshal([]byte(matches[1]), &jsonData)
if err == nil {
return jsonData
}
}
// 也尝试查找 ``` 代码块(可能是其他格式)
re2 := regexp.MustCompile("(?s)```\\s*(.*?)\\s*```")
matches2 := re2.FindStringSubmatch(markdown)
if len(matches2) > 1 {
var jsonData interface{}
err := json.Unmarshal([]byte(matches2[1]), &jsonData)
if err == nil {
return jsonData
}
}
// 如果提取失败,返回 nil由调用者决定默认值
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
package component_report
import (
"archive/zip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
)
// ZipGenerator ZIP文件生成器
type ZipGenerator struct {
logger *zap.Logger
}
// NewZipGenerator 创建ZIP文件生成器
func NewZipGenerator(logger *zap.Logger) *ZipGenerator {
return &ZipGenerator{
logger: logger,
}
}
// GenerateZipFile 生成ZIP文件包含 example.json 和匹配的组件文件
// productID: 产品ID
// subProductCodes: 子产品编号列表(如果为空,则处理所有子产品)
// exampleJSONGenerator: 示例JSON生成器
// outputPath: 输出ZIP文件路径如果为空则使用默认路径
func (g *ZipGenerator) GenerateZipFile(
ctx context.Context,
productID string,
subProductCodes []string,
exampleJSONGenerator *ExampleJSONGenerator,
outputPath string,
) (string, error) {
// 1. 生成 example.json 内容
exampleJSON, err := exampleJSONGenerator.GenerateExampleJSON(ctx, productID, subProductCodes)
if err != nil {
return "", fmt.Errorf("生成example.json失败: %w", err)
}
// 2. 确定输出路径
if outputPath == "" {
// 使用默认路径storage/component-reports/{productID}.zip
outputDir := "storage/component-reports"
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("创建输出目录失败: %w", err)
}
outputPath = filepath.Join(outputDir, fmt.Sprintf("%s_example.json.zip", productID))
}
// 3. 创建ZIP文件
zipFile, err := os.Create(outputPath)
if err != nil {
return "", fmt.Errorf("创建ZIP文件失败: %w", err)
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// 4. 添加 example.json 到 public 目录
exampleWriter, err := zipWriter.Create("public/example.json")
if err != nil {
return "", fmt.Errorf("创建example.json文件失败: %w", err)
}
_, err = exampleWriter.Write(exampleJSON)
if err != nil {
return "", fmt.Errorf("写入example.json失败: %w", err)
}
// 5. 添加整个 src 目录,但过滤 ui 目录下的文件
srcBasePath := filepath.Join("resources", "Pure Component", "src")
uiBasePath := filepath.Join(srcBasePath, "ui")
// 收集所有匹配的组件名称(文件夹名或文件名)
matchedNames := make(map[string]bool)
for _, productCode := range subProductCodes {
path, _, err := exampleJSONGenerator.MatchProductCodeToPath(ctx, productCode)
if err == nil && path != "" {
// 获取组件名称(文件夹名或文件名)
componentName := filepath.Base(path)
matchedNames[componentName] = true
}
}
// 遍历整个 src 目录
err = filepath.Walk(srcBasePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 计算相对于 src 的路径
relPath, err := filepath.Rel(srcBasePath, path)
if err != nil {
return err
}
// 转换为ZIP路径格式
zipPath := filepath.ToSlash(filepath.Join("src", relPath))
// 检查是否在 ui 目录下
uiRelPath, err := filepath.Rel(uiBasePath, path)
isInUIDir := err == nil && !strings.HasPrefix(uiRelPath, "..")
if isInUIDir {
// 如果是 ui 目录本身,直接添加
if uiRelPath == "." || uiRelPath == "" {
if info.IsDir() {
_, err = zipWriter.Create(zipPath + "/")
return err
}
return nil
}
// 获取文件/文件夹名称
fileName := info.Name()
// 检查是否应该保留:
// 1. CBehaviorRiskScan.vue 文件(无论在哪里)
// 2. 匹配到的组件文件夹/文件
shouldInclude := false
// 检查是否是 CBehaviorRiskScan.vue
if fileName == "CBehaviorRiskScan.vue" {
shouldInclude = true
} else {
// 检查是否是匹配的组件(检查组件名称)
if matchedNames[fileName] {
shouldInclude = true
} else {
// 检查是否在匹配的组件文件夹内
// 获取相对于 ui 的路径的第一部分(组件文件夹名)
parts := strings.Split(filepath.ToSlash(uiRelPath), "/")
if len(parts) > 0 && parts[0] != "" && parts[0] != "." {
if matchedNames[parts[0]] {
shouldInclude = true
}
}
}
}
if !shouldInclude {
// 跳过不匹配的文件/文件夹
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
// 如果是目录,创建目录项
if info.IsDir() {
_, err = zipWriter.Create(zipPath + "/")
return err
}
// 添加文件
return g.AddFileToZip(zipWriter, path, zipPath)
})
if err != nil {
g.logger.Warn("添加src目录失败", zap.Error(err))
}
g.logger.Info("成功生成ZIP文件",
zap.String("product_id", productID),
zap.String("output_path", outputPath),
zap.Int("example_json_size", len(exampleJSON)),
zap.Int("sub_product_count", len(subProductCodes)),
)
return outputPath, nil
}
// AddFileToZip 添加文件到ZIP
func (g *ZipGenerator) AddFileToZip(zipWriter *zip.Writer, filePath string, zipPath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
writer, err := zipWriter.Create(zipPath)
if err != nil {
return fmt.Errorf("创建ZIP文件项失败: %w", err)
}
_, err = io.Copy(writer, file)
if err != nil {
return fmt.Errorf("复制文件内容失败: %w", err)
}
return nil
}
// AddFolderToZip 递归添加文件夹到ZIP
func (g *ZipGenerator) AddFolderToZip(zipWriter *zip.Writer, folderPath string, basePath string) error {
return filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// 计算相对路径
relPath, err := filepath.Rel(basePath, path)
if err != nil {
return err
}
// 转换为ZIP路径格式使用正斜杠
zipPath := filepath.ToSlash(relPath)
return g.AddFileToZip(zipWriter, path, zipPath)
})
}
// AddFileToZipWithTarget 将单个文件添加到ZIP的指定目标路径
func (g *ZipGenerator) AddFileToZipWithTarget(zipWriter *zip.Writer, filePath string, targetPath string) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
writer, err := zipWriter.Create(filepath.ToSlash(targetPath))
if err != nil {
return fmt.Errorf("创建ZIP文件项失败: %w", err)
}
_, err = io.Copy(writer, file)
if err != nil {
return fmt.Errorf("复制文件内容失败: %w", err)
}
return nil
}
// AddFolderToZipWithPrefix 递归添加文件夹到ZIP并在ZIP内添加路径前缀
func (g *ZipGenerator) AddFolderToZipWithPrefix(zipWriter *zip.Writer, folderPath string, basePath string, prefix string) error {
return filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(basePath, path)
if err != nil {
return err
}
zipPath := filepath.ToSlash(filepath.Join(prefix, relPath))
return g.AddFileToZip(zipWriter, path, zipPath)
})
}