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

@@ -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)
})
}