Files
tyapi-server/docs/产品示例报告下载功能实现方案.md

1002 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 产品示例报告下载功能实现方案
## 一、功能概述
在产品详情页面添加"下载示例报告"功能,允许用户下载与产品对应的前端组件报告。报告文件位于 `resources/Pure Component/src/ui` 目录下通过产品编号product_code匹配对应的文件夹或文件。
## 二、核心需求
### 2.1 基本功能
1. **报告匹配**:根据子产品的 `product_code` 模糊匹配 `resources/Pure Component/src/ui` 下的文件夹或文件
- 支持前缀匹配(如产品编号为 `DWBG6A2C`,文件夹可能是 `DWBG6A2C``多cDWBG6A2C`
- 匹配规则:文件夹名称包含产品编号,或产品编号包含文件夹名称的核心部分
2. **文件打包**:将匹配到的文件夹或文件压缩成 ZIP 格式供用户下载
- ZIP 文件结构:
```text
component-report.zip
├── src/
│ └── ui/
│ ├── DWBG6A2C/ # 子产品1的UI组件
│ │ ├── index.vue
│ │ ├── components/
│ │ └── ...
│ ├── CFLXG0V4B/ # 子产品2的UI组件
│ └── ...
└── public/
└── example.json # 响应示例数据文件
```
- 同时需要生成 `public/example.json` 文件,包含子产品的响应示例数据
- `example.json` 文件格式:
```json
[
{
"feature": {
"featureName": "产品名称",
"sort": 1
},
"data": {
"apiID": "产品编号",
"data": {
// 子产品的响应示例数据JSON对象
"code": 0,
"message": "success",
"data": { ... }
}
}
},
{
"feature": {
"featureName": "另一个产品名称",
"sort": 2
},
"data": {
"apiID": "另一个产品编号",
"data": { ... }
}
}
]
```
3. **支付流程**
- 下载前检查用户已下载过的组件报告
- 根据子产品价格计算报告总价(已下载过的子产品价格需减免)
- 用户扫码支付(微信/支付宝)成功后允许下载
### 2.2 组合包产品特殊处理
1. **子产品筛选**:组合包产品只下载包含的子产品对应的报告
2. **价格减免**:如果用户已下载过某个子产品的报告,该子产品的价格不计入总价
3. **批量下载**:将多个子产品的报告打包成一个 ZIP 文件
### 2.3 性能优化
1. **缓存机制**
- 缓存已生成的 ZIP 文件基于产品ID和子产品列表的哈希值
- 缓存文件匹配结果(产品编号到文件夹路径的映射)
- 缓存有效期24小时
2. **二次下载**
- 用户已支付过的报告支持免费重新下载(在有效期内)
- 记录下载历史,避免重复支付
## 三、数据库设计
### 3.1 报告下载记录表 (component_report_downloads)
```sql
CREATE TABLE component_report_downloads (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
product_id VARCHAR(36) NOT NULL,
product_code VARCHAR(50) NOT NULL,
sub_product_ids TEXT, -- JSON数组存储子产品ID列表组合包使用
sub_product_codes TEXT, -- JSON数组存储子产品编号列表
download_price DECIMAL(10,2) NOT NULL, -- 实际支付价格
original_price DECIMAL(10,2) NOT NULL, -- 原始总价
discount_amount DECIMAL(10,2) DEFAULT 0, -- 减免金额
payment_order_id VARCHAR(64), -- 支付订单号(关联充值记录)
payment_type VARCHAR(20), -- 支付类型alipay, wechat
payment_status VARCHAR(20) DEFAULT 'pending', -- pending, success, failed
file_path VARCHAR(500), -- 生成的ZIP文件路径用于二次下载
file_hash VARCHAR(64), -- 文件哈希值(用于缓存验证)
download_count INT DEFAULT 0, -- 下载次数
last_download_at TIMESTAMP, -- 最后下载时间
expires_at TIMESTAMP, -- 下载有效期支付成功后30天
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
INDEX idx_user_id (user_id),
INDEX idx_product_id (product_id),
INDEX idx_product_code (product_code),
INDEX idx_payment_status (payment_status),
INDEX idx_expires_at (expires_at)
);
```
### 3.2 报告文件匹配缓存表 (component_report_cache)
```sql
CREATE TABLE component_report_cache (
id VARCHAR(36) PRIMARY KEY,
product_code VARCHAR(50) NOT NULL UNIQUE,
matched_path VARCHAR(500) NOT NULL, -- 匹配到的文件夹/文件路径
file_type VARCHAR(20) NOT NULL, -- folder, file
cache_key VARCHAR(64) NOT NULL UNIQUE, -- 缓存键用于ZIP文件缓存
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_product_code (product_code),
INDEX idx_cache_key (cache_key)
);
```
## 四、后端实现方案
### 4.1 领域层 (Domain Layer)
#### 4.1.1 实体定义
**文件位置**: `internal/domains/product/entities/component_report_download.go`
```go
package entities
import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// ComponentReportDownload 组件报告下载记录
type ComponentReportDownload struct {
ID string `gorm:"primaryKey;type:varchar(36)"`
UserID string `gorm:"type:varchar(36);not null;index"`
ProductID string `gorm:"type:varchar(36);not null;index"`
ProductCode string `gorm:"type:varchar(50);not null;index"`
SubProductIDs string `gorm:"type:text"` // JSON数组
SubProductCodes string `gorm:"type:text"` // JSON数组
DownloadPrice decimal.Decimal `gorm:"type:decimal(10,2);not null"`
OriginalPrice decimal.Decimal `gorm:"type:decimal(10,2);not null"`
DiscountAmount decimal.Decimal `gorm:"type:decimal(10,2);default:0"`
PaymentOrderID *string `gorm:"type:varchar(64)"`
PaymentType *string `gorm:"type:varchar(20)"`
PaymentStatus string `gorm:"type:varchar(20);default:'pending';index"`
FilePath *string `gorm:"type:varchar(500)"`
FileHash *string `gorm:"type:varchar(64)"`
DownloadCount int `gorm:"default:0"`
LastDownloadAt *time.Time
ExpiresAt *time.Time `gorm:"index"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
// ComponentReportCache 报告文件匹配缓存
type ComponentReportCache struct {
ID string `gorm:"primaryKey;type:varchar(36)"`
ProductCode string `gorm:"type:varchar(50);not null;uniqueIndex"`
MatchedPath string `gorm:"type:varchar(500);not null"`
FileType string `gorm:"type:varchar(20);not null"`
CacheKey string `gorm:"type:varchar(64);not null;uniqueIndex"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
```
#### 4.1.2 仓储接口
**文件位置**: `internal/domains/product/repositories/component_report_repository_interface.go`
```go
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
}
```
#### 4.1.3 领域服务
**文件位置**: `internal/domains/product/services/component_report_service.go`
```go
package services
import (
"context"
"path/filepath"
"strings"
"os"
"tyapi-server/internal/domains/product/repositories"
)
// ComponentReportService 组件报告服务
type ComponentReportService interface {
// 匹配产品编号到文件路径
MatchProductCodeToPath(ctx context.Context, productCode string) (string, string, error) // 返回路径和类型
// 计算报告价格(考虑已下载的产品)
CalculateReportPrice(ctx context.Context, userID string, productID string, subProductCodes []string) (decimal.Decimal, decimal.Decimal, []string, error) // 返回总价、减免金额、已下载的产品编号列表
// 生成 example.json 文件内容
GenerateExampleJSON(ctx context.Context, productID string, subProductCodes []string) ([]byte, error)
// 生成ZIP文件
GenerateZipFile(ctx context.Context, productID string, subProductCodes []string, cacheKey string) (string, string, error) // 返回文件路径和哈希值
// 验证下载权限
ValidateDownloadPermission(ctx context.Context, userID string, downloadID string) (bool, error)
}
```
### 4.2 应用层 (Application Layer)
**文件位置**: `internal/application/product/component_report_application_service.go`
```go
package application
import (
"context"
"tyapi-server/internal/domains/product/services"
"tyapi-server/internal/domains/finance/services"
)
// ComponentReportApplicationService 组件报告应用服务
type ComponentReportApplicationService interface {
// 1. 获取报告下载信息(价格、已下载列表等)
GetReportDownloadInfo(ctx context.Context, userID string, productID string) (*ReportDownloadInfoResponse, error)
// 2. 创建支付订单
CreateReportPaymentOrder(ctx context.Context, userID string, productID string) (*PaymentOrderResponse, error)
// 3. 处理支付成功回调
HandlePaymentSuccess(ctx context.Context, orderID string) error
// 4. 下载报告文件
DownloadReport(ctx context.Context, userID string, downloadID string) (*DownloadResponse, error)
// 5. 获取用户下载历史
GetUserDownloadHistory(ctx context.Context, userID string, params *PaginationParams) (*DownloadHistoryResponse, error)
}
```
### 4.3 接口层 (Interface Layer)
**文件位置**: `internal/infrastructure/http/handlers/component_report_handler.go`
#### API 接口设计
1. **GET `/api/v1/products/{productId}/component-report/info`**
- 获取报告下载信息(价格、已下载列表等)
- 响应示例:
```json
{
"code": 200,
"data": {
"product_id": "xxx",
"product_code": "DWBG6A2C",
"is_package": true,
"sub_products": [
{
"product_id": "xxx",
"product_code": "DWBG6A2C",
"product_name": "产品名称",
"price": 100.00,
"is_downloaded": false,
"matched": true,
"has_response_example": true
}
],
"original_total_price": 300.00,
"discount_amount": 100.00,
"final_price": 200.00,
"downloaded_product_codes": ["DWBG6A2C"],
"includes_example_json": true
}
}
```
2. **POST `/api/v1/products/{productId}/component-report/create-order`**
- 创建支付订单
- 请求体:
```json
{
"payment_type": "wechat" // 或 "alipay"
}
```
- 响应返回支付二维码URL或支付链接
3. **POST `/api/v1/products/{productId}/component-report/payment-callback`**
- 支付成功回调(内部调用,由支付服务触发)
4. **GET `/api/v1/products/{productId}/component-report/download/{downloadId}`**
- 下载报告文件
- 响应ZIP文件流
5. **GET `/api/v1/my/component-reports`**
- 获取用户下载历史
- 查询参数page, page_size, product_id
## 五、前端实现方案
### 5.1 API 接口定义
**文件位置**: `tyapi-frontend/src/api/index.js`
```javascript
// 组件报告相关接口
export const componentReportApi = {
// 获取报告下载信息
getReportDownloadInfo: (productId) => request.get(`/products/${productId}/component-report/info`),
// 创建支付订单
createReportPaymentOrder: (productId, data) => request.post(`/products/${productId}/component-report/create-order`, data),
// 下载报告
downloadReport: (productId, downloadId) => request.get(`/products/${productId}/component-report/download/${downloadId}`, {
responseType: 'blob'
}),
// 获取用户下载历史
getUserDownloadHistory: (params) => request.get('/my/component-reports', { params })
}
```
### 5.2 页面组件修改
**文件位置**: `tyapi-frontend/src/pages/products/detail.vue`
#### 5.2.1 添加下载按钮
在页面头部操作区域添加"下载示例报告"按钮:
```vue
<el-button
v-if="isSubscribed"
:size="isMobile ? 'small' : 'default'"
type="success"
@click="handleDownloadReport"
:loading="reportDownloading"
>
<el-icon><Download /></el-icon>
下载示例报告
</el-button>
```
#### 5.2.2 下载流程实现
```javascript
// 响应式数据
const reportDownloading = ref(false)
const reportDownloadInfo = ref(null)
const showReportPaymentDialog = ref(false)
const reportPaymentType = ref('wechat') // 或 'alipay'
// 获取报告下载信息
const loadReportDownloadInfo = async () => {
if (!product.value) return
try {
const response = await componentReportApi.getReportDownloadInfo(product.value.id)
reportDownloadInfo.value = response.data
} catch (error) {
console.error('获取报告下载信息失败:', error)
}
}
// 处理下载报告
const handleDownloadReport = async () => {
if (!product.value) return
try {
// 1. 获取下载信息
await loadReportDownloadInfo()
// 2. 如果价格大于0需要支付
if (reportDownloadInfo.value.final_price > 0) {
showReportPaymentDialog.value = true
return
}
// 3. 如果已支付过,直接下载
await downloadReportFile()
} catch (error) {
console.error('下载报告失败:', error)
ElMessage.error('下载报告失败')
}
}
// 创建支付订单并支付
const createReportPayment = async () => {
if (!product.value) return
try {
const response = await componentReportApi.createReportPaymentOrder(
product.value.id,
{ payment_type: reportPaymentType.value }
)
// 显示支付二维码(参考现有的支付流程)
if (reportPaymentType.value === 'wechat') {
await showWechatQrCode(response.data.code_url)
} else {
// 支付宝支付
window.location.href = response.data.pay_url
}
// 开始轮询支付状态
startPaymentPolling(response.data.order_id)
} catch (error) {
console.error('创建支付订单失败:', error)
ElMessage.error('创建支付订单失败')
}
}
// 下载报告文件
const downloadReportFile = async (downloadId) => {
if (!product.value) return
reportDownloading.value = true
try {
// 如果没有downloadId从下载信息中获取
const id = downloadId || reportDownloadInfo.value?.download_id
if (!id) {
ElMessage.warning('请先完成支付')
return
}
const response = await componentReportApi.downloadReport(product.value.id, id)
// 创建下载链接
const blob = new Blob([response.data], { type: 'application/zip' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${product.value.name || '产品'}_示例报告.zip`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('报告下载成功')
// 关闭支付对话框
showReportPaymentDialog.value = false
} catch (error) {
console.error('下载报告文件失败:', error)
ElMessage.error('下载报告文件失败')
} finally {
reportDownloading.value = false
}
}
```
## 六、核心算法实现
### 6.1 响应示例数据提取
在生成 `example.json` 时,需要从产品文档中提取响应示例数据。数据来源优先级:
1. **产品文档的 `response_example` 字段**JSON格式
2. **产品文档的 `response_example` 字段**Markdown代码块中的JSON
3. **产品API配置的 `response_example` 字段**(如果产品文档中没有)
4. **默认空对象** `{}`(如果都没有)
### 6.2 产品编号匹配算法
```go
// MatchProductCodeToPath 匹配产品编号到文件路径
func (s *ComponentReportServiceImpl) MatchProductCodeToPath(ctx context.Context, productCode string) (string, string, error) {
// 1. 先查缓存
cache, err := s.repo.GetCacheByProductCode(ctx, productCode)
if err == nil && cache != nil {
return cache.MatchedPath, cache.FileType, nil
}
// 2. 扫描目录
basePath := "resources/Pure Component/src/ui"
entries, err := os.ReadDir(basePath)
if err != nil {
return "", "", err
}
// 3. 模糊匹配
for _, entry := range entries {
name := entry.Name()
// 精确匹配
if name == productCode {
path := filepath.Join(basePath, name)
fileType := "folder"
if !entry.IsDir() {
fileType = "file"
}
// 保存到缓存
cache := &entities.ComponentReportCache{
ID: uuid.New().String(),
ProductCode: productCode,
MatchedPath: path,
FileType: fileType,
CacheKey: generateCacheKey(productCode),
}
s.repo.CreateCache(ctx, cache)
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"
}
// 保存到缓存
cache := &entities.ComponentReportCache{
ID: uuid.New().String(),
ProductCode: productCode,
MatchedPath: path,
FileType: fileType,
CacheKey: generateCacheKey(productCode),
}
s.repo.CreateCache(ctx, cache)
return path, fileType, nil
}
}
return "", "", fmt.Errorf("未找到匹配的报告文件: %s", productCode)
}
// extractCoreCode 提取文件夹名称的核心代码(去除前缀)
func extractCoreCode(name string) string {
// 如果名称以字母开头,提取后面的代码部分
// 例如多cDWBG6A2C -> DWBG6A2C
for i, r := range name {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return name[i:]
}
}
return name
}
```
### 6.3 价格计算算法
```go
// CalculateReportPrice 计算报告价格
func (s *ComponentReportServiceImpl) CalculateReportPrice(ctx context.Context, userID string, productID string, subProductCodes []string) (decimal.Decimal, decimal.Decimal, []string, error) {
// 1. 获取用户已下载的产品编号列表
downloadedCodes, err := s.repo.GetUserDownloadedProductCodes(ctx, userID)
if err != nil {
return decimal.Zero, decimal.Zero, nil, err
}
// 2. 获取产品信息(包括子产品价格)
product, err := s.productRepo.GetByID(ctx, productID)
if err != nil {
return decimal.Zero, decimal.Zero, nil, err
}
// 3. 计算总价和减免金额
originalTotal := decimal.Zero
discountAmount := decimal.Zero
downloadedList := []string{}
if product.IsPackage {
// 组合包:遍历子产品
for _, item := range product.PackageItems {
if contains(subProductCodes, item.ProductCode) {
originalTotal = originalTotal.Add(item.Price)
// 检查是否已下载
if contains(downloadedCodes, item.ProductCode) {
discountAmount = discountAmount.Add(item.Price)
downloadedList = append(downloadedList, item.ProductCode)
}
}
}
} else {
// 单品
originalTotal = product.Price
if contains(downloadedCodes, product.Code) {
discountAmount = product.Price
downloadedList = append(downloadedList, product.Code)
}
}
finalPrice := originalTotal.Sub(discountAmount)
return finalPrice, discountAmount, downloadedList, nil
}
```
### 6.4 example.json 文件生成算法
```go
// GenerateExampleJSON 生成 example.json 文件内容
func (s *ComponentReportServiceImpl) GenerateExampleJSON(ctx context.Context, productID string, subProductCodes []string) ([]byte, error) {
// 1. 获取产品信息(包括子产品)
product, err := s.productRepo.GetByID(ctx, productID)
if err != nil {
return nil, err
}
// 2. 构建 example.json 数组
var examples []map[string]interface{}
if product.IsPackage {
// 组合包:遍历子产品
for sort, item := range product.PackageItems {
// 只处理在 subProductCodes 列表中的子产品
if !contains(subProductCodes, item.ProductCode) {
continue
}
// 获取子产品的文档信息(包含响应示例)
subProduct, err := s.productRepo.GetByID(ctx, item.ProductID)
if err != nil {
s.logger.Warn("获取子产品信息失败", zap.String("product_id", item.ProductID), zap.Error(err))
continue
}
// 获取响应示例数据(优先级:文档 > API配置 > 默认值)
var responseData interface{}
responseData = s.extractResponseExample(ctx, subProduct)
// 构建示例项
example := map[string]interface{}{
"feature": map[string]interface{}{
"featureName": item.ProductName,
"sort": sort + 1,
},
"data": map[string]interface{}{
"apiID": item.ProductCode,
"data": responseData,
},
}
examples = append(examples, example)
}
} else {
// 单品
responseData := s.extractResponseExample(ctx, product)
example := map[string]interface{}{
"feature": map[string]interface{}{
"featureName": product.Name,
"sort": 1,
},
"data": map[string]interface{}{
"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
}
// extractResponseExample 提取产品响应示例数据(优先级:文档 > API配置 > 默认值)
func (s *ComponentReportServiceImpl) extractResponseExample(ctx context.Context, product *entities.Product) interface{} {
var responseData interface{}
// 1. 优先从产品文档中获取
if product.Documentation != nil && product.Documentation.ResponseExample != "" {
// 尝试直接解析为JSON
err := json.Unmarshal([]byte(product.Documentation.ResponseExample), &responseData)
if err == nil {
return responseData
}
// 如果解析失败尝试从Markdown代码块中提取JSON
responseData = extractJSONFromMarkdown(product.Documentation.ResponseExample)
if responseData != nil {
return responseData
}
}
// 2. 如果文档中没有尝试从产品API配置中获取
apiConfig, err := s.productApiConfigRepo.GetByProductID(ctx, product.ID)
if err == nil && apiConfig != nil && apiConfig.ResponseExample != nil {
// API配置的响应示例通常是 map[string]interface{} 类型
return apiConfig.ResponseExample
}
// 3. 如果都没有,返回默认空对象
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
}
}
// 如果提取失败,返回 nil由调用者决定默认值
return nil
}
```
### 6.5 ZIP文件生成算法
```go
// GenerateZipFile 生成ZIP文件
func (s *ComponentReportServiceImpl) GenerateZipFile(ctx context.Context, productID string, subProductCodes []string, cacheKey string) (string, string, error) {
// 1. 检查缓存文件是否存在
cacheDir := "storage/component-reports"
zipPath := filepath.Join(cacheDir, cacheKey+".zip")
if _, err := os.Stat(zipPath); err == nil {
// 文件已存在,计算哈希值
hash, err := calculateFileHash(zipPath)
if err == nil {
return zipPath, hash, nil
}
}
// 2. 创建ZIP文件
zipFile, err := os.Create(zipPath)
if err != nil {
return "", "", err
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// 3. 遍历子产品添加UI组件文件到ZIP
basePath := "resources/Pure Component/src/ui"
for _, productCode := range subProductCodes {
path, fileType, err := s.MatchProductCodeToPath(ctx, productCode)
if err != nil {
continue // 跳过未找到的文件
}
if fileType == "folder" {
// 递归添加文件夹
err = addFolderToZip(zipWriter, path, basePath)
} else {
// 添加单个文件
err = addFileToZip(zipWriter, path, basePath)
}
if err != nil {
s.logger.Warn("添加文件到ZIP失败", zap.String("path", path), zap.Error(err))
}
}
// 4. 生成并添加 example.json 文件
exampleJSON, err := s.GenerateExampleJSON(ctx, productID, subProductCodes)
if err != nil {
s.logger.Warn("生成example.json失败", zap.Error(err))
// 不阻断流程继续生成ZIP
} else {
// 添加 example.json 到 ZIP 的 public 目录
exampleWriter, err := zipWriter.Create("public/example.json")
if err != nil {
s.logger.Warn("创建example.json文件失败", zap.Error(err))
} else {
_, err = exampleWriter.Write(exampleJSON)
if err != nil {
s.logger.Warn("写入example.json失败", zap.Error(err))
}
}
}
// 5. 添加其他必要的文件(如果需要)
// 例如:复制 public 目录下的其他文件(如果有)
publicBasePath := "resources/Pure Component/public"
publicFiles, err := os.ReadDir(publicBasePath)
if err == nil {
for _, file := range publicFiles {
// 跳过 example.json已经生成
if file.Name() == "example.json" {
continue
}
filePath := filepath.Join(publicBasePath, file.Name())
if !file.IsDir() {
err = addFileToZip(zipWriter, filePath, publicBasePath)
if err != nil {
s.logger.Warn("添加public文件失败", zap.String("file", file.Name()), zap.Error(err))
}
}
}
}
// 6. 计算文件哈希值
hash, err := calculateFileHash(zipPath)
if err != nil {
return "", "", err
}
return zipPath, hash, nil
}
```
## 七、支付流程集成
### 7.1 支付订单创建
在创建报告支付订单时,需要:
1. 创建充值记录RechargeRecord类型为 `component_report`
2. 创建支付订单(微信/支付宝)
3. 创建下载记录ComponentReportDownload状态为 `pending`
### 7.2 支付成功回调
支付成功后:
1. 更新充值记录状态
2. 更新下载记录状态为 `success`
3. 生成ZIP文件包括UI组件文件和 `public/example.json`
4. 更新下载记录的 `file_path` 和 `file_hash`
5. 设置 `expires_at`30天后
### 7.3 二次下载
用户已支付过的报告:
1. 检查下载记录是否存在且未过期
2. 如果ZIP文件存在直接返回
3. 如果ZIP文件不存在重新生成使用相同的cache_key
## 八、性能优化策略
### 8.1 缓存策略
1. **文件匹配缓存**:产品编号到文件路径的映射缓存到数据库
2. **ZIP文件缓存**生成的ZIP文件保存到 `storage/component-reports` 目录
3. **缓存键生成**基于产品ID和子产品编号列表生成唯一缓存键
### 8.2 异步处理
对于大文件或包含多个子产品的组合包:
1. 支付成功后异步生成ZIP文件
2. 生成完成后通知用户(可选:邮件/站内消息)
3. 用户下载时检查文件是否已生成完成
### 8.3 文件清理
定期清理过期的ZIP文件
1. 下载记录过期后expires_at + 7天删除ZIP文件
2. 使用定时任务cron定期执行清理
## 九、错误处理
### 9.1 文件未找到
- 如果某个子产品的报告文件未找到,记录警告日志,但不影响其他文件的打包
- 在响应中返回未找到的文件列表
### 9.4 example.json 生成失败
- 如果某个子产品没有响应示例数据,使用空对象 `{}` 作为默认值
- 如果响应示例解析失败尝试从Markdown代码块中提取JSON
- 如果所有子产品都没有响应示例,生成包含空数据的 `example.json` 文件
- 记录警告日志但不阻断ZIP文件生成流程
### 9.2 支付失败
- 支付失败时,下载记录状态保持为 `pending`
- 用户可以重新发起支付
### 9.3 文件生成失败
- 记录错误日志
- 通知管理员
- 用户可以联系客服处理
## 十、测试要点
1. **匹配算法测试**
- 精确匹配
- 模糊匹配(前缀、后缀)
- 未找到文件的情况
2. **价格计算测试**
- 单品价格计算
- 组合包价格计算
- 已下载产品价格减免
3. **example.json 生成测试**
- 单品 example.json 生成
- 组合包 example.json 生成(多个子产品)
- 响应示例数据提取JSON格式、Markdown代码块格式
- 缺少响应示例时的默认值处理
4. **支付流程测试**
- 创建支付订单
- 支付成功回调
- 支付失败处理
5. **下载功能测试**
- 首次下载
- 二次下载
- 文件过期处理
6. **性能测试**
- 大文件生成时间
- 并发下载处理
- 缓存命中率
## 十一、部署注意事项
1. **目录权限**:确保 `storage/component-reports` 目录有写权限
2. **存储空间**ZIP文件可能较大需要足够的磁盘空间
3. **备份策略**:定期备份下载记录和缓存表
4. **监控告警**:监控文件生成失败、支付回调异常等情况
## 十二、后续优化方向
1. **CDN加速**将ZIP文件上传到CDN加速下载
2. **分片下载**:对于超大文件,支持分片下载
3. **预览功能**:在下载前提供报告预览
4. **版本管理**:支持报告版本更新,用户可选择下载历史版本
5. **批量下载**:支持用户选择多个产品批量下载