From cc3472ff40e227b75ceb07f682b0503b35912724 Mon Sep 17 00:00:00 2001 From: 18278715334 <18278715334@163.com> Date: Wed, 17 Dec 2025 16:13:13 +0800 Subject: [PATCH] Enhance ProductRoutes to include component report handling and related routes --- docs/产品示例报告下载功能实现方案.md | 1001 +++++++++++++++++ .../entities/component_report_cache.go | 34 + .../entities/component_report_download.go | 85 ++ .../component_report_repository_interface.go | 24 + .../http/routes/product_routes.go | 35 +- 5 files changed, 1171 insertions(+), 8 deletions(-) create mode 100644 docs/产品示例报告下载功能实现方案.md create mode 100644 internal/domains/product/entities/component_report_cache.go create mode 100644 internal/domains/product/entities/component_report_download.go create mode 100644 internal/domains/product/repositories/component_report_repository_interface.go diff --git a/docs/产品示例报告下载功能实现方案.md b/docs/产品示例报告下载功能实现方案.md new file mode 100644 index 0000000..ff36f2f --- /dev/null +++ b/docs/产品示例报告下载功能实现方案.md @@ -0,0 +1,1001 @@ +# 产品示例报告下载功能实现方案 + +## 一、功能概述 + +在产品详情页面添加"下载示例报告"功能,允许用户下载与产品对应的前端组件报告。报告文件位于 `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 + + + 下载示例报告 + +``` + +#### 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. **批量下载**:支持用户选择多个产品批量下载 diff --git a/internal/domains/product/entities/component_report_cache.go b/internal/domains/product/entities/component_report_cache.go new file mode 100644 index 0000000..ad999b3 --- /dev/null +++ b/internal/domains/product/entities/component_report_cache.go @@ -0,0 +1,34 @@ +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 +} + diff --git a/internal/domains/product/entities/component_report_download.go b/internal/domains/product/entities/component_report_download.go new file mode 100644 index 0000000..3894c9c --- /dev/null +++ b/internal/domains/product/entities/component_report_download.go @@ -0,0 +1,85 @@ +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)" 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:"下载有效期"` + + 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:"软删除时间"` +} + +// TableName 指定数据库表名 +func (ComponentReportDownload) TableName() string { + return "component_report_downloads" +} + +// BeforeCreate GORM钩子:创建前自动生成UUID +func (c *ComponentReportDownload) BeforeCreate(tx *gorm.DB) error { + if c.ID == "" { + c.ID = uuid.New().String() + } + return nil +} + +// IsPaymentSuccess 检查是否支付成功 +func (c *ComponentReportDownload) IsPaymentSuccess() bool { + return c.PaymentStatus == "success" +} + +// IsExpired 检查是否已过期 +func (c *ComponentReportDownload) IsExpired() bool { + if c.ExpiresAt == nil { + return false + } + return time.Now().After(*c.ExpiresAt) +} + +// CanDownload 检查是否可以下载 +func (c *ComponentReportDownload) CanDownload() bool { + return c.IsPaymentSuccess() && !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 +} + diff --git a/internal/domains/product/repositories/component_report_repository_interface.go b/internal/domains/product/repositories/component_report_repository_interface.go new file mode 100644 index 0000000..a877b2e --- /dev/null +++ b/internal/domains/product/repositories/component_report_repository_interface.go @@ -0,0 +1,24 @@ +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 + 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 +} + diff --git a/internal/infrastructure/http/routes/product_routes.go b/internal/infrastructure/http/routes/product_routes.go index 6f9f01a..6392ed1 100644 --- a/internal/infrastructure/http/routes/product_routes.go +++ b/internal/infrastructure/http/routes/product_routes.go @@ -10,24 +10,27 @@ import ( // ProductRoutes 产品路由 type ProductRoutes struct { - productHandler *handlers.ProductHandler - auth *middleware.JWTAuthMiddleware - optionalAuth *middleware.OptionalAuthMiddleware - logger *zap.Logger + productHandler *handlers.ProductHandler + componentReportHandler *handlers.ComponentReportHandler + auth *middleware.JWTAuthMiddleware + optionalAuth *middleware.OptionalAuthMiddleware + logger *zap.Logger } // NewProductRoutes 创建产品路由 func NewProductRoutes( productHandler *handlers.ProductHandler, + componentReportHandler *handlers.ComponentReportHandler, auth *middleware.JWTAuthMiddleware, optionalAuth *middleware.OptionalAuthMiddleware, logger *zap.Logger, ) *ProductRoutes { return &ProductRoutes{ - productHandler: productHandler, - auth: auth, - optionalAuth: optionalAuth, - logger: logger, + productHandler: productHandler, + componentReportHandler: componentReportHandler, + auth: auth, + optionalAuth: optionalAuth, + logger: logger, } } @@ -55,6 +58,19 @@ 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) + } } // 分类 - 公开接口 @@ -87,6 +103,9 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) { // 取消订阅 subscriptions.POST("/:id/cancel", r.productHandler.CancelMySubscription) } + + // 我的组件报告下载历史 + my.GET("/component-reports", r.componentReportHandler.GetUserDownloadHistory) } r.logger.Info("产品路由注册完成")