Compare commits

...

26 Commits

Author SHA1 Message Date
39c46937ea addui 2025-12-19 17:05:09 +08:00
cc3472ff40 Enhance ProductRoutes to include component report handling and related routes 2025-12-17 16:13:13 +08:00
451d869361 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-16 14:32:42 +08:00
08ea153cac uplogo 2025-12-16 14:32:41 +08:00
6147878dfe Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-14 21:38:49 +08:00
be47a0f045 fix 2025-12-14 21:38:01 +08:00
810696e0f0 fix 2025-12-13 15:09:55 +08:00
17dbaf1ccb fix 2025-12-12 15:47:20 +08:00
18f3d10518 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-12 15:27:17 +08:00
0d4953c6d3 微信支付 2025-12-12 15:27:15 +08:00
3f64600f02 change 2025-12-11 19:32:46 +08:00
2c89b8cb26 fix组合包文档 2025-12-11 11:14:31 +08:00
09d7a4f076 fix_step null false 2025-12-10 17:51:28 +08:00
83d0fd6587 fix身份证最后一个字母提醒 2025-12-10 14:17:33 +08:00
0fd28054f1 fix 2025-12-10 10:32:33 +08:00
ce858983ee 503接口问题 2025-12-09 18:32:29 +08:00
9b2bffae15 移动端弹幕优化 2025-12-09 16:42:37 +08:00
c68ece5bea 企业五要素 2025-12-09 15:12:03 +08:00
398d2cee74 企业五要素 2025-12-09 14:42:32 +08:00
b6c8d93af5 1 2025-12-09 10:48:13 +08:00
b423aa6be8 2 2025-12-09 10:25:26 +08:00
a47c306c87 18278715334@163.com 2025-12-08 14:03:14 +08:00
af88bcc8eb 18278715334@163.com 2025-12-06 15:46:46 +08:00
89367fb2ee 18278715334@163.com 2025-12-06 13:56:00 +08:00
05b6623e75 18278715334@163.com 2025-12-05 14:59:23 +08:00
bfedec249f 18278715334@163.com 2025-12-04 18:10:14 +08:00
396 changed files with 95387 additions and 529 deletions

View File

@@ -20,6 +20,7 @@ import (
const (
TaskTypeArticlePublish = "article:publish"
TaskTypeAnnouncementPublish = "announcement_publish"
)
func main() {
@@ -78,6 +79,9 @@ func main() {
mux.HandleFunc(TaskTypeArticlePublish, func(ctx context.Context, t *asynq.Task) error {
return handleArticlePublish(ctx, t, db, logger)
})
mux.HandleFunc(TaskTypeAnnouncementPublish, func(ctx context.Context, t *asynq.Task) error {
return handleAnnouncementPublish(ctx, t, db, logger)
})
// 启动 Worker
go func() {
@@ -135,3 +139,55 @@ func handleArticlePublish(ctx context.Context, t *asynq.Task, db *gorm.DB, logge
logger.Info("定时发布文章成功", zap.String("article_id", articleID))
return nil
}
// handleAnnouncementPublish 处理公告定时发布任务
func handleAnnouncementPublish(ctx context.Context, t *asynq.Task, db *gorm.DB, logger *zap.Logger) error {
var payload map[string]interface{}
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
logger.Error("解析任务载荷失败", zap.Error(err))
return fmt.Errorf("解析任务载荷失败: %w", err)
}
announcementID, ok := payload["announcement_id"].(string)
if !ok {
logger.Error("任务载荷中缺少公告ID")
return fmt.Errorf("任务载荷中缺少公告ID")
}
// 获取公告
var announcement entities.Announcement
if err := db.WithContext(ctx).First(&announcement, "id = ?", announcementID).Error; err != nil {
logger.Error("获取公告失败", zap.String("announcement_id", announcementID), zap.Error(err))
return fmt.Errorf("获取公告失败: %w", err)
}
// 检查是否已取消定时发布
if !announcement.IsScheduled() {
logger.Info("公告定时发布已取消,跳过执行",
zap.String("announcement_id", announcementID),
zap.String("status", string(announcement.Status)))
return nil // 静默返回,不报错
}
// 检查定时发布时间是否匹配
if announcement.ScheduledAt == nil {
logger.Info("公告没有定时发布时间,跳过执行",
zap.String("announcement_id", announcementID))
return nil
}
// 发布公告
if err := announcement.Publish(); err != nil {
logger.Error("发布公告失败", zap.String("announcement_id", announcementID), zap.Error(err))
return fmt.Errorf("发布公告失败: %w", err)
}
// 保存更新
if err := db.WithContext(ctx).Save(&announcement).Error; err != nil {
logger.Error("保存公告失败", zap.String("announcement_id", announcementID), zap.Error(err))
return fmt.Errorf("保存公告失败: %w", err)
}
logger.Info("定时发布公告成功", zap.String("announcement_id", announcementID))
return nil
}

View File

@@ -362,6 +362,28 @@ alipay:
notify_url: "https://console.tianyuanapi.com/api/v1/finance/alipay/callback"
return_url: "https://console.tianyuanapi.com/api/v1/finance/alipay/return"
# ===========================================
# 💰 微信支付配置
# ===========================================
Wxpay:
app_id: "wxa581992dc74d860e"
mch_id: "1683589176"
mch_certificate_serial_number: "1F4E8B3C39C60035D4CC154F276D03D9CC2C603D"
mch_apiv3_key: "TY8X9nP2qR5tY7uW3zA6bC4dE1flgGJ0"
mch_private_key_path: "resources/etc/wxetc_cert/apiclient_key.pem"
mch_public_key_id: "PUB_KEY_ID_0116835891762025062600211574000800"
mch_public_key_path: "resources/etc/wxetc_cert/pub_key.pem"
notify_url: "https://console.tianyuanapi.com/api/v1/pay/wechat/callback"
refund_notify_url: "https://console.tianyuanapi.com/api/v1/wechat/refund_callback"
# 微信小程序配置
WechatMini:
app_id: "wxa581992dc74d860e"
# 微信H5配置
WechatH5:
app_id: "wxa581992dc74d860e"
# ===========================================
# 🔍 天眼查配置
# ===========================================

View File

@@ -81,6 +81,27 @@ alipay:
return_url: "http://127.0.0.1:8080/api/v1/finance/alipay/return"
# ===========================================
# 💰 微信支付配置
# ===========================================
Wxpay:
app_id: "wxa581992dc74d860e"
mch_id: "1683589176"
mch_certificate_serial_number: "1F4E8B3C39C60035D4CC154F276D03D9CC2C603D"
mch_apiv3_key: "TY8X9nP2qR5tY7uW3zA6bC4dE1flgGJ0"
mch_private_key_path: "resources/etc/wxetc_cert/apiclient_key.pem"
mch_public_key_id: "PUB_KEY_ID_0116835891762025062600211574000800"
mch_public_key_path: "resources/etc/wxetc_cert/pub_key.pem"
notify_url: "https://bx89915628g.vicp.fun/api/v1/pay/wechat/callback"
refund_notify_url: "https://bx89915628g.vicp.fun/api/v1/wechat/refund_callback"
# 微信小程序配置
WechatMini:
app_id: "wxa581992dc74d860e"
# 微信H5配置
WechatH5:
app_id: "wxa581992dc74d860e"
# ===========================================
# 💰 钱包配置
# ===========================================
wallet:
@@ -114,6 +135,14 @@ development:
cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
# ===========================================
# 🚦 开发环境全局限流(放宽或近似关闭)
# ===========================================
ratelimit:
requests: 1000000 # 每窗口允许的请求数,足够大,相当于关闭
window: 1s # 时间窗口
burst: 1000000 # 令牌桶突发容量
# ===========================================
# 🚀 开发环境频率限制配置(放宽限制)
# ===========================================

View File

@@ -0,0 +1,210 @@
# PDF接口文档下载缓存优化说明
## 📋 概述
本次优化为PDF接口文档下载功能添加了本地文件缓存机制显著提升了下载性能减少了重复生成PDF的开销。
## 🔍 问题分析
### 原有问题
1. **性能问题**
- 每次请求都重新生成PDF没有缓存机制
- PDF生成涉及复杂的字体加载、页面构建、表格渲染等操作耗时较长
- 同一产品的PDF被多次下载时会重复执行相同的生成过程
2. **资源浪费**
- CPU资源浪费在重复的PDF生成上
- 数据库查询重复执行
- 没有版本控制,即使产品文档没有变化,也会重新生成
## ✅ 解决方案
### 1. PDF缓存管理器 (`PDFCacheManager`)
创建了专门的PDF缓存管理器提供以下功能
- **本地文件缓存**将生成的PDF文件保存到本地文件系统
- **版本控制**基于产品ID和文档版本号生成缓存键确保版本更新时自动失效
- **自动过期**支持TTLTime To Live机制自动清理过期缓存
- **大小限制**:支持最大缓存大小限制,防止磁盘空间耗尽
- **定期清理**:后台任务每小时自动清理过期文件
### 2. 缓存键生成策略
```go
// 基于产品ID和文档版本号生成唯一的缓存键
cacheKey = MD5(productID + ":" + version)
```
- 当产品文档版本更新时,自动生成新的缓存
- 旧版本的缓存会在过期后自动清理
### 3. 缓存流程
```
请求下载PDF
检查缓存是否存在且有效
├─ 缓存命中 → 直接返回缓存的PDF文件
└─ 缓存未命中 → 生成PDF → 保存到缓存 → 返回PDF
```
### 4. 集成到下载接口
修改了 `DownloadProductDocumentation` 方法:
- **缓存优先**首先尝试从缓存获取PDF
- **异步保存**生成新PDF后异步保存到缓存不阻塞响应
- **缓存标识**:响应头中添加 `X-Cache: HIT/MISS` 标识,便于监控
## 🚀 性能提升
### 预期效果
1. **首次下载**与之前相同需要生成PDF约1-3秒
2. **后续下载**:直接从缓存读取(< 100ms性能提升 **10-30倍**
3. **缓存命中率**对于热门产品缓存命中率可达 **80-90%**
### 响应时间对比
| 场景 | 优化前 | 优化后 | 提升 |
|------|--------|--------|------|
| 首次下载 | 1-3秒 | 1-3秒 | - |
| 缓存命中 | 1-3秒 | < 100ms | **10-30倍** |
| 版本更新后首次 | 1-3秒 | 1-3秒 | - |
## ⚙️ 配置说明
### 环境变量配置
可以通过环境变量自定义缓存配置
```bash
# 缓存目录默认系统临时目录下的tyapi_pdf_cache
export PDF_CACHE_DIR="/path/to/cache"
# 缓存过期时间默认24小时
export PDF_CACHE_TTL="24h"
# 最大缓存大小默认500MB
export PDF_CACHE_MAX_SIZE="524288000" # 字节
```
### 默认配置
- **缓存目录**系统临时目录下的 `tyapi_pdf_cache`
- **TTL**24小时
- **最大缓存大小**500MB
## 📁 文件结构
```
tyapi-server/
├── internal/
│ └── shared/
│ └── pdf/
│ ├── pdf_cache_manager.go # 新增PDF缓存管理器
│ ├── pdf_generator.go # 原有PDF生成器
│ └── ...
├── internal/
│ └── infrastructure/
│ └── http/
│ └── handlers/
│ └── product_handler.go # 修改:集成缓存机制
└── internal/
└── container/
└── container.go # 修改:初始化缓存管理器
```
## 🔧 使用示例
### 基本使用
缓存机制已自动集成无需额外代码
```go
// 用户请求下载PDF
GET /api/v1/products/{id}/documentation/download
// 系统自动:
// 1. 检查缓存
// 2. 缓存命中 → 直接返回
// 3. 缓存未命中 → 生成PDF → 保存缓存 → 返回
```
### 手动管理缓存
如果需要手动管理缓存如产品更新后清除缓存
```go
// 使特定产品的缓存失效
cacheManager.InvalidateByProductID(productID)
// 使特定版本的缓存失效
cacheManager.Invalidate(productID, version)
// 清空所有缓存
cacheManager.Clear()
// 获取缓存统计信息
stats, _ := cacheManager.GetCacheStats()
```
## 📊 监控和日志
### 日志输出
系统会记录以下日志
- **缓存命中**`PDF缓存命中` - 包含产品ID版本文件大小
- **缓存未命中**`PDF缓存未命中开始生成PDF`
- **缓存保存**`PDF已缓存` - 包含产品ID缓存键文件大小
- **缓存清理**`已清理过期缓存文件` - 包含清理数量和释放空间
### 响应头标识
响应头中添加了缓存标识
- `X-Cache: HIT` - 缓存命中
- `X-Cache: MISS` - 缓存未命中
## 🔒 安全考虑
1. **文件权限**缓存文件权限设置为 `0644`仅所有者可写
2. **目录隔离**缓存文件存储在独立目录不影响其他文件
3. **自动清理**过期文件自动清理防止磁盘空间耗尽
## 🐛 故障处理
### 缓存初始化失败
如果缓存管理器初始化失败系统会
- 记录警告日志
- 继续正常运行禁用缓存功能
- 所有请求都会重新生成PDF
### 缓存读取失败
如果缓存读取失败系统会
- 记录警告日志
- 自动降级为重新生成PDF
- 不影响用户体验
## 🔄 后续优化建议
1. **分布式缓存**考虑使用Redis等分布式缓存支持多实例部署
2. **缓存预热**在系统启动时预生成热门产品的PDF
3. **压缩存储**对PDF文件进行压缩存储节省磁盘空间
4. **缓存统计**添加更详细的缓存统计和监控指标
5. **智能清理**基于LRU等算法优先清理不常用的缓存
## 📝 更新日志
- **2024-12-XX**初始版本实现本地文件缓存机制
- 添加PDF缓存管理器
- 集成到下载接口
- 支持版本控制和自动过期

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@@ -18,6 +18,7 @@ require (
github.com/redis/go-redis/v9 v9.11.0
github.com/robfig/cron/v3 v3.0.1
github.com/shopspring/decimal v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartwalle/alipay/v3 v3.2.25
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
@@ -25,6 +26,7 @@ require (
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
github.com/tidwall/gjson v1.18.0
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/xuri/excelize/v2 v2.9.1
go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0

6
go.sum
View File

@@ -9,6 +9,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
@@ -208,6 +210,8 @@ github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsF
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartwalle/alipay/v3 v3.2.25 h1:cRDN+fpDWTVHnuHIF/vsJETskRXS/S+fDOdAkzXmV/Q=
github.com/smartwalle/alipay/v3 v3.2.25/go.mod h1:lVqFiupPf8YsAXaq5JXcwqnOUC2MCF+2/5vub+RlagE=
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
@@ -262,6 +266,8 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=

View File

@@ -239,12 +239,17 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
&productEntities.Subscription{},
&productEntities.ProductDocumentation{},
&productEntities.ProductApiConfig{},
&productEntities.ComponentReportDownload{},
&productEntities.UIComponent{},
&productEntities.ProductUIComponent{},
// 文章域
&articleEntities.Article{},
&articleEntities.Category{},
&articleEntities.Tag{},
&articleEntities.ScheduledTask{},
// 公告
&articleEntities.Announcement{},
// 统计域
&statisticsEntities.StatisticsMetric{},
@@ -279,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

@@ -0,0 +1,30 @@
package article
import (
"context"
"tyapi-server/internal/application/article/dto/commands"
appQueries "tyapi-server/internal/application/article/dto/queries"
"tyapi-server/internal/application/article/dto/responses"
)
// AnnouncementApplicationService 公告应用服务接口
type AnnouncementApplicationService interface {
// 公告管理
CreateAnnouncement(ctx context.Context, cmd *commands.CreateAnnouncementCommand) error
UpdateAnnouncement(ctx context.Context, cmd *commands.UpdateAnnouncementCommand) error
DeleteAnnouncement(ctx context.Context, cmd *commands.DeleteAnnouncementCommand) error
GetAnnouncementByID(ctx context.Context, query *appQueries.GetAnnouncementQuery) (*responses.AnnouncementInfoResponse, error)
ListAnnouncements(ctx context.Context, query *appQueries.ListAnnouncementQuery) (*responses.AnnouncementListResponse, error)
// 公告状态管理
PublishAnnouncement(ctx context.Context, cmd *commands.PublishAnnouncementCommand) error
PublishAnnouncementByID(ctx context.Context, announcementID string) error // 通过ID发布公告 (用于定时任务)
WithdrawAnnouncement(ctx context.Context, cmd *commands.WithdrawAnnouncementCommand) error
ArchiveAnnouncement(ctx context.Context, cmd *commands.ArchiveAnnouncementCommand) error
SchedulePublishAnnouncement(ctx context.Context, cmd *commands.SchedulePublishAnnouncementCommand) error
UpdateSchedulePublishAnnouncement(ctx context.Context, cmd *commands.UpdateSchedulePublishAnnouncementCommand) error
CancelSchedulePublishAnnouncement(ctx context.Context, cmd *commands.CancelSchedulePublishAnnouncementCommand) error
// 统计信息
GetAnnouncementStats(ctx context.Context) (*responses.AnnouncementStatsResponse, error)
}

View File

@@ -0,0 +1,484 @@
package article
import (
"context"
"fmt"
"tyapi-server/internal/application/article/dto/commands"
appQueries "tyapi-server/internal/application/article/dto/queries"
"tyapi-server/internal/application/article/dto/responses"
"tyapi-server/internal/domains/article/entities"
"tyapi-server/internal/domains/article/repositories"
repoQueries "tyapi-server/internal/domains/article/repositories/queries"
"tyapi-server/internal/domains/article/services"
task_entities "tyapi-server/internal/infrastructure/task/entities"
task_interfaces "tyapi-server/internal/infrastructure/task/interfaces"
"go.uber.org/zap"
)
// AnnouncementApplicationServiceImpl 公告应用服务实现
type AnnouncementApplicationServiceImpl struct {
announcementRepo repositories.AnnouncementRepository
announcementService *services.AnnouncementService
taskManager task_interfaces.TaskManager
logger *zap.Logger
}
// NewAnnouncementApplicationService 创建公告应用服务
func NewAnnouncementApplicationService(
announcementRepo repositories.AnnouncementRepository,
announcementService *services.AnnouncementService,
taskManager task_interfaces.TaskManager,
logger *zap.Logger,
) AnnouncementApplicationService {
return &AnnouncementApplicationServiceImpl{
announcementRepo: announcementRepo,
announcementService: announcementService,
taskManager: taskManager,
logger: logger,
}
}
// CreateAnnouncement 创建公告
func (s *AnnouncementApplicationServiceImpl) CreateAnnouncement(ctx context.Context, cmd *commands.CreateAnnouncementCommand) error {
// 1. 创建公告实体
announcement := &entities.Announcement{
Title: cmd.Title,
Content: cmd.Content,
Status: entities.AnnouncementStatusDraft,
}
// 2. 调用领域服务验证
if err := s.announcementService.ValidateAnnouncement(announcement); err != nil {
return fmt.Errorf("业务验证失败: %w", err)
}
// 3. 保存公告
_, err := s.announcementRepo.Create(ctx, *announcement)
if err != nil {
s.logger.Error("创建公告失败", zap.Error(err))
return fmt.Errorf("创建公告失败: %w", err)
}
s.logger.Info("创建公告成功", zap.String("id", announcement.ID), zap.String("title", announcement.Title))
return nil
}
// UpdateAnnouncement 更新公告
func (s *AnnouncementApplicationServiceImpl) UpdateAnnouncement(ctx context.Context, cmd *commands.UpdateAnnouncementCommand) error {
// 1. 获取原公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 检查是否可以编辑
if err := s.announcementService.CanEdit(&announcement); err != nil {
return fmt.Errorf("公告状态不允许编辑: %w", err)
}
// 3. 更新字段
if cmd.Title != "" {
announcement.Title = cmd.Title
}
if cmd.Content != "" {
announcement.Content = cmd.Content
}
// 4. 验证更新后的公告
if err := s.announcementService.ValidateAnnouncement(&announcement); err != nil {
return fmt.Errorf("业务验证失败: %w", err)
}
// 5. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("更新公告失败: %w", err)
}
s.logger.Info("更新公告成功", zap.String("id", announcement.ID))
return nil
}
// DeleteAnnouncement 删除公告
func (s *AnnouncementApplicationServiceImpl) DeleteAnnouncement(ctx context.Context, cmd *commands.DeleteAnnouncementCommand) error {
// 1. 检查公告是否存在
_, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 删除公告
if err := s.announcementRepo.Delete(ctx, cmd.ID); err != nil {
s.logger.Error("删除公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("删除公告失败: %w", err)
}
s.logger.Info("删除公告成功", zap.String("id", cmd.ID))
return nil
}
// GetAnnouncementByID 获取公告详情
func (s *AnnouncementApplicationServiceImpl) GetAnnouncementByID(ctx context.Context, query *appQueries.GetAnnouncementQuery) (*responses.AnnouncementInfoResponse, error) {
// 1. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, query.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", query.ID), zap.Error(err))
return nil, fmt.Errorf("公告不存在: %w", err)
}
// 2. 转换为响应对象
response := responses.FromAnnouncementEntity(&announcement)
return response, nil
}
// ListAnnouncements 获取公告列表
func (s *AnnouncementApplicationServiceImpl) ListAnnouncements(ctx context.Context, query *appQueries.ListAnnouncementQuery) (*responses.AnnouncementListResponse, error) {
// 1. 构建仓储查询
repoQuery := &repoQueries.ListAnnouncementQuery{
Page: query.Page,
PageSize: query.PageSize,
Status: query.Status,
Title: query.Title,
OrderBy: query.OrderBy,
OrderDir: query.OrderDir,
}
// 2. 调用仓储
announcements, total, err := s.announcementRepo.ListAnnouncements(ctx, repoQuery)
if err != nil {
s.logger.Error("获取公告列表失败", zap.Error(err))
return nil, fmt.Errorf("获取公告列表失败: %w", err)
}
// 3. 转换为响应对象
items := responses.FromAnnouncementEntityList(announcements)
response := &responses.AnnouncementListResponse{
Total: total,
Page: query.Page,
Size: query.PageSize,
Items: items,
}
s.logger.Info("获取公告列表成功", zap.Int64("total", total))
return response, nil
}
// PublishAnnouncement 发布公告
func (s *AnnouncementApplicationServiceImpl) PublishAnnouncement(ctx context.Context, cmd *commands.PublishAnnouncementCommand) error {
// 1. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 检查是否可以发布
if err := s.announcementService.CanPublish(&announcement); err != nil {
return fmt.Errorf("无法发布公告: %w", err)
}
// 3. 发布公告
if err := announcement.Publish(); err != nil {
return fmt.Errorf("发布公告失败: %w", err)
}
// 4. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("发布公告失败: %w", err)
}
s.logger.Info("发布公告成功", zap.String("id", announcement.ID))
return nil
}
// PublishAnnouncementByID 通过ID发布公告 (用于定时任务)
func (s *AnnouncementApplicationServiceImpl) PublishAnnouncementByID(ctx context.Context, announcementID string) error {
// 1. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, announcementID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", announcementID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 检查是否已取消定时发布
if !announcement.IsScheduled() {
s.logger.Info("公告定时发布已取消,跳过执行",
zap.String("id", announcementID),
zap.String("status", string(announcement.Status)))
return nil // 静默返回,不报错
}
// 3. 检查定时发布时间是否匹配
if announcement.ScheduledAt == nil {
s.logger.Info("公告没有定时发布时间,跳过执行",
zap.String("id", announcementID))
return nil
}
// 4. 发布公告
if err := announcement.Publish(); err != nil {
return fmt.Errorf("发布公告失败: %w", err)
}
// 5. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("发布公告失败: %w", err)
}
s.logger.Info("定时发布公告成功", zap.String("id", announcement.ID))
return nil
}
// WithdrawAnnouncement 撤回公告
func (s *AnnouncementApplicationServiceImpl) WithdrawAnnouncement(ctx context.Context, cmd *commands.WithdrawAnnouncementCommand) error {
// 1. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 检查是否可以撤回
if err := s.announcementService.CanWithdraw(&announcement); err != nil {
return fmt.Errorf("无法撤回公告: %w", err)
}
// 3. 撤回公告
if err := announcement.Withdraw(); err != nil {
return fmt.Errorf("撤回公告失败: %w", err)
}
// 4. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("撤回公告失败: %w", err)
}
s.logger.Info("撤回公告成功", zap.String("id", announcement.ID))
return nil
}
// ArchiveAnnouncement 归档公告
func (s *AnnouncementApplicationServiceImpl) ArchiveAnnouncement(ctx context.Context, cmd *commands.ArchiveAnnouncementCommand) error {
// 1. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 检查是否可以归档
if err := s.announcementService.CanArchive(&announcement); err != nil {
return fmt.Errorf("无法归档公告: %w", err)
}
// 3. 归档公告
announcement.Status = entities.AnnouncementStatusArchived
// 4. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("归档公告失败: %w", err)
}
s.logger.Info("归档公告成功", zap.String("id", announcement.ID))
return nil
}
// SchedulePublishAnnouncement 定时发布公告
func (s *AnnouncementApplicationServiceImpl) SchedulePublishAnnouncement(ctx context.Context, cmd *commands.SchedulePublishAnnouncementCommand) error {
// 1. 解析定时发布时间
scheduledTime, err := cmd.GetScheduledTime()
if err != nil {
s.logger.Error("解析定时发布时间失败", zap.String("scheduled_time", cmd.ScheduledTime), zap.Error(err))
return fmt.Errorf("定时发布时间格式错误: %w", err)
}
// 2. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 3. 检查是否可以定时发布
if err := s.announcementService.CanSchedulePublish(&announcement, scheduledTime); err != nil {
return fmt.Errorf("无法设置定时发布: %w", err)
}
// 4. 取消旧任务(如果存在)
if err := s.taskManager.CancelTask(ctx, cmd.ID); err != nil {
s.logger.Warn("取消旧任务失败", zap.String("announcement_id", cmd.ID), zap.Error(err))
}
// 5. 创建任务工厂
taskFactory := task_entities.NewTaskFactoryWithManager(s.taskManager)
// 6. 创建并异步入队公告发布任务
if err := taskFactory.CreateAndEnqueueAnnouncementPublishTask(
ctx,
cmd.ID,
scheduledTime,
"system", // 暂时使用系统用户ID
); err != nil {
s.logger.Error("创建并入队公告发布任务失败", zap.Error(err))
return fmt.Errorf("创建定时发布任务失败: %w", err)
}
// 7. 设置定时发布
if err := announcement.SchedulePublish(scheduledTime); err != nil {
return fmt.Errorf("设置定时发布失败: %w", err)
}
// 8. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("设置定时发布失败: %w", err)
}
s.logger.Info("设置定时发布成功", zap.String("id", announcement.ID), zap.Time("scheduled_at", scheduledTime))
return nil
}
// UpdateSchedulePublishAnnouncement 更新定时发布公告
func (s *AnnouncementApplicationServiceImpl) UpdateSchedulePublishAnnouncement(ctx context.Context, cmd *commands.UpdateSchedulePublishAnnouncementCommand) error {
// 1. 解析定时发布时间
scheduledTime, err := cmd.GetScheduledTime()
if err != nil {
s.logger.Error("解析定时发布时间失败", zap.String("scheduled_time", cmd.ScheduledTime), zap.Error(err))
return fmt.Errorf("定时发布时间格式错误: %w", err)
}
// 2. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 3. 检查是否已设置定时发布
if !announcement.IsScheduled() {
return fmt.Errorf("公告未设置定时发布,无法修改时间")
}
// 4. 取消旧任务
if err := s.taskManager.CancelTask(ctx, cmd.ID); err != nil {
s.logger.Warn("取消旧任务失败", zap.String("announcement_id", cmd.ID), zap.Error(err))
}
// 5. 创建任务工厂
taskFactory := task_entities.NewTaskFactoryWithManager(s.taskManager)
// 6. 创建并异步入队新的公告发布任务
if err := taskFactory.CreateAndEnqueueAnnouncementPublishTask(
ctx,
cmd.ID,
scheduledTime,
"system", // 暂时使用系统用户ID
); err != nil {
s.logger.Error("创建并入队公告发布任务失败", zap.Error(err))
return fmt.Errorf("创建定时发布任务失败: %w", err)
}
// 7. 更新定时发布时间
if err := announcement.UpdateSchedulePublish(scheduledTime); err != nil {
return fmt.Errorf("更新定时发布时间失败: %w", err)
}
// 8. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("修改定时发布时间失败: %w", err)
}
s.logger.Info("修改定时发布时间成功", zap.String("id", announcement.ID), zap.Time("scheduled_at", scheduledTime))
return nil
}
// CancelSchedulePublishAnnouncement 取消定时发布公告
func (s *AnnouncementApplicationServiceImpl) CancelSchedulePublishAnnouncement(ctx context.Context, cmd *commands.CancelSchedulePublishAnnouncementCommand) error {
// 1. 获取公告
announcement, err := s.announcementRepo.GetByID(ctx, cmd.ID)
if err != nil {
s.logger.Error("获取公告失败", zap.String("id", cmd.ID), zap.Error(err))
return fmt.Errorf("公告不存在: %w", err)
}
// 2. 检查是否已设置定时发布
if !announcement.IsScheduled() {
return fmt.Errorf("公告未设置定时发布,无需取消")
}
// 3. 取消任务
if err := s.taskManager.CancelTask(ctx, cmd.ID); err != nil {
s.logger.Warn("取消任务失败", zap.String("announcement_id", cmd.ID), zap.Error(err))
// 继续执行,即使取消任务失败也尝试取消定时发布状态
}
// 4. 取消定时发布
if err := announcement.CancelSchedulePublish(); err != nil {
return fmt.Errorf("取消定时发布失败: %w", err)
}
// 5. 保存更新
if err := s.announcementRepo.Update(ctx, announcement); err != nil {
s.logger.Error("更新公告失败", zap.String("id", announcement.ID), zap.Error(err))
return fmt.Errorf("取消定时发布失败: %w", err)
}
s.logger.Info("取消定时发布成功", zap.String("id", announcement.ID))
return nil
}
// GetAnnouncementStats 获取公告统计信息
func (s *AnnouncementApplicationServiceImpl) GetAnnouncementStats(ctx context.Context) (*responses.AnnouncementStatsResponse, error) {
// 1. 统计总数
total, err := s.announcementRepo.CountByStatus(ctx, entities.AnnouncementStatusDraft)
if err != nil {
s.logger.Error("统计公告总数失败", zap.Error(err))
return nil, fmt.Errorf("获取统计信息失败: %w", err)
}
// 2. 统计各状态数量
published, err := s.announcementRepo.CountByStatus(ctx, entities.AnnouncementStatusPublished)
if err != nil {
s.logger.Error("统计已发布公告数失败", zap.Error(err))
return nil, fmt.Errorf("获取统计信息失败: %w", err)
}
draft, err := s.announcementRepo.CountByStatus(ctx, entities.AnnouncementStatusDraft)
if err != nil {
s.logger.Error("统计草稿公告数失败", zap.Error(err))
return nil, fmt.Errorf("获取统计信息失败: %w", err)
}
archived, err := s.announcementRepo.CountByStatus(ctx, entities.AnnouncementStatusArchived)
if err != nil {
s.logger.Error("统计归档公告数失败", zap.Error(err))
return nil, fmt.Errorf("获取统计信息失败: %w", err)
}
// 3. 统计定时发布数量需要查询有scheduled_at的草稿
scheduled, err := s.announcementRepo.FindScheduled(ctx)
if err != nil {
s.logger.Error("统计定时发布公告数失败", zap.Error(err))
return nil, fmt.Errorf("获取统计信息失败: %w", err)
}
response := &responses.AnnouncementStatsResponse{
TotalAnnouncements: total + published + archived,
PublishedAnnouncements: published,
DraftAnnouncements: draft,
ArchivedAnnouncements: archived,
ScheduledAnnouncements: int64(len(scheduled)),
}
return response, nil
}

View File

@@ -0,0 +1,104 @@
package commands
import (
"fmt"
"time"
)
// CreateAnnouncementCommand 创建公告命令
type CreateAnnouncementCommand struct {
Title string `json:"title" binding:"required" comment:"公告标题"`
Content string `json:"content" binding:"required" comment:"公告内容"`
}
// UpdateAnnouncementCommand 更新公告命令
type UpdateAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
Title string `json:"title" comment:"公告标题"`
Content string `json:"content" comment:"公告内容"`
}
// DeleteAnnouncementCommand 删除公告命令
type DeleteAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
}
// PublishAnnouncementCommand 发布公告命令
type PublishAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
}
// WithdrawAnnouncementCommand 撤回公告命令
type WithdrawAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
}
// ArchiveAnnouncementCommand 归档公告命令
type ArchiveAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
}
// SchedulePublishAnnouncementCommand 定时发布公告命令
type SchedulePublishAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
ScheduledTime string `json:"scheduled_time" binding:"required" comment:"定时发布时间"`
}
// GetScheduledTime 获取解析后的定时发布时间
func (cmd *SchedulePublishAnnouncementCommand) GetScheduledTime() (time.Time, error) {
// 定义中国东八区时区
cst := time.FixedZone("CST", 8*3600)
// 支持多种时间格式
formats := []string{
"2006-01-02 15:04:05", // "2025-09-02 14:12:01"
"2006-01-02T15:04:05", // "2025-09-02T14:12:01"
"2006-01-02T15:04:05Z", // "2025-09-02T14:12:01Z"
"2006-01-02 15:04", // "2025-09-02 14:12"
time.RFC3339, // "2025-09-02T14:12:01+08:00"
}
for _, format := range formats {
if t, err := time.ParseInLocation(format, cmd.ScheduledTime, cst); err == nil {
// 确保返回的时间是东八区时区
return t.In(cst), nil
}
}
return time.Time{}, fmt.Errorf("不支持的时间格式: %s请使用 YYYY-MM-DD HH:mm:ss 格式", cmd.ScheduledTime)
}
// UpdateSchedulePublishAnnouncementCommand 更新定时发布公告命令
type UpdateSchedulePublishAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
ScheduledTime string `json:"scheduled_time" binding:"required" comment:"定时发布时间"`
}
// GetScheduledTime 获取解析后的定时发布时间
func (cmd *UpdateSchedulePublishAnnouncementCommand) GetScheduledTime() (time.Time, error) {
// 定义中国东八区时区
cst := time.FixedZone("CST", 8*3600)
// 支持多种时间格式
formats := []string{
"2006-01-02 15:04:05", // "2025-09-02 14:12:01"
"2006-01-02T15:04:05", // "2025-09-02T14:12:01"
"2006-01-02T15:04:05Z", // "2025-09-02T14:12:01Z"
"2006-01-02 15:04", // "2025-09-02 14:12"
time.RFC3339, // "2025-09-02T14:12:01+08:00"
}
for _, format := range formats {
if t, err := time.ParseInLocation(format, cmd.ScheduledTime, cst); err == nil {
// 确保返回的时间是东八区时区
return t.In(cst), nil
}
}
return time.Time{}, fmt.Errorf("不支持的时间格式: %s请使用 YYYY-MM-DD HH:mm:ss 格式", cmd.ScheduledTime)
}
// CancelSchedulePublishAnnouncementCommand 取消定时发布公告命令
type CancelSchedulePublishAnnouncementCommand struct {
ID string `json:"-" uri:"id" binding:"required" comment:"公告ID"`
}

View File

@@ -0,0 +1,18 @@
package queries
import "tyapi-server/internal/domains/article/entities"
// ListAnnouncementQuery 公告列表查询
type ListAnnouncementQuery struct {
Page int `form:"page" binding:"min=1" comment:"页码"`
PageSize int `form:"page_size" binding:"min=1,max=100" comment:"每页数量"`
Status entities.AnnouncementStatus `form:"status" comment:"公告状态"`
Title string `form:"title" comment:"标题关键词"`
OrderBy string `form:"order_by" comment:"排序字段"`
OrderDir string `form:"order_dir" comment:"排序方向"`
}
// GetAnnouncementQuery 获取公告详情查询
type GetAnnouncementQuery struct {
ID string `uri:"id" binding:"required" comment:"公告ID"`
}

View File

@@ -0,0 +1,79 @@
package responses
import (
"time"
"tyapi-server/internal/domains/article/entities"
)
// AnnouncementInfoResponse 公告详情响应
type AnnouncementInfoResponse struct {
ID string `json:"id" comment:"公告ID"`
Title string `json:"title" comment:"公告标题"`
Content string `json:"content" comment:"公告内容"`
Status string `json:"status" comment:"公告状态"`
ScheduledAt *time.Time `json:"scheduled_at" comment:"定时发布时间"`
CreatedAt time.Time `json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
}
// AnnouncementListItemResponse 公告列表项响应
type AnnouncementListItemResponse struct {
ID string `json:"id" comment:"公告ID"`
Title string `json:"title" comment:"公告标题"`
Content string `json:"content" comment:"公告内容"`
Status string `json:"status" comment:"公告状态"`
ScheduledAt *time.Time `json:"scheduled_at" comment:"定时发布时间"`
CreatedAt time.Time `json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `json:"updated_at" comment:"更新时间"`
}
// AnnouncementListResponse 公告列表响应
type AnnouncementListResponse struct {
Total int64 `json:"total" comment:"总数"`
Page int `json:"page" comment:"页码"`
Size int `json:"size" comment:"每页数量"`
Items []AnnouncementListItemResponse `json:"items" comment:"公告列表"`
}
// AnnouncementStatsResponse 公告统计响应
type AnnouncementStatsResponse struct {
TotalAnnouncements int64 `json:"total_announcements" comment:"公告总数"`
PublishedAnnouncements int64 `json:"published_announcements" comment:"已发布公告数"`
DraftAnnouncements int64 `json:"draft_announcements" comment:"草稿公告数"`
ArchivedAnnouncements int64 `json:"archived_announcements" comment:"归档公告数"`
ScheduledAnnouncements int64 `json:"scheduled_announcements" comment:"定时发布公告数"`
}
// FromAnnouncementEntity 从公告实体转换为响应对象
func FromAnnouncementEntity(announcement *entities.Announcement) *AnnouncementInfoResponse {
if announcement == nil {
return nil
}
return &AnnouncementInfoResponse{
ID: announcement.ID,
Title: announcement.Title,
Content: announcement.Content,
Status: string(announcement.Status),
ScheduledAt: announcement.ScheduledAt,
CreatedAt: announcement.CreatedAt,
UpdatedAt: announcement.UpdatedAt,
}
}
// FromAnnouncementEntityList 从公告实体列表转换为列表项响应
func FromAnnouncementEntityList(announcements []*entities.Announcement) []AnnouncementListItemResponse {
items := make([]AnnouncementListItemResponse, 0, len(announcements))
for _, announcement := range announcements {
items = append(items, AnnouncementListItemResponse{
ID: announcement.ID,
Title: announcement.Title,
Content: announcement.Content,
Status: string(announcement.Status),
ScheduledAt: announcement.ScheduledAt,
CreatedAt: announcement.CreatedAt,
UpdatedAt: announcement.UpdatedAt,
})
}
return items
}

View File

@@ -156,7 +156,6 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo(
}
return nil, fmt.Errorf("企业信息验证失败: %s", err.Error())
}
if cmd.UserID != "3fbd6917-bb13-40b3-bab0-de0d44c0afca" {
err = s.enterpriseInfoSubmitRecordService.ValidateWithWestdex(ctx, enterpriseInfo)
if err != nil {
s.logger.Error("企业信息验证失败", zap.Error(err))
@@ -167,7 +166,6 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo(
}
return nil, fmt.Errorf("企业信息验证失败, %s", err.Error())
}
}
record.MarkAsVerified()
saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record)
if saveErr != nil {

View File

@@ -5,7 +5,6 @@ type CreateWalletCommand struct {
UserID string `json:"user_id" binding:"required,uuid"`
}
// TransferRechargeCommand 对公转账充值命令
type TransferRechargeCommand struct {
UserID string `json:"user_id" binding:"required,uuid"`
@@ -21,7 +20,6 @@ type GiftRechargeCommand struct {
Notes string `json:"notes" binding:"omitempty,max=500" comment:"备注信息"`
}
// CreateAlipayRechargeCommand 创建支付宝充值订单命令
type CreateAlipayRechargeCommand struct {
UserID string `json:"-"` // 用户ID从token获取
@@ -29,3 +27,12 @@ type CreateAlipayRechargeCommand struct {
Subject string `json:"-"` // 订单标题
Platform string `json:"platform" binding:"required,oneof=app h5 pc"` // 支付平台app/h5/pc
}
// CreateWechatRechargeCommand 创建微信充值订单命令
type CreateWechatRechargeCommand struct {
UserID string `json:"-"` // 用户ID从token获取
Amount string `json:"amount" binding:"required"` // 充值金额
Subject string `json:"-"` // 订单标题
Platform string `json:"platform" binding:"required,oneof=wx_native native wx_h5 h5"` // 仅支持微信Native扫码兼容传入native/wx_h5/h5
OpenID string `json:"openid" binding:"omitempty"` // 前端可直接传入的 openid用于小程序/H5
}

View File

@@ -55,7 +55,9 @@ type RechargeRecordResponse struct {
RechargeType string `json:"recharge_type"`
Status string `json:"status"`
AlipayOrderID string `json:"alipay_order_id,omitempty"`
WechatOrderID string `json:"wechat_order_id,omitempty"`
TransferOrderID string `json:"transfer_order_id,omitempty"`
Platform string `json:"platform,omitempty"` // 支付平台pc/wx_native等
Notes string `json:"notes,omitempty"`
OperatorID string `json:"operator_id,omitempty"`
CompanyName string `json:"company_name,omitempty"`

View File

@@ -0,0 +1,25 @@
package responses
import (
"time"
"github.com/shopspring/decimal"
)
// WechatOrderStatusResponse 微信订单状态响应
type WechatOrderStatusResponse struct {
OutTradeNo string `json:"out_trade_no"` // 商户订单号
TransactionID *string `json:"transaction_id"` // 微信支付交易号
Status string `json:"status"` // 订单状态
Amount decimal.Decimal `json:"amount"` // 订单金额
Subject string `json:"subject"` // 订单标题
Platform string `json:"platform"` // 支付平台
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
NotifyTime *time.Time `json:"notify_time"` // 异步通知时间
ReturnTime *time.Time `json:"return_time"` // 同步返回时间
ErrorCode *string `json:"error_code"` // 错误码
ErrorMessage *string `json:"error_message"` // 错误信息
IsProcessing bool `json:"is_processing"` // 是否处理中
CanRetry bool `json:"can_retry"` // 是否可以重试
}

View File

@@ -0,0 +1,12 @@
package responses
import "github.com/shopspring/decimal"
// WechatRechargeOrderResponse 微信充值下单响应
type WechatRechargeOrderResponse struct {
OutTradeNo string `json:"out_trade_no"` // 商户订单号
Amount decimal.Decimal `json:"amount"` // 充值金额
Platform string `json:"platform"` // 支付平台
Subject string `json:"subject"` // 订单标题
PrepayData interface{} `json:"prepay_data"` // 预支付数据APP预支付ID或JSAPI参数
}

View File

@@ -17,6 +17,7 @@ type FinanceApplicationService interface {
// 充值管理
CreateAlipayRechargeOrder(ctx context.Context, cmd *commands.CreateAlipayRechargeCommand) (*responses.AlipayRechargeOrderResponse, error)
CreateWechatRechargeOrder(ctx context.Context, cmd *commands.CreateWechatRechargeCommand) (*responses.WechatRechargeOrderResponse, error)
TransferRecharge(ctx context.Context, cmd *commands.TransferRechargeCommand) (*responses.RechargeRecordResponse, error)
GiftRecharge(ctx context.Context, cmd *commands.GiftRechargeCommand) (*responses.RechargeRecordResponse, error)
@@ -33,12 +34,15 @@ type FinanceApplicationService interface {
HandleAlipayReturn(ctx context.Context, outTradeNo string) (string, error)
GetAlipayOrderStatus(ctx context.Context, outTradeNo string) (*responses.AlipayOrderStatusResponse, error)
// 微信支付回调处理
HandleWechatPayCallback(ctx context.Context, r *http.Request) error
HandleWechatRefundCallback(ctx context.Context, r *http.Request) error
GetWechatOrderStatus(ctx context.Context, outTradeNo string) (*responses.WechatOrderStatusResponse, error)
// 充值记录
GetUserRechargeRecords(ctx context.Context, userID string, filters map[string]interface{}, options interfaces.ListOptions) (*responses.RechargeRecordListResponse, error)
GetAdminRechargeRecords(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.RechargeRecordListResponse, error)
// 获取充值配置
GetRechargeConfig(ctx context.Context) (*responses.RechargeConfigResponse, error)
}

View File

@@ -393,7 +393,7 @@ func (s *InvoiceApplicationServiceImpl) GetAvailableAmount(ctx context.Context,
return nil, err
}
// 3. 获取真实充值金额(支付宝充值+对公转账)和总赠送金额
// 3. 获取真实充值金额(支付宝充值+微信充值+对公转账)和总赠送金额
realRecharged, totalGifted, totalInvoiced, err := s.getAmountSummary(ctx, userID)
if err != nil {
return nil, err
@@ -408,7 +408,7 @@ func (s *InvoiceApplicationServiceImpl) GetAvailableAmount(ctx context.Context,
// 5. 构建响应DTO
return &dto.AvailableAmountResponse{
AvailableAmount: availableAmount,
TotalRecharged: realRecharged, // 使用真实充值金额(支付宝充值+对公转账)
TotalRecharged: realRecharged, // 使用真实充值金额(支付宝充值+微信充值+对公转账)
TotalGifted: totalGifted,
TotalInvoiced: totalInvoiced,
PendingApplications: pendingAmount,
@@ -417,7 +417,7 @@ func (s *InvoiceApplicationServiceImpl) GetAvailableAmount(ctx context.Context,
// calculateAvailableAmount 计算可开票金额(私有方法)
func (s *InvoiceApplicationServiceImpl) calculateAvailableAmount(ctx context.Context, userID string) (decimal.Decimal, error) {
// 1. 获取真实充值金额(支付宝充值+对公转账)和总赠送金额
// 1. 获取真实充值金额(支付宝充值+微信充值+对公转账)和总赠送金额
realRecharged, totalGifted, totalInvoiced, err := s.getAmountSummary(ctx, userID)
if err != nil {
return decimal.Zero, err
@@ -433,7 +433,7 @@ func (s *InvoiceApplicationServiceImpl) calculateAvailableAmount(ctx context.Con
fmt.Println("totalInvoiced", totalInvoiced)
fmt.Println("pendingAmount", pendingAmount)
// 3. 计算可开票金额:真实充值金额 - 已开票 - 待处理申请
// 可开票金额 = 真实充值金额(支付宝充值+对公转账) - 已开票金额 - 待处理申请金额
// 可开票金额 = 真实充值金额(支付宝充值+微信充值+对公转账) - 已开票金额 - 待处理申请金额
availableAmount := realRecharged.Sub(totalInvoiced).Sub(pendingAmount)
fmt.Println("availableAmount", availableAmount)
// 确保可开票金额不为负数
@@ -452,16 +452,16 @@ func (s *InvoiceApplicationServiceImpl) getAmountSummary(ctx context.Context, us
return decimal.Zero, decimal.Zero, decimal.Zero, fmt.Errorf("获取充值记录失败: %w", err)
}
// 2. 计算真实充值金额(支付宝充值 + 对公转账)和总赠送金额
var realRecharged decimal.Decimal // 真实充值金额:支付宝充值 + 对公转账
// 2. 计算真实充值金额(支付宝充值 + 微信充值 + 对公转账)和总赠送金额
var realRecharged decimal.Decimal // 真实充值金额:支付宝充值 + 微信充值 + 对公转账
var totalGifted decimal.Decimal // 总赠送金额
for _, record := range rechargeRecords {
if record.IsSuccess() {
if record.RechargeType == entities.RechargeTypeGift {
// 赠送金额不计入可开票金额
totalGifted = totalGifted.Add(record.Amount)
} else if record.RechargeType == entities.RechargeTypeAlipay || record.RechargeType == entities.RechargeTypeTransfer {
// 只有支付宝充值和对公转账计入可开票金额
} else if record.RechargeType == entities.RechargeTypeAlipay || record.RechargeType == entities.RechargeTypeWechat || record.RechargeType == entities.RechargeTypeTransfer {
// 支付宝充值、微信充值和对公转账计入可开票金额
realRecharged = realRecharged.Add(record.Amount)
}
}

View File

@@ -11,7 +11,8 @@ import (
// ProductApplicationService 产品应用服务接口
type ProductApplicationService interface {
// 产品管理
CreateProduct(ctx context.Context, cmd *commands.CreateProductCommand) error
CreateProduct(ctx context.Context, cmd *commands.CreateProductCommand) (*responses.ProductAdminInfoResponse, error)
UpdateProduct(ctx context.Context, cmd *commands.UpdateProductCommand) error
DeleteProduct(ctx context.Context, cmd *commands.DeleteProductCommand) error
@@ -46,6 +47,4 @@ type ProductApplicationService interface {
CreateProductApiConfig(ctx context.Context, productID string, config *responses.ProductApiConfigResponse) error
UpdateProductApiConfig(ctx context.Context, configID string, config *responses.ProductApiConfigResponse) error
DeleteProductApiConfig(ctx context.Context, configID string) error
}

View File

@@ -51,7 +51,7 @@ func NewProductApplicationService(
// CreateProduct 创建产品
// 业务流程<E6B581>?. 构建产品实体 2. 创建产品
func (s *ProductApplicationServiceImpl) CreateProduct(ctx context.Context, cmd *commands.CreateProductCommand) error {
func (s *ProductApplicationServiceImpl) CreateProduct(ctx context.Context, cmd *commands.CreateProductCommand) (*responses.ProductAdminInfoResponse, error) {
// 1. 构建产品实体
product := &entities.Product{
Name: cmd.Name,
@@ -71,8 +71,13 @@ func (s *ProductApplicationServiceImpl) CreateProduct(ctx context.Context, cmd *
}
// 2. 创建产品
_, err := s.productManagementService.CreateProduct(ctx, product)
return err
createdProduct, err := s.productManagementService.CreateProduct(ctx, product)
if err != nil {
return nil, err
}
// 3. 转换为响应对象
return s.convertToProductAdminInfoResponse(createdProduct), nil
}
// UpdateProduct 更新产品

View File

@@ -10,6 +10,7 @@ import (
"tyapi-server/internal/application/product/dto/commands"
appQueries "tyapi-server/internal/application/product/dto/queries"
"tyapi-server/internal/application/product/dto/responses"
domain_api_repo "tyapi-server/internal/domains/api/repositories"
"tyapi-server/internal/domains/product/entities"
repoQueries "tyapi-server/internal/domains/product/repositories/queries"
product_service "tyapi-server/internal/domains/product/services"
@@ -21,6 +22,7 @@ import (
type SubscriptionApplicationServiceImpl struct {
productSubscriptionService *product_service.ProductSubscriptionService
userRepo user_repositories.UserRepository
apiCallRepository domain_api_repo.ApiCallRepository
logger *zap.Logger
}
@@ -28,11 +30,13 @@ type SubscriptionApplicationServiceImpl struct {
func NewSubscriptionApplicationService(
productSubscriptionService *product_service.ProductSubscriptionService,
userRepo user_repositories.UserRepository,
apiCallRepository domain_api_repo.ApiCallRepository,
logger *zap.Logger,
) SubscriptionApplicationService {
return &SubscriptionApplicationServiceImpl{
productSubscriptionService: productSubscriptionService,
userRepo: userRepo,
apiCallRepository: apiCallRepository,
logger: logger,
}
}
@@ -262,17 +266,30 @@ func (s *SubscriptionApplicationServiceImpl) GetProductSubscriptions(ctx context
}
// GetSubscriptionUsage 获取订阅使用情况
// 业务流程1. 获取订阅使用情况 2. 构建响应数据
// 业务流程1. 获取订阅信息 2. 根据产品ID和用户ID统计API调用次数 3. 构建响应数据
func (s *SubscriptionApplicationServiceImpl) GetSubscriptionUsage(ctx context.Context, subscriptionID string) (*responses.SubscriptionUsageResponse, error) {
// 获取订阅信息
subscription, err := s.productSubscriptionService.GetSubscriptionByID(ctx, subscriptionID)
if err != nil {
return nil, err
}
// 根据用户ID和产品ID统计API调用次数
apiCallCount, err := s.apiCallRepository.CountByUserIdAndProductId(ctx, subscription.UserID, subscription.ProductID)
if err != nil {
s.logger.Warn("统计API调用次数失败使用订阅记录中的值",
zap.String("subscription_id", subscriptionID),
zap.String("user_id", subscription.UserID),
zap.String("product_id", subscription.ProductID),
zap.Error(err))
// 如果统计失败使用订阅实体中的APIUsed字段作为备选
apiCallCount = subscription.APIUsed
}
return &responses.SubscriptionUsageResponse{
ID: subscription.ID,
ProductID: subscription.ProductID,
APIUsed: subscription.APIUsed,
APIUsed: apiCallCount,
}, 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

@@ -1301,8 +1301,10 @@ func (s *StatisticsApplicationServiceImpl) getUserApiCallsStats(ctx context.Cont
}
// 获取今日调用次数
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayCalls, err := s.getApiCallsCountByDateRange(ctx, userID, today, tomorrow)
if err != nil {
s.logger.Error("获取今日API调用次数失败", zap.String("user_id", userID), zap.Error(err))
@@ -1356,8 +1358,10 @@ func (s *StatisticsApplicationServiceImpl) getUserConsumptionStats(ctx context.C
}
// 获取今日消费金额
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayAmount, err := s.getWalletTransactionsByDateRange(ctx, userID, today, tomorrow)
if err != nil {
s.logger.Error("获取今日消费金额失败", zap.String("user_id", userID), zap.Error(err))
@@ -1411,8 +1415,10 @@ func (s *StatisticsApplicationServiceImpl) getUserRechargeStats(ctx context.Cont
}
// 获取今日充值金额
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayAmount, err := s.getRechargeRecordsByDateRange(ctx, userID, today, tomorrow)
if err != nil {
s.logger.Error("获取今日充值金额失败", zap.String("user_id", userID), zap.Error(err))
@@ -1682,13 +1688,13 @@ func (s *StatisticsApplicationServiceImpl) getCertificationStats(ctx context.Con
successRate = float64(userStats.CertifiedUsers) / float64(userStats.TotalUsers)
}
// 根据时间范围获取趋势数据
// 根据时间范围获取认证趋势数据基于is_certified字段
var trendData []map[string]interface{}
if !startTime.IsZero() && !endTime.IsZero() {
if period == "day" {
trendData, err = s.userRepo.GetSystemDailyUserStats(ctx, startTime, endTime)
trendData, err = s.userRepo.GetSystemDailyCertificationStats(ctx, startTime, endTime)
} else if period == "month" {
trendData, err = s.userRepo.GetSystemMonthlyUserStats(ctx, startTime, endTime)
trendData, err = s.userRepo.GetSystemMonthlyCertificationStats(ctx, startTime, endTime)
}
if err != nil {
s.logger.Error("获取认证趋势数据失败", zap.Error(err))
@@ -1698,16 +1704,35 @@ func (s *StatisticsApplicationServiceImpl) getCertificationStats(ctx context.Con
// 默认获取最近7天的数据
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -7)
trendData, err = s.userRepo.GetSystemDailyUserStats(ctx, startDate, endDate)
trendData, err = s.userRepo.GetSystemDailyCertificationStats(ctx, startDate, endDate)
if err != nil {
s.logger.Error("获取认证每日趋势失败", zap.Error(err))
return nil, err
}
}
// 获取今日认证用户数基于is_certified字段东八时区
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
var certifiedToday int64
todayCertStats, err := s.userRepo.GetSystemDailyCertificationStats(ctx, today, tomorrow)
if err == nil && len(todayCertStats) > 0 {
// 累加今日所有认证用户数
for _, stat := range todayCertStats {
if count, ok := stat["count"].(int64); ok {
certifiedToday += count
} else if count, ok := stat["count"].(int); ok {
certifiedToday += int64(count)
}
}
}
stats := map[string]interface{}{
"total_certified": userStats.CertifiedUsers,
"certified_today": userStats.TodayRegistrations, // 今日注册的用户
"certified_today": certifiedToday, // 今日认证的用户基于is_certified字段
"success_rate": successRate,
"daily_trend": trendData,
}
@@ -1723,9 +1748,11 @@ func (s *StatisticsApplicationServiceImpl) getSystemApiCallStats(ctx context.Con
return nil, err
}
// 获取今日API调用次数
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
// 获取今日API调用次数(东八时区)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayCalls, err := s.apiCallRepo.GetSystemCallsByDateRange(ctx, today, tomorrow)
if err != nil {
s.logger.Error("获取今日API调用次数失败", zap.Error(err))
@@ -1780,8 +1807,11 @@ func (s *StatisticsApplicationServiceImpl) getSystemFinanceStats(ctx context.Con
}
// 获取今日消费金额
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayConsumption, err := s.walletTransactionRepo.GetSystemAmountByDateRange(ctx, today, tomorrow)
if err != nil {
s.logger.Error("获取今日消费金额失败", zap.Error(err))
@@ -2275,6 +2305,10 @@ func (s *StatisticsApplicationServiceImpl) AdminGetApiDomainStatistics(ctx conte
s.logger.Error("解析开始日期失败", zap.Error(err))
return nil, err
}
// 如果是月统计将开始日期调整为当月1号00:00:00
if period == "month" {
startTime = time.Date(startTime.Year(), startTime.Month(), 1, 0, 0, 0, 0, startTime.Location())
}
}
if endDate != "" {
endTime, err = time.Parse("2006-01-02", endDate)
@@ -2282,6 +2316,14 @@ func (s *StatisticsApplicationServiceImpl) AdminGetApiDomainStatistics(ctx conte
s.logger.Error("解析结束日期失败", zap.Error(err))
return nil, err
}
if period == "month" {
// 如果是月统计将结束日期调整为下个月1号00:00:00
// 这样在查询时使用 created_at < endTime 可以包含整个月份的数据到本月最后一天23:59:59.999
endTime = time.Date(endTime.Year(), endTime.Month()+1, 1, 0, 0, 0, 0, endTime.Location())
} else {
// 日统计将结束日期设置为次日00:00:00这样在查询时使用 created_at < endTime 可以包含当天的所有数据
endTime = endTime.AddDate(0, 0, 1)
}
}
// 获取API调用统计数据
@@ -2318,6 +2360,10 @@ func (s *StatisticsApplicationServiceImpl) AdminGetConsumptionDomainStatistics(c
s.logger.Error("解析开始日期失败", zap.Error(err))
return nil, err
}
// 如果是月统计将开始日期调整为当月1号00:00:00
if period == "month" {
startTime = time.Date(startTime.Year(), startTime.Month(), 1, 0, 0, 0, 0, startTime.Location())
}
}
if endDate != "" {
endTime, err = time.Parse("2006-01-02", endDate)
@@ -2325,6 +2371,14 @@ func (s *StatisticsApplicationServiceImpl) AdminGetConsumptionDomainStatistics(c
s.logger.Error("解析结束日期失败", zap.Error(err))
return nil, err
}
if period == "month" {
// 如果是月统计将结束日期调整为下个月1号00:00:00
// 这样在查询时使用 created_at < endTime 可以包含整个月份的数据到本月最后一天23:59:59.999
endTime = time.Date(endTime.Year(), endTime.Month()+1, 1, 0, 0, 0, 0, endTime.Location())
} else {
// 日统计将结束日期设置为次日00:00:00这样在查询时使用 created_at < endTime 可以包含当天的所有数据
endTime = endTime.AddDate(0, 0, 1)
}
}
// 获取消费统计数据
@@ -2335,8 +2389,10 @@ func (s *StatisticsApplicationServiceImpl) AdminGetConsumptionDomainStatistics(c
}
// 获取今日消费金额
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayConsumption, err := s.walletTransactionRepo.GetSystemAmountByDateRange(ctx, today, tomorrow)
if err != nil {
s.logger.Error("获取今日消费金额失败", zap.Error(err))
@@ -2406,6 +2462,10 @@ func (s *StatisticsApplicationServiceImpl) AdminGetRechargeDomainStatistics(ctx
s.logger.Error("解析开始日期失败", zap.Error(err))
return nil, err
}
// 如果是月统计将开始日期调整为当月1号00:00:00
if period == "month" {
startTime = time.Date(startTime.Year(), startTime.Month(), 1, 0, 0, 0, 0, startTime.Location())
}
}
if endDate != "" {
endTime, err = time.Parse("2006-01-02", endDate)
@@ -2413,6 +2473,14 @@ func (s *StatisticsApplicationServiceImpl) AdminGetRechargeDomainStatistics(ctx
s.logger.Error("解析结束日期失败", zap.Error(err))
return nil, err
}
if period == "month" {
// 如果是月统计将结束日期调整为下个月1号00:00:00
// 这样在查询时使用 created_at < endTime 可以包含整个月份的数据到本月最后一天23:59:59.999
endTime = time.Date(endTime.Year(), endTime.Month()+1, 1, 0, 0, 0, 0, endTime.Location())
} else {
// 日统计将结束日期设置为次日00:00:00这样在查询时使用 created_at < endTime 可以包含当天的所有数据
endTime = endTime.AddDate(0, 0, 1)
}
}
// 获取充值统计数据
@@ -2423,8 +2491,10 @@ func (s *StatisticsApplicationServiceImpl) AdminGetRechargeDomainStatistics(ctx
}
// 获取今日充值金额
today := time.Now().Truncate(24 * time.Hour)
tomorrow := today.Add(24 * time.Hour)
loc, _ := time.LoadLocation("Asia/Shanghai") // 东八时区
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) // 当天0点
tomorrow := today.AddDate(0, 0, 1) // 次日0点
todayRecharge, err := s.rechargeRecordRepo.GetSystemAmountByDateRange(ctx, today, tomorrow)
if err != nil {
s.logger.Error("获取今日充值金额失败", zap.Error(err))

View File

@@ -31,6 +31,9 @@ type Config struct {
Zhicha ZhichaConfig `mapstructure:"zhicha"`
Muzi MuziConfig `mapstructure:"muzi"`
AliPay AliPayConfig `mapstructure:"alipay"`
Wxpay WxpayConfig `mapstructure:"wxpay"`
WechatMini WechatMiniConfig `mapstructure:"wechat_mini"`
WechatH5 WechatH5Config `mapstructure:"wechat_h5"`
Yushan YushanConfig `mapstructure:"yushan"`
TianYanCha TianYanChaConfig `mapstructure:"tianyancha"`
Alicloud AlicloudConfig `mapstructure:"alicloud"`
@@ -429,6 +432,29 @@ type AliPayConfig struct {
ReturnURL string `mapstructure:"return_url"`
}
// WxpayConfig 微信支付配置
type WxpayConfig struct {
AppID string `mapstructure:"app_id"`
MchID string `mapstructure:"mch_id"`
MchCertificateSerialNumber string `mapstructure:"mch_certificate_serial_number"`
MchApiv3Key string `mapstructure:"mch_apiv3_key"`
MchPrivateKeyPath string `mapstructure:"mch_private_key_path"`
MchPublicKeyID string `mapstructure:"mch_public_key_id"`
MchPublicKeyPath string `mapstructure:"mch_public_key_path"`
NotifyUrl string `mapstructure:"notify_url"`
RefundNotifyUrl string `mapstructure:"refund_notify_url"`
}
// WechatMiniConfig 微信小程序配置
type WechatMiniConfig struct {
AppID string `mapstructure:"app_id"`
}
// WechatH5Config 微信H5配置
type WechatH5Config struct {
AppID string `mapstructure:"app_id"`
}
// YushanConfig 羽山配置
type YushanConfig struct {
URL string `mapstructure:"url"`

View File

@@ -3,6 +3,8 @@ package container
import (
"context"
"fmt"
"os"
"strconv"
"time"
"go.uber.org/fx"
@@ -53,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"
@@ -305,6 +308,16 @@ func NewContainer() *Container {
}
return payment.NewAliPayService(config)
},
// 微信支付服务
func(cfg *config.Config, logger *zap.Logger) *payment.WechatPayService {
// 根据配置选择初始化方式,默认使用平台证书方式
initType := payment.InitTypePlatformCert
// 如果配置了公钥ID使用公钥方式
if cfg.Wxpay.MchPublicKeyID != "" {
initType = payment.InitTypeWxPayPubKey
}
return payment.NewWechatPayService(*cfg, initType, logger)
},
// 导出管理器
func(logger *zap.Logger) *export.ExportManager {
return export.NewExportManager(logger)
@@ -510,6 +523,11 @@ func NewContainer() *Container {
finance_repo.NewGormAlipayOrderRepository,
fx.As(new(domain_finance_repo.AlipayOrderRepository)),
),
// 微信订单仓储
fx.Annotate(
finance_repo.NewGormWechatOrderRepository,
fx.As(new(domain_finance_repo.WechatOrderRepository)),
),
// 发票申请仓储
fx.Annotate(
finance_repo.NewGormInvoiceApplicationRepository,
@@ -548,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)),
),
),
// 仓储层 - 文章域
@@ -572,6 +605,11 @@ func NewContainer() *Container {
article_repo.NewGormScheduledTaskRepository,
fx.As(new(domain_article_repo.ScheduledTaskRepository)),
),
// 公告仓储 - 同时注册具体类型和接口类型
fx.Annotate(
article_repo.NewGormAnnouncementRepository,
fx.As(new(domain_article_repo.AnnouncementRepository)),
),
),
// API域仓储层
@@ -676,6 +714,8 @@ func NewContainer() *Container {
certification_service.NewEnterpriseInfoSubmitRecordService,
// 文章领域服务
article_service.NewArticleService,
// 公告领域服务
article_service.NewAnnouncementService,
// 统计领域服务
statistics_service.NewStatisticsAggregateService,
statistics_service.NewStatisticsCalculationService,
@@ -776,6 +816,7 @@ func NewContainer() *Container {
cfg *config.Config,
logger *zap.Logger,
articleApplicationService article.ArticleApplicationService,
announcementApplicationService article.AnnouncementApplicationService,
apiApplicationService api_app.ApiApplicationService,
walletService finance_services.WalletAggregateService,
subscriptionService *product_services.ProductSubscriptionService,
@@ -786,6 +827,7 @@ func NewContainer() *Container {
redisAddr,
logger,
articleApplicationService,
announcementApplicationService,
apiApplicationService,
walletService,
subscriptionService,
@@ -844,22 +886,30 @@ func NewContainer() *Container {
fx.Annotate(
func(
aliPayClient *payment.AliPayService,
wechatPayService *payment.WechatPayService,
walletService finance_services.WalletAggregateService,
rechargeRecordService finance_services.RechargeRecordService,
walletTransactionRepo domain_finance_repo.WalletTransactionRepository,
alipayOrderRepo domain_finance_repo.AlipayOrderRepository,
wechatOrderRepo domain_finance_repo.WechatOrderRepository,
rechargeRecordRepo domain_finance_repo.RechargeRecordRepository,
userRepo domain_user_repo.UserRepository,
txManager *shared_database.TransactionManager,
logger *zap.Logger,
config *config.Config,
exportManager *export.ExportManager,
componentReportRepo domain_product_repo.ComponentReportRepository,
) finance.FinanceApplicationService {
return finance.NewFinanceApplicationService(
aliPayClient,
wechatPayService,
walletService,
rechargeRecordService,
walletTransactionRepo,
alipayOrderRepo,
wechatOrderRepo,
rechargeRecordRepo,
componentReportRepo,
userRepo,
txManager,
logger,
@@ -942,6 +992,23 @@ func NewContainer() *Container {
},
fx.As(new(article.ArticleApplicationService)),
),
// 公告应用服务 - 绑定到接口
fx.Annotate(
func(
announcementRepo domain_article_repo.AnnouncementRepository,
announcementService *article_service.AnnouncementService,
taskManager task_interfaces.TaskManager,
logger *zap.Logger,
) article.AnnouncementApplicationService {
return article.NewAnnouncementApplicationService(
announcementRepo,
announcementService,
taskManager,
logger,
)
},
fx.As(new(article.AnnouncementApplicationService)),
),
// 统计应用服务 - 绑定到接口
fx.Annotate(
func(
@@ -979,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查找服务
@@ -999,6 +1087,62 @@ func NewContainer() *Container {
return pdf.NewPDFGenerator(logger)
},
),
// PDF缓存管理器
fx.Provide(
func(logger *zap.Logger) (*pdf.PDFCacheManager, error) {
// 使用默认配置缓存目录在临时目录TTL为24小时最大缓存大小为500MB
cacheDir := "" // 使用默认目录临时目录下的tyapi_pdf_cache
ttl := 24 * time.Hour
maxSize := int64(500 * 1024 * 1024) // 500MB
// 可以通过环境变量覆盖
if envCacheDir := os.Getenv("PDF_CACHE_DIR"); envCacheDir != "" {
cacheDir = envCacheDir
}
if envTTL := os.Getenv("PDF_CACHE_TTL"); envTTL != "" {
if parsedTTL, err := time.ParseDuration(envTTL); err == nil {
ttl = parsedTTL
}
}
if envMaxSize := os.Getenv("PDF_CACHE_MAX_SIZE"); envMaxSize != "" {
if parsedMaxSize, err := strconv.ParseInt(envMaxSize, 10, 64); err == nil {
maxSize = parsedMaxSize
}
}
cacheManager, err := pdf.NewPDFCacheManager(logger, cacheDir, ttl, maxSize)
if err != nil {
logger.Warn("PDF缓存管理器初始化失败将禁用缓存功能", zap.Error(err))
return nil, nil // 返回nilhandler中会检查
}
logger.Info("PDF缓存管理器已初始化",
zap.String("cache_dir", cacheDir),
zap.Duration("ttl", ttl),
zap.Int64("max_size", maxSize),
)
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处理器
@@ -1024,6 +1168,39 @@ func NewContainer() *Container {
) *handlers.ArticleHandler {
return handlers.NewArticleHandler(appService, responseBuilder, validator, logger)
},
// 公告HTTP处理器
func(
appService article.AnnouncementApplicationService,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
logger *zap.Logger,
) *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)
},
),
// 路由注册
@@ -1038,8 +1215,12 @@ func NewContainer() *Container {
routes.NewProductRoutes,
// 产品管理员路由
routes.NewProductAdminRoutes,
// UI组件路由
routes.NewUIComponentRoutes,
// 文章路由
routes.NewArticleRoutes,
// 公告路由
routes.NewAnnouncementRoutes,
// API路由
routes.NewApiRoutes,
// 统计路由
@@ -1150,9 +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,
) {
@@ -1167,7 +1352,15 @@ 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

@@ -133,6 +133,13 @@ type QYGL23T7Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
}
type QYGL5CMPReq struct {
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
EntCode string `json:"ent_code" validate:"required,validUSCI"`
LegalPerson string `json:"legal_person" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
type YYSY4B37Req struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
@@ -195,6 +202,18 @@ type IVYZGZ08Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
}
type IVYZ2B2TReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
QueryReasonId int64 `json:"query_reason_id" validate:"required"`
}
type IVYZ5A9OReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
AuthAuthorizeFileCode string `json:"auth_authorize_file_code" validate:"required"`
}
type FLXG8A3FReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
@@ -677,6 +696,11 @@ type IVYZ8I9JReq struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
type IVYZ6M8PReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type YYSY9E4AReq struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}

View File

@@ -25,6 +25,9 @@ type ApiCallRepository interface {
// 新增统计用户API调用次数
CountByUserId(ctx context.Context, userId string) (int64, error)
// 新增根据用户ID和产品ID统计API调用次数
CountByUserIdAndProductId(ctx context.Context, userId string, productId string) (int64, error)
// 新增根据TransactionID查询
FindByTransactionId(ctx context.Context, transactionId string) (*entities.ApiCall, error)

View File

@@ -156,6 +156,7 @@ func registerAllProcessors(combService *comb.CombService) {
"QYGL9T1Q": qygl.ProcessQYGL9T1QRequest, //全国企业借贷意向验证查询_V1
"QYGL5A9T": qygl.ProcessQYGL5A9TRequest, //全国企业各类工商风险统计数量查询
"QYGL2S0W": qygl.ProcessQYGL2S0WRequest, //失信被执行企业个人查询
"QYGL5CMP": qygl.ProcessQYGL5CMPRequest, //企业五要素验证
// YYSY系列处理器
"YYSYD50F": yysy.ProcessYYSYD50FRequest,
@@ -202,6 +203,9 @@ func registerAllProcessors(combService *comb.CombService) {
"IVYZ9K2L": ivyz.ProcessIVYZ9K2LRequest,
"IVYZ2C1P": ivyz.ProcessIVYZ2C1PRequest,
"IVYZP2Q6": ivyz.ProcessIVYZP2Q6Request,
"IVYZ2B2T": ivyz.ProcessIVYZ2B2TRequest, //能力资质核验(学历)
"IVYZ5A9O": ivyz.ProcessIVYZ5A9ORequest, //全国⾃然⼈⻛险评估评分模型
"IVYZ6M8P": ivyz.ProcessIVYZ6M8PRequest, //职业资格证书
// COMB系列处理器 - 只注册有自定义逻辑的组合包
"COMB86PM": comb.ProcessCOMB86PMRequest, // 有自定义逻辑重命名ApiCode

View File

@@ -195,7 +195,10 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"QYGL5A9T": &dto.QYGL5A9TReq{}, //全国企业各类工商风险统计数量查询
"JRZQ3P01": &dto.JRZQ3P01Req{}, //天远风控决策
"JRZQ3AG6": &dto.JRZQ3AG6Req{}, //轻松查公积
"IVYZ2B2T": &dto.IVYZ2B2TReq{}, //能力资质核验(学历)
"IVYZ5A9O": &dto.IVYZ5A9OReq{}, //全国⾃然⼈⻛险评估评分模型
"IVYZ6M8P": &dto.IVYZ6M8PReq{}, //职业资格证书
"QYGL5CMP": &dto.QYGL5CMPReq{}, //企业五要素验证
}
// 优先返回已配置的DTO
@@ -319,6 +322,7 @@ func (s *FormConfigServiceImpl) parseValidationRules(validateTag string) string
values := strings.TrimPrefix(rule, "oneof=")
frontendRules = append(frontendRules, "可选值: "+values)
}
}
return strings.Join(frontendRules, "、")
@@ -394,6 +398,7 @@ func (s *FormConfigServiceImpl) generateFieldLabel(jsonTag string) string {
"photo_data": "人脸图片",
"owner_type": "企业主类型",
"type": "查询类型",
"query_reason_id": "查询原因ID",
}
if label, exists := labelMap[jsonTag]; exists {
@@ -438,6 +443,7 @@ func (s *FormConfigServiceImpl) generateExampleValue(fieldType reflect.Type, jso
"photo_data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"ownerType": "1",
"type": "per",
"query_reason_id": "1",
}
if example, exists := exampleMap[jsonTag]; exists {
@@ -491,6 +497,7 @@ func (s *FormConfigServiceImpl) generatePlaceholder(jsonTag string, fieldType st
"photo_data": "请输入base64编码的人脸图片支持JPG、BMP、PNG格式",
"ownerType": "请选择企业主类型",
"type": "请选择查询类型",
"query_reason_id": "请选择查询原因ID",
}
if placeholder, exists := placeholderMap[jsonTag]; exists {
@@ -516,7 +523,7 @@ func (s *FormConfigServiceImpl) generatePlaceholder(jsonTag string, fieldType st
func (s *FormConfigServiceImpl) generateDescription(jsonTag string, validation string) string {
descMap := map[string]string{
"mobile_no": "请输入11位手机号码",
"id_card": "请输入18位身份证号码",
"id_card": "请输入18位身份证号码最后一位如是字母请大写",
"name": "请输入真实姓名",
"man_name": "请输入男方真实姓名",
"woman_name": "请输入女方真实姓名",
@@ -545,7 +552,8 @@ func (s *FormConfigServiceImpl) generateDescription(jsonTag string, validation s
"return_type": "返回类型1-专业和学校名称数据返回编码形式默认2-专业和学校名称数据返回中文名称",
"photo_data": "人脸图片必填base64编码的图片数据仅支持JPG、BMP、PNG三种格式",
"owner_type": "企业主类型编码1-法定代表人2-主要人员3-自然人股东4-法定代表人及自然人股东5-其他",
"type": "查询类型per-人员ent-企业 1",
"type": "查询类型per-人员ent-企业 ",
"query_reason_id": "查询原因ID1-授信审批2-贷中管理3-贷后管理4-异议处理5-担保查询6-租赁资质审查7-融资租赁审批8-借贷撮合查询9-保险审批10-资质审核11-风控审核12-企业背调",
}
if desc, exists := descMap[jsonTag]; exists {

View File

@@ -24,7 +24,9 @@ func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)

View File

@@ -20,7 +20,9 @@ func ProcessFLXG5A3BRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)

View File

@@ -20,7 +20,9 @@ func ProcessFLXG7E8FRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
// 构建请求数据,将项目规范的字段名转换为 XingweiService 需要的字段名
reqData := map[string]interface{}{
"name": paramsDto.Name,

View File

@@ -20,7 +20,9 @@ func ProcessFLXGCA3DRequest(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)

View File

@@ -25,7 +25,9 @@ func ProcessFLXGDEA9Request(ctx context.Context, params []byte, deps *processors
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" {
return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空"))
}
encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)

View File

@@ -0,0 +1,58 @@
package ivyz
import (
"context"
"encoding/json"
"errors"
"strconv"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/westdex"
)
// ProcessIVYZ2B2TRequest IVYZ2B2T API处理方法 能力资质核验(学历)
func ProcessIVYZ2B2TRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.IVYZ2B2TReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedIDCard, err := deps.WestDexService.Encrypt(paramsDto.IDCard)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedQueryReasonId, err := deps.WestDexService.Encrypt(strconv.FormatInt(paramsDto.QueryReasonId, 10))
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
reqData := map[string]interface{}{
"data": map[string]interface{}{
"idCard": encryptedIDCard,
"name": encryptedName,
"queryReasonId": encryptedQueryReasonId,
},
}
respBytes, err := deps.WestDexService.CallAPI(ctx, "G11JX01", reqData)
if err != nil {
if errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else {
return nil, errors.Join(processors.ErrSystem, err)
}
}
return respBytes, nil
}

View File

@@ -0,0 +1,57 @@
package ivyz
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/westdex"
)
// ProcessIVYZ5A9ORequest IVYZ5A9O API处理方法 全国⾃然⼈⻛险评估评分模型
func ProcessIVYZ5A9ORequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.IVYZ5A9OReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedIDCard, err := deps.WestDexService.Encrypt(paramsDto.IDCard)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedAuthAuthorizeFileCode, err := deps.WestDexService.Encrypt(paramsDto.AuthAuthorizeFileCode)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
reqData := map[string]interface{}{
"data": map[string]interface{}{
"idcard": encryptedIDCard,
"name": encryptedName,
"auth_authorizeFileCode": encryptedAuthAuthorizeFileCode,
},
}
respBytes, err := deps.WestDexService.CallAPI(ctx, "G01SC01", reqData)
if err != nil {
if errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else {
return nil, errors.Join(processors.ErrSystem, err)
}
}
return respBytes, nil
}

View File

@@ -0,0 +1,46 @@
package ivyz
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/xingwei"
)
// ProcessIVYZ6M8PRequest IVYZ6M8P 职业资格证书API处理方法
func ProcessIVYZ6M8PRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.IVYZ6M8PReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
// 构建请求数据,将项目规范的字段名转换为 XingweiService 需要的字段名
reqData := map[string]interface{}{
"name": paramsDto.Name,
"idCardNum": paramsDto.IDCard,
}
// 调用行为数据API使用指定的project_id
projectID := "CDJ-1147725836315455488"
respBytes, err := deps.XingweiService.CallAPI(ctx, projectID, reqData)
if err != nil {
if errors.Is(err, xingwei.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
} else if errors.Is(err, xingwei.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else if errors.Is(err, xingwei.ErrSystem) {
return nil, errors.Join(processors.ErrSystem, err)
} else {
return nil, errors.Join(processors.ErrSystem, err)
}
}
return respBytes, nil
}

View File

@@ -38,13 +38,27 @@ func ProcessIVYZ81NCRequest(ctx context.Context, params []byte, deps *processors
},
}
respBytes, err := deps.WestDexService.CallAPI(ctx, "G09XM02", reqData)
if err != nil {
if errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else {
const maxRetries = 5
var respBytes []byte
for attempt := 0; attempt <= maxRetries; attempt++ {
var err error
respBytes, err = deps.WestDexService.CallAPI(ctx, "G09XM02", reqData)
if err == nil {
return respBytes, nil
}
// 如果不是数据源异常,直接返回错误
if !errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrSystem, err)
}
// 如果是最后一次尝试,返回错误
if attempt == maxRetries {
return nil, errors.Join(processors.ErrDatasource, err)
}
// 立即重试,不等待
}
return respBytes, nil

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
@@ -20,6 +21,10 @@ func ProcessIVYZ9363Request(ctx context.Context, params []byte, deps *processors
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
// 新增:身份证一致性校验
if strings.EqualFold(strings.TrimSpace(paramsDto.ManIDCard), strings.TrimSpace(paramsDto.WomanIDCard)) {
return nil, errors.Join(processors.ErrInvalidParam, errors.New("请正确填写身份信息"))
}
encryptedManName, err := deps.WestDexService.Encrypt(paramsDto.ManName)
if err != nil {

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/zhicha"

View File

@@ -12,7 +12,7 @@ import (
"github.com/tidwall/gjson"
)
// ProcessQYGL23T7Request QYGL23T7 API处理方法 - 企业要素验证
// ProcessQYGL23T7Request QYGL23T7 API处理方法 - 企业要素验证
func ProcessQYGL23T7Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QYGL23T7Req
if err := json.Unmarshal(params, &paramsDto); err != nil {

View File

@@ -26,31 +26,25 @@ func ProcessQYGL2S0WRequest(ctx context.Context, params []byte, deps *processors
var nameValue string
if paramsDto.Type == "per" {
// 个人查询idCardNum 必填
fmt.Print("个人")
nameValue = paramsDto.Name
fmt.Println("nameValue", nameValue)
if paramsDto.IDCard == "" {
fmt.Print("个人身份证件号不能为空")
return nil, fmt.Errorf("%s: %w", processors.ErrInvalidParam, errors.New("当失信被执行人类型为个人时,身份证件号不能为空"))
}
} else if paramsDto.Type == "ent" {
// 企业查询name 和 entMark 两者必填其一
nameValue = paramsDto.EntName
if paramsDto.EntName == "" && paramsDto.EntCode == "" {
return nil, fmt.Errorf("%s: %w", processors.ErrInvalidParam, errors.New("当查询为企业时,企业名称和企业标识统一代码注册号两者必填其一"))
}
}
// 确定使用哪个值作为 name
if paramsDto.Type == "ent" && paramsDto.EntName != "" {
} // 确定使用哪个值作为 name
if paramsDto.EntName != "" {
nameValue = paramsDto.EntName
} else {
nameValue = paramsDto.EntCode
}
}
fmt.Println("dto2s0w", paramsDto)
// 构建请求数据(不传的参数也需要添加,值为空字符串)
reqData := map[string]interface{}{
"idCardNum": paramsDto.IDCard,

View File

@@ -0,0 +1,147 @@
package qygl
import (
"context"
"encoding/json"
"errors"
"fmt"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/xingwei"
"github.com/tidwall/gjson"
)
// ProcessQYGL5CMPRequest QYGL5CMP API处理方法 - 企业五要素验证
func ProcessQYGL5CMPRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QYGL5CMPReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
// 构建API调用参数
apiParams := map[string]string{
"code": paramsDto.EntCode,
"name": paramsDto.EntName,
"legalPersonName": paramsDto.LegalPerson,
}
// 调用天眼查API - 使用通用的CallAPI方法
response, err := deps.TianYanChaService.CallAPI(ctx, "VerifyThreeElements", apiParams)
if err != nil {
return nil, convertTianYanChaError(err)
}
// 检查天眼查API调用是否成功
if !response.Success {
// 天眼查API调用失败返回企业信息校验不通过
return createStatusResponsess(1), nil
}
// 解析天眼查响应数据
if response.Data == nil {
// 天眼查响应数据为空,返回企业信息校验不通过
return createStatusResponsess(1), nil
}
// 将response.Data转换为JSON字符串然后使用gjson解析
dataBytes, err := json.Marshal(response.Data)
if err != nil {
// 数据序列化失败,返回企业信息校验不通过
return createStatusResponsess(1), nil
}
// 使用gjson解析嵌套的data.result.data字段
result := gjson.GetBytes(dataBytes, "result")
if !result.Exists() {
// 字段不存在,返回企业信息校验不通过
return createStatusResponsess(1), nil
}
// 检查data.result.data是否等于1
if result.Int() != 1 {
// 不等于1返回企业信息校验不通过
return createStatusResponsess(1), nil
}
// 天眼查三要素验证通过,继续调用星维身份证三要素验证
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
// 构建请求数据,将项目规范的字段名转换为 XingweiService 需要的字段名
reqData := map[string]interface{}{
"name": paramsDto.LegalPerson,
"idCardNum": paramsDto.IDCard,
"phoneNumber": paramsDto.MobileNo,
}
// 调用行为数据API使用指定的project_id
projectID := "CDJ-1100244702166183936"
respBytes, err := deps.XingweiService.CallAPI(ctx, projectID, reqData)
if err != nil {
if errors.Is(err, xingwei.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
} else if errors.Is(err, xingwei.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else if errors.Is(err, xingwei.ErrSystem) {
return nil, errors.Join(processors.ErrSystem, err)
} else {
return nil, errors.Join(processors.ErrSystem, err)
}
}
// 解析星维API返回的数据
var xingweiData map[string]interface{}
if err := json.Unmarshal(respBytes, &xingweiData); err != nil {
return nil, errors.Join(processors.ErrSystem, fmt.Errorf("解析星维API响应失败: %w", err))
}
// 构建天眼查API返回的数据结构
tianYanChaData := map[string]interface{}{
"success": response.Success,
"message": response.Message,
"data": response.Data,
}
// 解析status响应将JSON字节解析为对象
statusBytes := createStatusResponsess(0) // 验证通过status为0
var statusData map[string]interface{}
if err := json.Unmarshal(statusBytes, &statusData); err != nil {
return nil, errors.Join(processors.ErrSystem, fmt.Errorf("解析status响应失败: %w", err))
}
// 合并两个API的返回数据
mergedData := map[string]interface{}{
"Personal Information": xingweiData, // 星维API返回的数据
"Enterprise Information": tianYanChaData, // 天眼查API返回的数据
"status": statusData, // 解析后的status对象
}
// 将合并后的数据序列化为JSON
mergedBytes, err := json.Marshal(mergedData)
if err != nil {
return nil, errors.Join(processors.ErrSystem, fmt.Errorf("合并数据序列化失败: %w", err))
}
return mergedBytes, nil
}
// createStatusResponsess 创建状态响应
func createStatusResponsess(status int) []byte {
response := map[string]interface{}{
"status": status,
}
respBytes, _ := json.Marshal(response)
return respBytes
}

View File

@@ -0,0 +1,137 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// AnnouncementStatus 公告状态枚举
type AnnouncementStatus string
const (
AnnouncementStatusDraft AnnouncementStatus = "draft" // 草稿
AnnouncementStatusPublished AnnouncementStatus = "published" // 已发布
AnnouncementStatusArchived AnnouncementStatus = "archived" // 已归档
)
// Announcement 公告聚合根
// 用于对系统公告进行管理,支持发布、撤回、定时发布等功能
type Announcement struct {
// 基础标识
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"公告唯一标识"`
Title string `gorm:"type:varchar(200);not null;index" json:"title" comment:"公告标题"`
Content string `gorm:"type:text;not null" json:"content" comment:"公告内容"`
Status AnnouncementStatus `gorm:"type:varchar(20);not null;default:'draft';index" json:"status" comment:"公告状态"`
ScheduledAt *time.Time `gorm:"index" json:"scheduled_at" comment:"定时发布时间"`
CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-" comment:"软删除时间"`
}
// TableName 指定表名
func (Announcement) TableName() string {
return "announcements"
}
// BeforeCreate GORM钩子创建前自动生成UUID
func (a *Announcement) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
return nil
}
// 实现 Entity 接口 - 提供统一的实体管理接口
// GetID 获取实体唯一标识
func (a *Announcement) GetID() string {
return a.ID
}
// GetCreatedAt 获取创建时间
func (a *Announcement) GetCreatedAt() time.Time {
return a.CreatedAt
}
// GetUpdatedAt 获取更新时间
func (a *Announcement) GetUpdatedAt() time.Time {
return a.UpdatedAt
}
// 验证公告信息
func (a *Announcement) Validate() error {
if a.Title == "" {
return NewValidationError("公告标题不能为空")
}
if a.Content == "" {
return NewValidationError("公告内容不能为空")
}
return nil
}
// 发布公告
func (a *Announcement) Publish() error {
if a.Status == AnnouncementStatusPublished {
return NewValidationError("公告已经是发布状态")
}
a.Status = AnnouncementStatusPublished
now := time.Now()
a.CreatedAt = now
return nil
}
// 撤回公告
func (a *Announcement) Withdraw() error {
if a.Status == AnnouncementStatusDraft {
return NewValidationError("公告已经是草稿状态")
}
a.Status = AnnouncementStatusDraft
now := time.Now()
a.CreatedAt = now
return nil
}
// 定时发布公告
func (a *Announcement) SchedulePublish(scheduledTime time.Time) error {
if a.Status == AnnouncementStatusPublished {
return NewValidationError("公告已经是发布状态")
}
a.Status = AnnouncementStatusDraft // 保持草稿状态,等待定时发布
a.ScheduledAt = &scheduledTime
return nil
}
// 更新定时发布时间
func (a *Announcement) UpdateSchedulePublish(scheduledTime time.Time) error {
if a.Status == AnnouncementStatusPublished {
return NewValidationError("公告已经是发布状态")
}
if scheduledTime.Before(time.Now()) {
return NewValidationError("定时发布时间不能早于当前时间")
}
a.ScheduledAt = &scheduledTime
return nil
}
// CancelSchedulePublish 取消定时发布
func (a *Announcement) CancelSchedulePublish() error {
if a.Status == AnnouncementStatusPublished {
return NewValidationError("公告已经是发布状态")
}
a.ScheduledAt = nil
return nil
}
// IsScheduled 判断是否已设置定时发布
func (a *Announcement) IsScheduled() bool {
return a.ScheduledAt != nil && a.Status == AnnouncementStatusDraft
}
// GetScheduledTime 获取定时发布时间
func (a *Announcement) GetScheduledTime() *time.Time {
return a.ScheduledAt
}

View File

@@ -0,0 +1,24 @@
// 存储公告的仓储接口
package repositories
import (
"context"
"tyapi-server/internal/domains/article/entities"
"tyapi-server/internal/domains/article/repositories/queries"
"tyapi-server/internal/shared/interfaces"
)
// AnnouncementRepository 公告仓储接口
type AnnouncementRepository interface {
interfaces.Repository[entities.Announcement]
// 自定义查询方法
FindByStatus(ctx context.Context, status entities.AnnouncementStatus) ([]*entities.Announcement, error)
FindScheduled(ctx context.Context) ([]*entities.Announcement, error)
ListAnnouncements(ctx context.Context, query *queries.ListAnnouncementQuery) ([]*entities.Announcement, int64, error)
// 统计方法
CountByStatus(ctx context.Context, status entities.AnnouncementStatus) (int64, error)
// 更新统计信息
UpdateStatistics(ctx context.Context, announcementID string) error
}

View File

@@ -0,0 +1,13 @@
package queries
import "tyapi-server/internal/domains/article/entities"
// ListAnnouncementQuery 公告列表查询
type ListAnnouncementQuery struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Status entities.AnnouncementStatus `json:"status"`
Title string `json:"title"`
OrderBy string `json:"order_by"`
OrderDir string `json:"order_dir"`
}

View File

@@ -0,0 +1,133 @@
package services
import (
"tyapi-server/internal/domains/article/entities"
)
// AnnouncementService 公告领域服务
// 处理公告相关的业务逻辑,包括验证、状态管理等
type AnnouncementService struct{}
// NewAnnouncementService 创建公告领域服务
func NewAnnouncementService() *AnnouncementService {
return &AnnouncementService{}
}
// ValidateAnnouncement 验证公告
// 检查公告是否符合业务规则
func (s *AnnouncementService) ValidateAnnouncement(announcement *entities.Announcement) error {
// 1. 基础验证
if err := announcement.Validate(); err != nil {
return err
}
// 2. 业务规则验证
// 标题不能包含敏感词
if s.containsSensitiveWords(announcement.Title) {
return entities.NewValidationError("公告标题包含敏感词")
}
// 内容不能包含敏感词
if s.containsSensitiveWords(announcement.Content) {
return entities.NewValidationError("公告内容包含敏感词")
}
// 标题长度验证
if len(announcement.Title) > 200 {
return entities.NewValidationError("公告标题不能超过200个字符")
}
return nil
}
// CanPublish 检查是否可以发布
func (s *AnnouncementService) CanPublish(announcement *entities.Announcement) error {
if announcement.Status == entities.AnnouncementStatusPublished {
return entities.NewValidationError("公告已经是发布状态")
}
if announcement.Status == entities.AnnouncementStatusArchived {
return entities.NewValidationError("已归档的公告不能发布")
}
// 检查必填字段
if announcement.Title == "" {
return entities.NewValidationError("公告标题不能为空")
}
if announcement.Content == "" {
return entities.NewValidationError("公告内容不能为空")
}
return nil
}
// CanEdit 检查是否可以编辑
func (s *AnnouncementService) CanEdit(announcement *entities.Announcement) error {
if announcement.Status == entities.AnnouncementStatusPublished {
return entities.NewValidationError("已发布的公告不能编辑,请先撤回")
}
if announcement.Status == entities.AnnouncementStatusArchived {
return entities.NewValidationError("已归档的公告不能编辑")
}
return nil
}
// CanArchive 检查是否可以归档
func (s *AnnouncementService) CanArchive(announcement *entities.Announcement) error {
if announcement.Status != entities.AnnouncementStatusPublished {
return entities.NewValidationError("只有已发布的公告才能归档")
}
return nil
}
// CanWithdraw 检查是否可以撤回
func (s *AnnouncementService) CanWithdraw(announcement *entities.Announcement) error {
if announcement.Status != entities.AnnouncementStatusPublished {
return entities.NewValidationError("只有已发布的公告才能撤回")
}
return nil
}
// CanSchedulePublish 检查是否可以定时发布
func (s *AnnouncementService) CanSchedulePublish(announcement *entities.Announcement, scheduledTime interface{}) error {
if announcement.Status == entities.AnnouncementStatusPublished {
return entities.NewValidationError("已发布的公告不能设置定时发布")
}
if announcement.Status == entities.AnnouncementStatusArchived {
return entities.NewValidationError("已归档的公告不能设置定时发布")
}
return nil
}
// containsSensitiveWords 检查是否包含敏感词
func (s *AnnouncementService) containsSensitiveWords(text string) bool {
// TODO: 实现敏感词检查逻辑
// 这里可以集成敏感词库或调用外部服务
sensitiveWords := []string{
"敏感词1",
"敏感词2",
"敏感词3",
}
for _, word := range sensitiveWords {
if len(word) > 0 && len(text) > 0 {
// 简单的字符串包含检查
// 实际项目中应该使用更复杂的算法
if len(text) >= len(word) {
for i := 0; i <= len(text)-len(word); i++ {
if text[i:i+len(word)] == word {
return true
}
}
}
}
}
return false
}

View File

@@ -1,22 +1,16 @@
package entities
import (
"time"
import "github.com/shopspring/decimal"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// AlipayOrderStatus 支付宝订单状态枚举
type AlipayOrderStatus string
// AlipayOrderStatus 支付宝订单状态枚举(别名)
type AlipayOrderStatus = PayOrderStatus
const (
AlipayOrderStatusPending AlipayOrderStatus = "pending" // 待支付
AlipayOrderStatusSuccess AlipayOrderStatus = "success" // 支付成功
AlipayOrderStatusFailed AlipayOrderStatus = "failed" // 支付失败
AlipayOrderStatusCancelled AlipayOrderStatus = "cancelled" // 已取消
AlipayOrderStatusClosed AlipayOrderStatus = "closed" // 已关闭
AlipayOrderStatusPending AlipayOrderStatus = PayOrderStatusPending // 待支付
AlipayOrderStatusSuccess AlipayOrderStatus = PayOrderStatusSuccess // 支付成功
AlipayOrderStatusFailed AlipayOrderStatus = PayOrderStatusFailed // 支付失败
AlipayOrderStatusCancelled AlipayOrderStatus = PayOrderStatusCancelled // 已取消
AlipayOrderStatusClosed AlipayOrderStatus = PayOrderStatusClosed // 已关闭
)
const (
@@ -25,116 +19,10 @@ const (
AlipayOrderPlatformPC = "pc" // 支付宝PC支付
)
// AlipayOrder 支付宝订单详情实体
type AlipayOrder struct {
// 基础标识
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"支付宝订单唯一标识"`
RechargeID string `gorm:"type:varchar(36);not null;uniqueIndex" json:"recharge_id" comment:"关联充值记录ID"`
OutTradeNo string `gorm:"type:varchar(64);not null;uniqueIndex" json:"out_trade_no" comment:"商户订单号"`
TradeNo *string `gorm:"type:varchar(64);uniqueIndex" json:"trade_no,omitempty" comment:"支付宝交易号"`
// 订单信息
Subject string `gorm:"type:varchar(200);not null" json:"subject" comment:"订单标题"`
Amount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"amount" comment:"订单金额"`
Platform string `gorm:"type:varchar(20);not null" json:"platform" comment:"支付平台app/h5/pc"`
Status AlipayOrderStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status" comment:"订单状态"`
// 支付宝返回信息
BuyerID string `gorm:"type:varchar(64)" json:"buyer_id,omitempty" comment:"买家支付宝用户ID"`
SellerID string `gorm:"type:varchar(64)" json:"seller_id,omitempty" comment:"卖家支付宝用户ID"`
PayAmount decimal.Decimal `gorm:"type:decimal(20,8)" json:"pay_amount,omitempty" comment:"实际支付金额"`
ReceiptAmount decimal.Decimal `gorm:"type:decimal(20,8)" json:"receipt_amount,omitempty" comment:"实收金额"`
// 回调信息
NotifyTime *time.Time `gorm:"index" json:"notify_time,omitempty" comment:"异步通知时间"`
ReturnTime *time.Time `gorm:"index" json:"return_time,omitempty" comment:"同步返回时间"`
// 错误信息
ErrorCode string `gorm:"type:varchar(64)" json:"error_code,omitempty" comment:"错误码"`
ErrorMessage string `gorm:"type:text" json:"error_message,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 (AlipayOrder) TableName() string {
return "alipay_orders"
}
// BeforeCreate GORM钩子创建前自动生成UUID
func (a *AlipayOrder) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
return nil
}
// IsPending 检查是否为待支付状态
func (a *AlipayOrder) IsPending() bool {
return a.Status == AlipayOrderStatusPending
}
// IsSuccess 检查是否为支付成功状态
func (a *AlipayOrder) IsSuccess() bool {
return a.Status == AlipayOrderStatusSuccess
}
// IsFailed 检查是否为支付失败状态
func (a *AlipayOrder) IsFailed() bool {
return a.Status == AlipayOrderStatusFailed
}
// IsCancelled 检查是否为已取消状态
func (a *AlipayOrder) IsCancelled() bool {
return a.Status == AlipayOrderStatusCancelled
}
// IsClosed 检查是否为已关闭状态
func (a *AlipayOrder) IsClosed() bool {
return a.Status == AlipayOrderStatusClosed
}
// MarkSuccess 标记为支付成功
func (a *AlipayOrder) MarkSuccess(tradeNo, buyerID, sellerID string, payAmount, receiptAmount decimal.Decimal) {
a.Status = AlipayOrderStatusSuccess
a.TradeNo = &tradeNo
a.BuyerID = buyerID
a.SellerID = sellerID
a.PayAmount = payAmount
a.ReceiptAmount = receiptAmount
now := time.Now()
a.NotifyTime = &now
}
// MarkFailed 标记为支付失败
func (a *AlipayOrder) MarkFailed(errorCode, errorMessage string) {
a.Status = AlipayOrderStatusFailed
a.ErrorCode = errorCode
a.ErrorMessage = errorMessage
}
// MarkCancelled 标记为已取消
func (a *AlipayOrder) MarkCancelled() {
a.Status = AlipayOrderStatusCancelled
}
// MarkClosed 标记为已关闭
func (a *AlipayOrder) MarkClosed() {
a.Status = AlipayOrderStatusClosed
}
// AlipayOrder 支付宝订单实体(统一表 typay_orders兼容多支付渠道
type AlipayOrder = PayOrder
// NewAlipayOrder 工厂方法 - 创建支付宝订单
func NewAlipayOrder(rechargeID, outTradeNo, subject string, amount decimal.Decimal, platform string) *AlipayOrder {
return &AlipayOrder{
ID: uuid.New().String(),
RechargeID: rechargeID,
OutTradeNo: outTradeNo,
Subject: subject,
Amount: amount,
Platform: platform,
Status: AlipayOrderStatusPending,
}
return NewPayOrder(rechargeID, outTradeNo, subject, amount, platform, "alipay")
}

View File

@@ -0,0 +1,136 @@
package entities
import (
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// PayOrderStatus 支付订单状态枚举(通用)
type PayOrderStatus string
const (
PayOrderStatusPending PayOrderStatus = "pending" // 待支付
PayOrderStatusSuccess PayOrderStatus = "success" // 支付成功
PayOrderStatusFailed PayOrderStatus = "failed" // 支付失败
PayOrderStatusCancelled PayOrderStatus = "cancelled" // 已取消
PayOrderStatusClosed PayOrderStatus = "closed" // 已关闭
)
// PayOrder 支付订单详情实体(统一表 typay_orders兼容多支付渠道
type PayOrder struct {
// 基础标识
ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"支付订单唯一标识"`
RechargeID string `gorm:"type:varchar(36);not null;uniqueIndex" json:"recharge_id" comment:"关联充值记录ID"`
OutTradeNo string `gorm:"type:varchar(64);not null;uniqueIndex" json:"out_trade_no" comment:"商户订单号"`
TradeNo *string `gorm:"type:varchar(64);uniqueIndex" json:"trade_no,omitempty" comment:"第三方支付交易号"`
// 订单信息
Subject string `gorm:"type:varchar(200);not null" json:"subject" comment:"订单标题"`
Amount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"amount" comment:"订单金额"`
Platform string `gorm:"type:varchar(20);not null" json:"platform" comment:"支付平台app/h5/pc/wx_h5/wx_mini等"`
PayChannel string `gorm:"type:varchar(20);not null;default:'alipay';index" json:"pay_channel" comment:"支付渠道alipay/wechat"`
Status PayOrderStatus `gorm:"type:varchar(20);not null;default:'pending';index" json:"status" comment:"订单状态"`
// 支付渠道返回信息
BuyerID string `gorm:"type:varchar(64)" json:"buyer_id,omitempty" comment:"买家ID支付渠道方"`
SellerID string `gorm:"type:varchar(64)" json:"seller_id,omitempty" comment:"卖家ID支付渠道方"`
PayAmount decimal.Decimal `gorm:"type:decimal(20,8)" json:"pay_amount,omitempty" comment:"实际支付金额"`
ReceiptAmount decimal.Decimal `gorm:"type:decimal(20,8)" json:"receipt_amount,omitempty" comment:"实收金额"`
// 回调信息
NotifyTime *time.Time `gorm:"index" json:"notify_time,omitempty" comment:"异步通知时间"`
ReturnTime *time.Time `gorm:"index" json:"return_time,omitempty" comment:"同步返回时间"`
// 错误信息
ErrorCode string `gorm:"type:varchar(64)" json:"error_code,omitempty" comment:"错误码"`
ErrorMessage string `gorm:"type:text" json:"error_message,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 (PayOrder) TableName() string {
return "typay_orders"
}
// BeforeCreate GORM钩子创建前自动生成UUID
func (p *PayOrder) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = uuid.New().String()
}
return nil
}
// IsPending 检查是否为待支付状态
func (p *PayOrder) IsPending() bool {
return p.Status == PayOrderStatusPending
}
// IsSuccess 检查是否为支付成功状态
func (p *PayOrder) IsSuccess() bool {
return p.Status == PayOrderStatusSuccess
}
// IsFailed 检查是否为支付失败状态
func (p *PayOrder) IsFailed() bool {
return p.Status == PayOrderStatusFailed
}
// IsCancelled 检查是否为已取消状态
func (p *PayOrder) IsCancelled() bool {
return p.Status == PayOrderStatusCancelled
}
// IsClosed 检查是否为已关闭状态
func (p *PayOrder) IsClosed() bool {
return p.Status == PayOrderStatusClosed
}
// MarkSuccess 标记为支付成功
func (p *PayOrder) MarkSuccess(tradeNo, buyerID, sellerID string, payAmount, receiptAmount decimal.Decimal) {
p.Status = PayOrderStatusSuccess
p.TradeNo = &tradeNo
p.BuyerID = buyerID
p.SellerID = sellerID
p.PayAmount = payAmount
p.ReceiptAmount = receiptAmount
now := time.Now()
p.NotifyTime = &now
}
// MarkFailed 标记为支付失败
func (p *PayOrder) MarkFailed(errorCode, errorMessage string) {
p.Status = PayOrderStatusFailed
p.ErrorCode = errorCode
p.ErrorMessage = errorMessage
}
// MarkCancelled 标记为已取消
func (p *PayOrder) MarkCancelled() {
p.Status = PayOrderStatusCancelled
}
// MarkClosed 标记为已关闭
func (p *PayOrder) MarkClosed() {
p.Status = PayOrderStatusClosed
}
// NewPayOrder 通用工厂方法 - 创建支付订单(支持多支付渠道)
func NewPayOrder(rechargeID, outTradeNo, subject string, amount decimal.Decimal, platform, payChannel string) *PayOrder {
return &PayOrder{
ID: uuid.New().String(),
RechargeID: rechargeID,
OutTradeNo: outTradeNo,
Subject: subject,
Amount: amount,
Platform: platform,
PayChannel: payChannel,
Status: PayOrderStatusPending,
}
}

View File

@@ -14,6 +14,7 @@ type RechargeType string
const (
RechargeTypeAlipay RechargeType = "alipay" // 支付宝充值
RechargeTypeWechat RechargeType = "wechat" // 微信充值
RechargeTypeTransfer RechargeType = "transfer" // 对公转账
RechargeTypeGift RechargeType = "gift" // 赠送
)
@@ -42,6 +43,7 @@ type RechargeRecord struct {
// 订单号字段(根据充值类型使用不同字段)
AlipayOrderID *string `gorm:"type:varchar(64);uniqueIndex" json:"alipay_order_id,omitempty" comment:"支付宝订单号"`
WechatOrderID *string `gorm:"type:varchar(64);uniqueIndex" json:"wechat_order_id,omitempty" comment:"微信订单号"`
TransferOrderID *string `gorm:"type:varchar(64);uniqueIndex" json:"transfer_order_id,omitempty" comment:"转账订单号"`
// 通用字段
@@ -104,14 +106,24 @@ func (r *RechargeRecord) MarkCancelled() {
// ValidatePaymentMethod 验证支付方式:支付宝订单号和转账订单号只能有一个存在
func (r *RechargeRecord) ValidatePaymentMethod() error {
hasAlipay := r.AlipayOrderID != nil && *r.AlipayOrderID != ""
hasWechat := r.WechatOrderID != nil && *r.WechatOrderID != ""
hasTransfer := r.TransferOrderID != nil && *r.TransferOrderID != ""
if hasAlipay && hasTransfer {
return errors.New("支付宝订单号和转账订单号不能同时存在")
count := 0
if hasAlipay {
count++
}
if !hasAlipay && !hasTransfer {
return errors.New("必须提供支付宝订单号或转账订单号")
if hasWechat {
count++
}
if hasTransfer {
count++
}
if count > 1 {
return errors.New("支付宝、微信或转账订单号只能存在一个")
}
if count == 0 {
return errors.New("必须提供支付宝、微信或转账订单号")
}
return nil
@@ -124,6 +136,10 @@ func (r *RechargeRecord) GetOrderID() string {
if r.AlipayOrderID != nil {
return *r.AlipayOrderID
}
case RechargeTypeWechat:
if r.WechatOrderID != nil {
return *r.WechatOrderID
}
case RechargeTypeTransfer:
if r.TransferOrderID != nil {
return *r.TransferOrderID
@@ -137,6 +153,11 @@ func (r *RechargeRecord) SetAlipayOrderID(orderID string) {
r.AlipayOrderID = &orderID
}
// SetWechatOrderID 设置微信订单号
func (r *RechargeRecord) SetWechatOrderID(orderID string) {
r.WechatOrderID = &orderID
}
// SetTransferOrderID 设置转账订单号
func (r *RechargeRecord) SetTransferOrderID(orderID string) {
r.TransferOrderID = &orderID
@@ -144,12 +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

@@ -0,0 +1,33 @@
package entities
import "github.com/shopspring/decimal"
// WechatOrderStatus 微信订单状态枚举(别名)
type WechatOrderStatus = PayOrderStatus
const (
WechatOrderStatusPending WechatOrderStatus = PayOrderStatusPending // 待支付
WechatOrderStatusSuccess WechatOrderStatus = PayOrderStatusSuccess // 支付成功
WechatOrderStatusFailed WechatOrderStatus = PayOrderStatusFailed // 支付失败
WechatOrderStatusCancelled WechatOrderStatus = PayOrderStatusCancelled // 已取消
WechatOrderStatusClosed WechatOrderStatus = PayOrderStatusClosed // 已关闭
)
const (
WechatOrderPlatformApp = "app" // 微信APP支付
WechatOrderPlatformH5 = "h5" // 微信H5支付
WechatOrderPlatformMini = "mini" // 微信小程序支付
)
// WechatOrder 微信订单实体(统一表 typay_orders兼容多支付渠道
type WechatOrder = PayOrder
// NewWechatOrder 工厂方法 - 创建微信订单(统一表 typay_orders
func NewWechatOrder(rechargeID, outTradeNo, subject string, amount decimal.Decimal, platform string) *WechatOrder {
return NewPayOrder(rechargeID, outTradeNo, subject, amount, platform, "wechat")
}
// NewWechatPayOrder 工厂方法 - 创建微信支付订单(别名,保持向后兼容)
func NewWechatPayOrder(rechargeID, outTradeNo, subject string, amount decimal.Decimal, platform string) *WechatOrder {
return NewWechatOrder(rechargeID, outTradeNo, subject, amount, platform)
}

View File

@@ -0,0 +1,20 @@
package repositories
import (
"context"
"tyapi-server/internal/domains/finance/entities"
)
// WechatOrderRepository 微信订单仓储接口
type WechatOrderRepository interface {
Create(ctx context.Context, order entities.WechatOrder) (entities.WechatOrder, error)
GetByID(ctx context.Context, id string) (entities.WechatOrder, error)
GetByOutTradeNo(ctx context.Context, outTradeNo string) (*entities.WechatOrder, error)
GetByRechargeID(ctx context.Context, rechargeID string) (*entities.WechatOrder, error)
GetByUserID(ctx context.Context, userID string) ([]entities.WechatOrder, error)
Update(ctx context.Context, order entities.WechatOrder) error
UpdateStatus(ctx context.Context, id string, status entities.WechatOrderStatus) error
Delete(ctx context.Context, id string) error
Exists(ctx context.Context, id string) (bool, error)
}

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

@@ -0,0 +1,65 @@
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)" 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" comment:"创建时间"`
UpdatedAt time.Time `gorm:"autoUpdateTime" comment:"更新时间"`
DeletedAt gorm.DeletedAt `gorm:"index" 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
}
// IsPaid 检查是否已支付
func (c *ComponentReportDownload) IsPaid() 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.IsPaid() && !c.IsExpired()
}

View File

@@ -20,11 +20,14 @@ type Product struct {
Price decimal.Decimal `gorm:"type:decimal(10,2);not null;default:0" comment:"产品价格"`
CostPrice decimal.Decimal `gorm:"type:decimal(10,2);default:0" comment:"成本价"`
Remark string `gorm:"type:text" comment:"备注"`
IsEnabled bool `gorm:"default:true" comment:"是否启用"`
IsVisible bool `gorm:"default:true" comment:"是否展示"`
IsEnabled bool `gorm:"default:false" comment:"是否启用"`
IsVisible bool `gorm:"default:false" comment:"是否展示"`
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描述"`
@@ -62,7 +65,6 @@ func (p *Product) CanBeSubscribed() bool {
return p.IsValid()
}
// UpdateSEO 更新SEO信息
func (p *Product) UpdateSEO(title, description, keywords string) {
p.SEOTitle = title

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

@@ -0,0 +1,32 @@
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)
}

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

@@ -79,6 +79,7 @@ func (s *ProductManagementService) GetProductByCode(ctx context.Context, product
}
return product, nil
}
// GetProductWithCategory 获取产品及其分类信息
func (s *ProductManagementService) GetProductWithCategory(ctx context.Context, productID string) (*entities.Product, error) {
product, err := s.productRepo.GetByID(ctx, productID)

View File

@@ -20,7 +20,6 @@ type UserStats struct {
// UserRepository 用户仓储接口
type UserRepository interface {
interfaces.Repository[entities.User]
// 基础查询 - 直接使用实体
GetByPhone(ctx context.Context, phone string) (*entities.User, error)
GetByUsername(ctx context.Context, username string) (*entities.User, error)
@@ -54,6 +53,8 @@ type UserRepository interface {
GetSystemUserStatsByDateRange(ctx context.Context, startDate, endDate time.Time) (*UserStats, error)
GetSystemDailyUserStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemMonthlyUserStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemDailyCertificationStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemMonthlyCertificationStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
// 排行榜查询方法
GetUserCallRankingByCalls(ctx context.Context, period string, limit int) ([]map[string]interface{}, error)

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
"strings"
"time"
"tyapi-server/internal/domains/api/entities"
"tyapi-server/internal/domains/api/repositories"
@@ -229,6 +230,11 @@ func (r *GormApiCallRepository) CountByUserId(ctx context.Context, userId string
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ?", userId)
}
// CountByUserIdAndProductId 按用户ID和产品ID统计API调用次数
func (r *GormApiCallRepository) CountByUserIdAndProductId(ctx context.Context, userId string, productId string) (int64, error) {
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ? AND product_id = ?", userId, productId)
}
// CountByUserIdAndDateRange 按用户ID和日期范围统计API调用次数
func (r *GormApiCallRepository) CountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (int64, error) {
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ? AND created_at >= ? AND created_at < ?", userId, startDate, endDate)
@@ -304,8 +310,29 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
// 应用筛选条件
if filters != nil {
// 用户ID筛选
if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 用户ID筛选支持单个user_id和多个user_ids
// 如果同时存在优先使用user_ids批量查询
if userIds, ok := filters["user_ids"].(string); ok && userIds != "" {
// 解析逗号分隔的用户ID列表
userIdsList := strings.Split(userIds, ",")
// 去除空白字符
var cleanUserIds []string
for _, id := range userIdsList {
id = strings.TrimSpace(id)
if id != "" {
cleanUserIds = append(cleanUserIds, id)
}
}
if len(cleanUserIds) > 0 {
placeholders := strings.Repeat("?,", len(cleanUserIds))
placeholders = placeholders[:len(placeholders)-1] // 移除最后一个逗号
whereCondition += " AND ac.user_id IN (" + placeholders + ")"
for _, id := range cleanUserIds {
whereArgs = append(whereArgs, id)
}
}
} else if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 单个用户ID筛选
whereCondition += " AND ac.user_id = ?"
whereArgs = append(whereArgs, userId)
}
@@ -404,10 +431,11 @@ func (r *GormApiCallRepository) GetSystemTotalCalls(ctx context.Context) (int64,
}
// GetSystemCallsByDateRange 获取系统指定时间范围内的API调用次数
// endDate 应该是结束日期当天的次日00:00:00日统计或下个月1号00:00:00月统计使用 < 而不是 <=
func (r *GormApiCallRepository) GetSystemCallsByDateRange(ctx context.Context, startDate, endDate time.Time) (int64, error) {
var count int64
err := r.GetDB(ctx).Model(&entities.ApiCall{}).
Where("created_at >= ? AND created_at <= ?", startDate, endDate).
Where("created_at >= ? AND created_at < ?", startDate, endDate).
Count(&count).Error
return count, err
}
@@ -445,7 +473,7 @@ func (r *GormApiCallRepository) GetSystemMonthlyStats(ctx context.Context, start
COUNT(*) as calls
FROM api_calls
WHERE created_at >= $1
AND created_at <= $2
AND created_at < $2
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`

View File

@@ -0,0 +1,328 @@
package repositories
import (
"context"
"fmt"
"strings"
"time"
"tyapi-server/internal/domains/article/entities"
"tyapi-server/internal/domains/article/repositories"
repoQueries "tyapi-server/internal/domains/article/repositories/queries"
"tyapi-server/internal/shared/interfaces"
"go.uber.org/zap"
"gorm.io/gorm"
)
// GormAnnouncementRepository GORM公告仓储实现
type GormAnnouncementRepository struct {
db *gorm.DB
logger *zap.Logger
}
// 编译时检查接口实现
var _ repositories.AnnouncementRepository = (*GormAnnouncementRepository)(nil)
// NewGormAnnouncementRepository 创建GORM公告仓储
func NewGormAnnouncementRepository(db *gorm.DB, logger *zap.Logger) *GormAnnouncementRepository {
return &GormAnnouncementRepository{
db: db,
logger: logger,
}
}
// Create 创建公告
func (r *GormAnnouncementRepository) Create(ctx context.Context, entity entities.Announcement) (entities.Announcement, error) {
r.logger.Info("创建公告", zap.String("id", entity.ID), zap.String("title", entity.Title))
err := r.db.WithContext(ctx).Create(&entity).Error
if err != nil {
r.logger.Error("创建公告失败", zap.Error(err))
return entity, err
}
return entity, nil
}
// GetByID 根据ID获取公告
func (r *GormAnnouncementRepository) GetByID(ctx context.Context, id string) (entities.Announcement, error) {
var entity entities.Announcement
err := r.db.WithContext(ctx).
Where("id = ?", id).
First(&entity).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return entity, fmt.Errorf("公告不存在")
}
r.logger.Error("获取公告失败", zap.String("id", id), zap.Error(err))
return entity, err
}
return entity, nil
}
// Update 更新公告
func (r *GormAnnouncementRepository) Update(ctx context.Context, entity entities.Announcement) error {
r.logger.Info("更新公告", zap.String("id", entity.ID))
err := r.db.WithContext(ctx).Save(&entity).Error
if err != nil {
r.logger.Error("更新公告失败", zap.String("id", entity.ID), zap.Error(err))
return err
}
return nil
}
// Delete 删除公告
func (r *GormAnnouncementRepository) Delete(ctx context.Context, id string) error {
r.logger.Info("删除公告", zap.String("id", id))
err := r.db.WithContext(ctx).Delete(&entities.Announcement{}, "id = ?", id).Error
if err != nil {
r.logger.Error("删除公告失败", zap.String("id", id), zap.Error(err))
return err
}
return nil
}
// FindByStatus 根据状态查找公告
func (r *GormAnnouncementRepository) FindByStatus(ctx context.Context, status entities.AnnouncementStatus) ([]*entities.Announcement, error) {
var announcements []entities.Announcement
err := r.db.WithContext(ctx).
Where("status = ?", status).
Order("created_at DESC").
Find(&announcements).Error
if err != nil {
r.logger.Error("根据状态查找公告失败", zap.String("status", string(status)), zap.Error(err))
return nil, err
}
// 转换为指针切片
result := make([]*entities.Announcement, len(announcements))
for i := range announcements {
result[i] = &announcements[i]
}
return result, nil
}
// FindScheduled 查找定时发布的公告
func (r *GormAnnouncementRepository) FindScheduled(ctx context.Context) ([]*entities.Announcement, error) {
var announcements []entities.Announcement
now := time.Now()
err := r.db.WithContext(ctx).
Where("status = ? AND scheduled_at IS NOT NULL AND scheduled_at <= ?", entities.AnnouncementStatusDraft, now).
Order("scheduled_at ASC").
Find(&announcements).Error
if err != nil {
r.logger.Error("查找定时发布公告失败", zap.Error(err))
return nil, err
}
// 转换为指针切片
result := make([]*entities.Announcement, len(announcements))
for i := range announcements {
result[i] = &announcements[i]
}
return result, nil
}
// ListAnnouncements 获取公告列表
func (r *GormAnnouncementRepository) ListAnnouncements(ctx context.Context, query *repoQueries.ListAnnouncementQuery) ([]*entities.Announcement, int64, error) {
var announcements []entities.Announcement
var total int64
dbQuery := r.db.WithContext(ctx).Model(&entities.Announcement{})
// 应用筛选条件
if query.Status != "" {
dbQuery = dbQuery.Where("status = ?", query.Status)
}
if query.Title != "" {
dbQuery = dbQuery.Where("title ILIKE ?", "%"+query.Title+"%")
}
// 获取总数
if err := dbQuery.Count(&total).Error; err != nil {
r.logger.Error("获取公告列表总数失败", zap.Error(err))
return nil, 0, err
}
// 应用排序
if query.OrderBy != "" {
orderDir := "DESC"
if query.OrderDir != "" {
orderDir = strings.ToUpper(query.OrderDir)
}
dbQuery = dbQuery.Order(fmt.Sprintf("%s %s", query.OrderBy, orderDir))
} else {
dbQuery = dbQuery.Order("created_at DESC")
}
// 应用分页
if query.Page > 0 && query.PageSize > 0 {
offset := (query.Page - 1) * query.PageSize
dbQuery = dbQuery.Offset(offset).Limit(query.PageSize)
}
// 获取数据
if err := dbQuery.Find(&announcements).Error; err != nil {
r.logger.Error("获取公告列表失败", zap.Error(err))
return nil, 0, err
}
// 转换为指针切片
result := make([]*entities.Announcement, len(announcements))
for i := range announcements {
result[i] = &announcements[i]
}
return result, total, nil
}
// CountByStatus 根据状态统计公告数量
func (r *GormAnnouncementRepository) CountByStatus(ctx context.Context, status entities.AnnouncementStatus) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&entities.Announcement{}).
Where("status = ?", status).
Count(&count).Error
if err != nil {
r.logger.Error("统计公告数量失败", zap.String("status", string(status)), zap.Error(err))
return 0, err
}
return count, nil
}
// UpdateStatistics 更新统计信息
// 注意:公告实体目前没有统计字段,此方法预留扩展
func (r *GormAnnouncementRepository) UpdateStatistics(ctx context.Context, announcementID string) error {
r.logger.Info("更新公告统计信息", zap.String("announcement_id", announcementID))
// TODO: 如果将来需要统计字段(如阅读量等),可以在这里实现
return nil
}
// ================ 实现 BaseRepository 接口的其他方法 ================
// Count 统计数量
func (r *GormAnnouncementRepository) Count(ctx context.Context, options interfaces.CountOptions) (int64, error) {
dbQuery := r.db.WithContext(ctx).Model(&entities.Announcement{})
// 应用筛选条件
if options.Filters != nil {
for key, value := range options.Filters {
dbQuery = dbQuery.Where(key+" = ?", value)
}
}
if options.Search != "" {
search := "%" + options.Search + "%"
dbQuery = dbQuery.Where("title LIKE ? OR content LIKE ?", search, search)
}
var count int64
err := dbQuery.Count(&count).Error
return count, err
}
// Exists 检查是否存在
func (r *GormAnnouncementRepository) Exists(ctx context.Context, id string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&entities.Announcement{}).
Where("id = ?", id).
Count(&count).Error
return count > 0, err
}
// SoftDelete 软删除
func (r *GormAnnouncementRepository) SoftDelete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&entities.Announcement{}, "id = ?", id).Error
}
// Restore 恢复软删除
func (r *GormAnnouncementRepository) Restore(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Unscoped().Model(&entities.Announcement{}).
Where("id = ?", id).
Update("deleted_at", nil).Error
}
// CreateBatch 批量创建
func (r *GormAnnouncementRepository) CreateBatch(ctx context.Context, entities []entities.Announcement) error {
return r.db.WithContext(ctx).Create(&entities).Error
}
// GetByIDs 根据ID列表获取
func (r *GormAnnouncementRepository) GetByIDs(ctx context.Context, ids []string) ([]entities.Announcement, error) {
var announcements []entities.Announcement
err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&announcements).Error
return announcements, err
}
// UpdateBatch 批量更新
func (r *GormAnnouncementRepository) UpdateBatch(ctx context.Context, entities []entities.Announcement) error {
return r.db.WithContext(ctx).Save(&entities).Error
}
// DeleteBatch 批量删除
func (r *GormAnnouncementRepository) DeleteBatch(ctx context.Context, ids []string) error {
return r.db.WithContext(ctx).Delete(&entities.Announcement{}, "id IN ?", ids).Error
}
// List 列表查询
func (r *GormAnnouncementRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.Announcement, error) {
var announcements []entities.Announcement
dbQuery := r.db.WithContext(ctx).Model(&entities.Announcement{})
// 应用筛选条件
if options.Filters != nil {
for key, value := range options.Filters {
dbQuery = dbQuery.Where(key+" = ?", value)
}
}
if options.Search != "" {
search := "%" + options.Search + "%"
dbQuery = dbQuery.Where("title LIKE ? OR content LIKE ?", search, search)
}
// 应用排序
if options.Sort != "" {
order := "DESC"
if options.Order != "" {
order = strings.ToUpper(options.Order)
}
dbQuery = dbQuery.Order(fmt.Sprintf("%s %s", options.Sort, order))
} else {
dbQuery = dbQuery.Order("created_at DESC")
}
// 应用分页
if options.Page > 0 && options.PageSize > 0 {
offset := (options.Page - 1) * options.PageSize
dbQuery = dbQuery.Offset(offset).Limit(options.PageSize)
}
// 预加载关联数据
if len(options.Include) > 0 {
for _, include := range options.Include {
dbQuery = dbQuery.Preload(include)
}
}
err := dbQuery.Find(&announcements).Error
return announcements, err
}

View File

@@ -13,7 +13,7 @@ import (
)
const (
AlipayOrdersTable = "alipay_orders"
AlipayOrdersTable = "typay_orders"
)
type GormAlipayOrderRepository struct {
@@ -72,9 +72,9 @@ func (r *GormAlipayOrderRepository) GetByRechargeID(ctx context.Context, recharg
func (r *GormAlipayOrderRepository) GetByUserID(ctx context.Context, userID string) ([]entities.AlipayOrder, error) {
var orders []entities.AlipayOrder
err := r.GetDB(ctx).
Joins("JOIN recharge_records ON alipay_orders.recharge_id = recharge_records.id").
Joins("JOIN recharge_records ON typay_orders.recharge_id = recharge_records.id").
Where("recharge_records.user_id = ?", userID).
Order("alipay_orders.created_at DESC").
Order("typay_orders.created_at DESC").
Find(&orders).Error
return orders, err
}

View File

@@ -89,37 +89,86 @@ func (r *GormRechargeRecordRepository) UpdateStatus(ctx context.Context, id stri
func (r *GormRechargeRecordRepository) Count(ctx context.Context, options interfaces.CountOptions) (int64, error) {
var count int64
query := r.GetDB(ctx).Model(&entities.RechargeRecord{})
// 检查是否有 company_name 筛选,如果有则需要 JOIN 表
hasCompanyNameFilter := false
if options.Filters != nil {
if companyName, ok := options.Filters["company_name"].(string); ok && companyName != "" {
hasCompanyNameFilter = true
}
}
var query *gorm.DB
if hasCompanyNameFilter {
// 使用 JOIN 查询以支持企业名称筛选
query = r.GetDB(ctx).Table("recharge_records rr").
Joins("LEFT JOIN users u ON rr.user_id = u.id").
Joins("LEFT JOIN enterprise_infos ei ON u.id = ei.user_id")
} else {
// 普通查询
query = r.GetDB(ctx).Model(&entities.RechargeRecord{})
}
if options.Filters != nil {
for key, value := range options.Filters {
// 特殊处理时间范围过滤器
if key == "start_time" {
if startTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at >= ?", startTime)
} else {
query = query.Where("created_at >= ?", startTime)
}
}
} else if key == "end_time" {
if endTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at <= ?", endTime)
} else {
query = query.Where("created_at <= ?", endTime)
}
}
} else if key == "company_name" {
// 处理企业名称筛选
if companyName, ok := value.(string); ok && companyName != "" {
query = query.Where("ei.company_name LIKE ?", "%"+companyName+"%")
}
} else if key == "min_amount" {
// 处理最小金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount >= ?", amount)
} else {
query = query.Where("amount >= ?", amount)
}
}
} else if key == "max_amount" {
// 处理最大金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount <= ?", amount)
} else {
query = query.Where("amount <= ?", amount)
}
}
} else {
// 其他过滤器使用等值查询
if hasCompanyNameFilter {
query = query.Where("rr."+key+" = ?", value)
} else {
query = query.Where(key+" = ?", value)
}
}
}
}
if options.Search != "" {
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
if hasCompanyNameFilter {
query = query.Where("rr.user_id LIKE ? OR rr.transfer_order_id LIKE ? OR rr.alipay_order_id LIKE ? OR rr.wechat_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
} else {
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ? OR wechat_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
}
}
return count, query.Count(&count).Error
}
@@ -132,45 +181,98 @@ func (r *GormRechargeRecordRepository) Exists(ctx context.Context, id string) (b
func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfaces.ListOptions) ([]entities.RechargeRecord, error) {
var records []entities.RechargeRecord
query := r.GetDB(ctx).Model(&entities.RechargeRecord{})
// 检查是否有 company_name 筛选,如果有则需要 JOIN 表
hasCompanyNameFilter := false
if options.Filters != nil {
if companyName, ok := options.Filters["company_name"].(string); ok && companyName != "" {
hasCompanyNameFilter = true
}
}
var query *gorm.DB
if hasCompanyNameFilter {
// 使用 JOIN 查询以支持企业名称筛选
query = r.GetDB(ctx).Table("recharge_records rr").
Select("rr.*").
Joins("LEFT JOIN users u ON rr.user_id = u.id").
Joins("LEFT JOIN enterprise_infos ei ON u.id = ei.user_id")
} else {
// 普通查询
query = r.GetDB(ctx).Model(&entities.RechargeRecord{})
}
if options.Filters != nil {
for key, value := range options.Filters {
// 特殊处理 user_ids 过滤器
if key == "user_ids" {
if userIds, ok := value.(string); ok && userIds != "" {
if hasCompanyNameFilter {
query = query.Where("rr.user_id IN ?", strings.Split(userIds, ","))
} else {
query = query.Where("user_id IN ?", strings.Split(userIds, ","))
}
}
} else if key == "company_name" {
// 处理企业名称筛选
if companyName, ok := value.(string); ok && companyName != "" {
query = query.Where("ei.company_name LIKE ?", "%"+companyName+"%")
}
} else if key == "start_time" {
// 处理开始时间范围
if startTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at >= ?", startTime)
} else {
query = query.Where("created_at >= ?", startTime)
}
}
} else if key == "end_time" {
// 处理结束时间范围
if endTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at <= ?", endTime)
} else {
query = query.Where("created_at <= ?", endTime)
}
}
} else if key == "min_amount" {
// 处理最小金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount >= ?", amount)
} else {
query = query.Where("amount >= ?", amount)
}
}
} else if key == "max_amount" {
// 处理最大金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount <= ?", amount)
} else {
query = query.Where("amount <= ?", amount)
}
}
} else {
// 其他过滤器使用等值查询
if hasCompanyNameFilter {
query = query.Where("rr."+key+" = ?", value)
} else {
query = query.Where(key+" = ?", value)
}
}
}
}
if options.Search != "" {
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
if hasCompanyNameFilter {
query = query.Where("rr.user_id LIKE ? OR rr.transfer_order_id LIKE ? OR rr.alipay_order_id LIKE ? OR rr.wechat_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
} else {
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ? OR wechat_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
}
}
if options.Sort != "" {
@@ -178,10 +280,18 @@ func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfa
if options.Order == "desc" || options.Order == "DESC" {
order = "DESC"
}
if hasCompanyNameFilter {
query = query.Order("rr." + options.Sort + " " + order)
} else {
query = query.Order(options.Sort + " " + order)
}
} else {
if hasCompanyNameFilter {
query = query.Order("rr.created_at DESC")
} else {
query = query.Order("created_at DESC")
}
}
if options.Page > 0 && options.PageSize > 0 {
offset := (options.Page - 1) * options.PageSize
@@ -316,16 +426,18 @@ func (r *GormRechargeRecordRepository) GetSystemTotalAmount(ctx context.Context)
}
// GetSystemAmountByDateRange 获取系统指定时间范围内的充值金额(排除赠送)
// endDate 应该是结束日期当天的次日00:00:00日统计或下个月1号00:00:00月统计使用 < 而不是 <=
func (r *GormRechargeRecordRepository) GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error) {
var total float64
err := r.GetDB(ctx).Model(&entities.RechargeRecord{}).
Where("status = ? AND recharge_type != ? AND created_at >= ? AND created_at <= ?", entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).
Where("status = ? AND recharge_type != ? AND created_at >= ? AND created_at < ?", entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
return total, err
}
// GetSystemDailyStats 获取系统每日充值统计(排除赠送)
// startDate 和 endDate 应该是时间对象endDate 应该是结束日期当天的次日00:00:00使用 < 而不是 <=
func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
@@ -336,13 +448,13 @@ func (r *GormRechargeRecordRepository) GetSystemDailyStats(ctx context.Context,
FROM recharge_records
WHERE status = ?
AND recharge_type != ?
AND DATE(created_at) >= ?
AND DATE(created_at) <= ?
AND created_at >= ?
AND created_at < ?
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
@@ -362,7 +474,7 @@ func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context
WHERE status = ?
AND recharge_type != ?
AND created_at >= ?
AND created_at <= ?
AND created_at < ?
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`

View File

@@ -382,12 +382,26 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
// 应用筛选条件
if filters != nil {
// 用户ID筛选
if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 用户ID筛选(支持单个和多个)
if userIds, ok := filters["user_ids"].(string); ok && userIds != "" {
// 多个用户ID逗号分隔
userIdsList := strings.Split(userIds, ",")
whereCondition += " AND wt.user_id IN ?"
whereArgs = append(whereArgs, userIdsList)
} else if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 单个用户ID
whereCondition += " AND wt.user_id = ?"
whereArgs = append(whereArgs, userId)
}
// 产品ID筛选支持多个
if productIds, ok := filters["product_ids"].(string); ok && productIds != "" {
// 多个产品ID逗号分隔
productIdsList := strings.Split(productIds, ",")
whereCondition += " AND wt.product_id IN ?"
whereArgs = append(whereArgs, productIdsList)
}
// 时间范围筛选
if startTime, ok := filters["start_time"].(time.Time); ok {
whereCondition += " AND wt.created_at >= ?"
@@ -572,10 +586,11 @@ func (r *GormWalletTransactionRepository) GetSystemTotalAmount(ctx context.Conte
}
// GetSystemAmountByDateRange 获取系统指定时间范围内的消费金额
// endDate 应该是结束日期当天的次日00:00:00日统计或下个月1号00:00:00月统计使用 < 而不是 <=
func (r *GormWalletTransactionRepository) GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error) {
var total float64
err := r.GetDB(ctx).Model(&entities.WalletTransaction{}).
Where("created_at >= ? AND created_at <= ?", startDate, endDate).
Where("created_at >= ? AND created_at < ?", startDate, endDate).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
return total, err
@@ -614,7 +629,7 @@ func (r *GormWalletTransactionRepository) GetSystemMonthlyStats(ctx context.Cont
COALESCE(SUM(amount), 0) as amount
FROM wallet_transactions
WHERE created_at >= ?
AND created_at <= ?
AND created_at < ?
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`

View File

@@ -0,0 +1,93 @@
package repositories
import (
"context"
"errors"
"tyapi-server/internal/domains/finance/entities"
domain_finance_repo "tyapi-server/internal/domains/finance/repositories"
"tyapi-server/internal/shared/database"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
WechatOrdersTable = "typay_orders"
)
type GormWechatOrderRepository struct {
*database.CachedBaseRepositoryImpl
}
var _ domain_finance_repo.WechatOrderRepository = (*GormWechatOrderRepository)(nil)
func NewGormWechatOrderRepository(db *gorm.DB, logger *zap.Logger) domain_finance_repo.WechatOrderRepository {
return &GormWechatOrderRepository{
CachedBaseRepositoryImpl: database.NewCachedBaseRepositoryImpl(db, logger, WechatOrdersTable),
}
}
func (r *GormWechatOrderRepository) Create(ctx context.Context, order entities.WechatOrder) (entities.WechatOrder, error) {
err := r.CreateEntity(ctx, &order)
return order, err
}
func (r *GormWechatOrderRepository) GetByID(ctx context.Context, id string) (entities.WechatOrder, error) {
var order entities.WechatOrder
err := r.SmartGetByID(ctx, id, &order)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return entities.WechatOrder{}, gorm.ErrRecordNotFound
}
return entities.WechatOrder{}, err
}
return order, nil
}
func (r *GormWechatOrderRepository) GetByOutTradeNo(ctx context.Context, outTradeNo string) (*entities.WechatOrder, error) {
var order entities.WechatOrder
err := r.GetDB(ctx).Where("out_trade_no = ?", outTradeNo).First(&order).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (r *GormWechatOrderRepository) GetByRechargeID(ctx context.Context, rechargeID string) (*entities.WechatOrder, error) {
var order entities.WechatOrder
err := r.GetDB(ctx).Where("recharge_id = ?", rechargeID).First(&order).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &order, nil
}
func (r *GormWechatOrderRepository) GetByUserID(ctx context.Context, userID string) ([]entities.WechatOrder, error) {
var orders []entities.WechatOrder
// 需要通过充值记录关联查询,这里简化处理
err := r.GetDB(ctx).Find(&orders).Error
return orders, err
}
func (r *GormWechatOrderRepository) Update(ctx context.Context, order entities.WechatOrder) error {
return r.UpdateEntity(ctx, &order)
}
func (r *GormWechatOrderRepository) UpdateStatus(ctx context.Context, id string, status entities.WechatOrderStatus) error {
return r.GetDB(ctx).Model(&entities.WechatOrder{}).Where("id = ?", id).Update("status", status).Error
}
func (r *GormWechatOrderRepository) Delete(ctx context.Context, id string) error {
return r.DeleteEntity(ctx, id, &entities.WechatOrder{})
}
func (r *GormWechatOrderRepository) Exists(ctx context.Context, id string) (bool, error) {
return r.ExistsEntity(ctx, id, &entities.WechatOrder{})
}

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

@@ -458,6 +458,54 @@ func (r *GormUserRepository) GetSystemMonthlyUserStats(ctx context.Context, star
return results, nil
}
// GetSystemDailyCertificationStats 获取系统每日认证用户统计基于is_certified字段
func (r *GormUserRepository) GetSystemDailyCertificationStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
DATE(updated_at) as date,
COUNT(*) as count
FROM users
WHERE is_certified = true
AND DATE(updated_at) >= $1
AND DATE(updated_at) <= $2
GROUP BY DATE(updated_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetSystemMonthlyCertificationStats 获取系统每月认证用户统计基于is_certified字段
func (r *GormUserRepository) GetSystemMonthlyCertificationStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
TO_CHAR(updated_at, 'YYYY-MM') as month,
COUNT(*) as count
FROM users
WHERE is_certified = true
AND updated_at >= $1
AND updated_at <= $2
GROUP BY TO_CHAR(updated_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetUserCallRankingByCalls 按调用次数获取用户排行
func (r *GormUserRepository) GetUserCallRankingByCalls(ctx context.Context, period string, limit int) ([]map[string]interface{}, error) {
var sql string

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

@@ -133,8 +133,7 @@ func (z *ZhichaService) CallAPI(ctx context.Context, proID string, params map[st
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
// 检查是否是网络超时错误
isTimeout = true
} else if errStr := err.Error();
errStr == "context deadline exceeded" ||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {

View File

@@ -0,0 +1,411 @@
package handlers
import (
"tyapi-server/internal/application/article"
"tyapi-server/internal/application/article/dto/commands"
appQueries "tyapi-server/internal/application/article/dto/queries"
"tyapi-server/internal/shared/interfaces"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AnnouncementHandler 公告HTTP处理器
type AnnouncementHandler struct {
appService article.AnnouncementApplicationService
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
}
// NewAnnouncementHandler 创建公告HTTP处理器
func NewAnnouncementHandler(
appService article.AnnouncementApplicationService,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
logger *zap.Logger,
) *AnnouncementHandler {
return &AnnouncementHandler{
appService: appService,
responseBuilder: responseBuilder,
validator: validator,
logger: logger,
}
}
// CreateAnnouncement 创建公告
// @Summary 创建公告
// @Description 创建新的公告
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body commands.CreateAnnouncementCommand true "创建公告请求"
// @Success 201 {object} map[string]interface{} "公告创建成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements [post]
func (h *AnnouncementHandler) CreateAnnouncement(c *gin.Context) {
var cmd commands.CreateAnnouncementCommand
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
// 验证用户是否已登录
if _, exists := c.Get("user_id"); !exists {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
if err := h.appService.CreateAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("创建公告失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Created(c, nil, "公告创建成功")
}
// GetAnnouncementByID 获取公告详情
// @Summary 获取公告详情
// @Description 根据ID获取公告详情
// @Tags 公告管理-用户端
// @Accept json
// @Produce json
// @Param id path string true "公告ID"
// @Success 200 {object} responses.AnnouncementInfoResponse "获取公告详情成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "公告不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/announcements/{id} [get]
func (h *AnnouncementHandler) GetAnnouncementByID(c *gin.Context) {
var query appQueries.GetAnnouncementQuery
// 绑定URI参数公告ID
if err := h.validator.ValidateParam(c, &query); err != nil {
return
}
response, err := h.appService.GetAnnouncementByID(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取公告详情失败", zap.Error(err))
h.responseBuilder.NotFound(c, "公告不存在")
return
}
h.responseBuilder.Success(c, response, "获取公告详情成功")
}
// ListAnnouncements 获取公告列表
// @Summary 获取公告列表
// @Description 分页获取公告列表,支持多种筛选条件
// @Tags 公告管理-用户端
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param status query string false "公告状态"
// @Param title query string false "标题关键词"
// @Param order_by query string false "排序字段"
// @Param order_dir query string false "排序方向"
// @Success 200 {object} responses.AnnouncementListResponse "获取公告列表成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/announcements [get]
func (h *AnnouncementHandler) ListAnnouncements(c *gin.Context) {
var query appQueries.ListAnnouncementQuery
if err := h.validator.ValidateQuery(c, &query); err != nil {
return
}
// 设置默认值
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 {
query.PageSize = 10
}
if query.PageSize > 100 {
query.PageSize = 100
}
response, err := h.appService.ListAnnouncements(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取公告列表失败", zap.Error(err))
h.responseBuilder.InternalError(c, "获取公告列表失败")
return
}
h.responseBuilder.Success(c, response, "获取公告列表成功")
}
// PublishAnnouncement 发布公告
// @Summary 发布公告
// @Description 发布指定的公告
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Success 200 {object} map[string]interface{} "发布成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id}/publish [post]
func (h *AnnouncementHandler) PublishAnnouncement(c *gin.Context) {
var cmd commands.PublishAnnouncementCommand
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.PublishAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("发布公告失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "发布成功")
}
// WithdrawAnnouncement 撤回公告
// @Summary 撤回公告
// @Description 撤回已发布的公告
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Success 200 {object} map[string]interface{} "撤回成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id}/withdraw [post]
func (h *AnnouncementHandler) WithdrawAnnouncement(c *gin.Context) {
var cmd commands.WithdrawAnnouncementCommand
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.WithdrawAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("撤回公告失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "撤回成功")
}
// ArchiveAnnouncement 归档公告
// @Summary 归档公告
// @Description 归档指定的公告
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Success 200 {object} map[string]interface{} "归档成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id}/archive [post]
func (h *AnnouncementHandler) ArchiveAnnouncement(c *gin.Context) {
var cmd commands.ArchiveAnnouncementCommand
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.ArchiveAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("归档公告失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "归档成功")
}
// UpdateAnnouncement 更新公告
// @Summary 更新公告
// @Description 更新指定的公告
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Param request body commands.UpdateAnnouncementCommand true "更新公告请求"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id} [put]
func (h *AnnouncementHandler) UpdateAnnouncement(c *gin.Context) {
var cmd commands.UpdateAnnouncementCommand
// 先绑定URI参数公告ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体公告信息
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.UpdateAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("更新公告失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "更新成功")
}
// DeleteAnnouncement 删除公告
// @Summary 删除公告
// @Description 删除指定的公告
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id} [delete]
func (h *AnnouncementHandler) DeleteAnnouncement(c *gin.Context) {
var cmd commands.DeleteAnnouncementCommand
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.DeleteAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("删除公告失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "删除成功")
}
// SchedulePublishAnnouncement 定时发布公告
// @Summary 定时发布公告
// @Description 设置公告的定时发布时间
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Param request body commands.SchedulePublishAnnouncementCommand true "定时发布请求"
// @Success 200 {object} map[string]interface{} "设置成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id}/schedule-publish [post]
func (h *AnnouncementHandler) SchedulePublishAnnouncement(c *gin.Context) {
var cmd commands.SchedulePublishAnnouncementCommand
// 先绑定URI参数公告ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体定时发布时间
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.SchedulePublishAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("设置定时发布失败", zap.String("id", cmd.ID), zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "设置成功")
}
// UpdateSchedulePublishAnnouncement 更新定时发布公告
// @Summary 更新定时发布公告
// @Description 修改公告的定时发布时间
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Param request body commands.UpdateSchedulePublishAnnouncementCommand true "更新定时发布请求"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id}/update-schedule-publish [post]
func (h *AnnouncementHandler) UpdateSchedulePublishAnnouncement(c *gin.Context) {
var cmd commands.UpdateSchedulePublishAnnouncementCommand
// 先绑定URI参数公告ID
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
// 再绑定JSON请求体定时发布时间
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
if err := h.appService.UpdateSchedulePublishAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("更新定时发布时间失败", zap.String("id", cmd.ID), zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "更新成功")
}
// CancelSchedulePublishAnnouncement 取消定时发布公告
// @Summary 取消定时发布公告
// @Description 取消公告的定时发布
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "公告ID"
// @Success 200 {object} map[string]interface{} "取消成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/{id}/cancel-schedule [post]
func (h *AnnouncementHandler) CancelSchedulePublishAnnouncement(c *gin.Context) {
var cmd commands.CancelSchedulePublishAnnouncementCommand
if err := h.validator.ValidateParam(c, &cmd); err != nil {
return
}
if err := h.appService.CancelSchedulePublishAnnouncement(c.Request.Context(), &cmd); err != nil {
h.logger.Error("取消定时发布失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Success(c, nil, "取消成功")
}
// GetAnnouncementStats 获取公告统计信息
// @Summary 获取公告统计信息
// @Description 获取公告的统计数据
// @Tags 公告管理-管理端
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} responses.AnnouncementStatsResponse "获取统计信息成功"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/announcements/stats [get]
func (h *AnnouncementHandler) GetAnnouncementStats(c *gin.Context) {
response, err := h.appService.GetAnnouncementStats(c.Request.Context())
if err != nil {
h.logger.Error("获取公告统计信息失败", zap.Error(err))
h.responseBuilder.InternalError(c, "获取统计信息失败")
return
}
h.responseBuilder.Success(c, response, "获取统计信息成功")
}

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

@@ -2,7 +2,9 @@
package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"time"
@@ -201,6 +203,123 @@ func (h *FinanceHandler) HandleAlipayCallback(c *gin.Context) {
c.String(200, "success")
}
// HandleWechatPayCallback 处理微信支付回调
// @Summary 微信支付回调
// @Description 处理微信支付异步通知
// @Tags 支付管理
// @Accept application/json
// @Produce text/plain
// @Success 200 {string} string "success"
// @Failure 400 {string} string "fail"
// @Router /api/v1/pay/wechat/callback [post]
func (h *FinanceHandler) HandleWechatPayCallback(c *gin.Context) {
// 记录回调请求信息
h.logger.Info("收到微信支付回调请求",
zap.String("method", c.Request.Method),
zap.String("url", c.Request.URL.String()),
zap.String("remote_addr", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
zap.String("content_type", c.GetHeader("Content-Type")),
)
// 读取请求体内容用于调试(注意:读取后需要重新设置,否则后续解析会失败)
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
h.logger.Info("微信支付回调请求体",
zap.String("body", string(bodyBytes)),
zap.Int("body_size", len(bodyBytes)),
)
// 重新设置请求体,供后续解析使用
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// 通过应用服务处理微信支付回调
err = h.appService.HandleWechatPayCallback(c.Request.Context(), c.Request)
if err != nil {
h.logger.Error("微信支付回调处理失败",
zap.Error(err),
zap.String("remote_addr", c.ClientIP()),
)
c.String(400, "fail")
return
}
h.logger.Info("微信支付回调处理成功", zap.String("remote_addr", c.ClientIP()))
// 返回成功响应微信要求返回success
c.String(200, "success")
}
// HandleWechatRefundCallback 处理微信退款回调
// @Summary 微信退款回调
// @Description 处理微信退款异步通知
// @Tags 支付管理
// @Accept application/json
// @Produce text/plain
// @Success 200 {string} string "success"
// @Failure 400 {string} string "fail"
// @Router /api/v1/wechat/refund_callback [post]
func (h *FinanceHandler) HandleWechatRefundCallback(c *gin.Context) {
// 记录回调请求信息
h.logger.Info("收到微信退款回调请求",
zap.String("method", c.Request.Method),
zap.String("url", c.Request.URL.String()),
zap.String("remote_addr", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
)
// 通过应用服务处理微信退款回调
err := h.appService.HandleWechatRefundCallback(c.Request.Context(), c.Request)
if err != nil {
h.logger.Error("微信退款回调处理失败", zap.Error(err))
c.String(400, "fail")
return
}
// 返回成功响应微信要求返回success
c.String(200, "success")
}
// GetWechatOrderStatus 获取微信订单状态
// @Summary 获取微信订单状态
// @Description 根据商户订单号查询微信订单状态
// @Tags 钱包管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param out_trade_no query string true "商户订单号"
// @Success 200 {object} responses.WechatOrderStatusResponse "获取订单状态成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "订单不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/finance/wallet/wechat-order-status [get]
func (h *FinanceHandler) GetWechatOrderStatus(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
outTradeNo := c.Query("out_trade_no")
if outTradeNo == "" {
h.responseBuilder.BadRequest(c, "缺少商户订单号")
return
}
result, err := h.appService.GetWechatOrderStatus(c.Request.Context(), outTradeNo)
if err != nil {
h.logger.Error("获取微信订单状态失败",
zap.String("user_id", userID),
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
h.responseBuilder.BadRequest(c, "获取订单状态失败: "+err.Error())
return
}
h.responseBuilder.Success(c, result, "获取订单状态成功")
}
// HandleAlipayReturn 处理支付宝同步回调
// @Summary 支付宝同步回调
// @Description 处理支付宝同步支付通知,跳转到前端成功页面
@@ -319,7 +438,6 @@ func (h *FinanceHandler) CreateAlipayRecharge(c *gin.Context) {
return
}
// 调用应用服务进行完整的业务流程编排
result, err := h.appService.CreateAlipayRechargeOrder(c.Request.Context(), &cmd)
if err != nil {
@@ -343,6 +461,53 @@ func (h *FinanceHandler) CreateAlipayRecharge(c *gin.Context) {
h.responseBuilder.Success(c, result, "支付宝充值订单创建成功")
}
// CreateWechatRecharge 创建微信充值订单
// @Summary 创建微信充值订单
// @Description 创建微信充值订单并返回预支付数据
// @Tags 钱包管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body commands.CreateWechatRechargeCommand true "微信充值请求"
// @Success 200 {object} responses.WechatRechargeOrderResponse "创建充值订单成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/finance/wallet/wechat-recharge [post]
func (h *FinanceHandler) CreateWechatRecharge(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
var cmd commands.CreateWechatRechargeCommand
cmd.UserID = userID
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
result, err := h.appService.CreateWechatRechargeOrder(c.Request.Context(), &cmd)
if err != nil {
h.logger.Error("创建微信充值订单失败",
zap.String("user_id", userID),
zap.String("amount", cmd.Amount),
zap.Error(err),
)
h.responseBuilder.BadRequest(c, "创建微信充值订单失败: "+err.Error())
return
}
h.logger.Info("微信充值订单创建成功",
zap.String("user_id", userID),
zap.String("out_trade_no", result.OutTradeNo),
zap.String("amount", cmd.Amount),
zap.String("platform", cmd.Platform),
)
h.responseBuilder.Success(c, result, "微信充值订单创建成功")
}
// TransferRecharge 管理员对公转账充值
func (h *FinanceHandler) TransferRecharge(c *gin.Context) {
var cmd commands.TransferRechargeCommand
@@ -849,8 +1014,6 @@ func (h *FinanceHandler) ApproveInvoiceApplication(c *gin.Context) {
return
}
h.responseBuilder.Success(c, nil, "通过发票申请成功")
}

View File

@@ -1,7 +1,10 @@
package handlers
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"strconv"
"strings"
"time"
"tyapi-server/internal/application/api"
"tyapi-server/internal/application/finance"
@@ -10,9 +13,6 @@ import (
"tyapi-server/internal/application/product/dto/queries"
"tyapi-server/internal/application/product/dto/responses"
"tyapi-server/internal/shared/interfaces"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// ProductAdminHandler 产品管理员HTTP处理器
@@ -72,13 +72,14 @@ func (h *ProductAdminHandler) CreateProduct(c *gin.Context) {
return
}
if err := h.productAppService.CreateProduct(c.Request.Context(), &cmd); err != nil {
result, err := h.productAppService.CreateProduct(c.Request.Context(), &cmd)
if err != nil {
h.logger.Error("创建产品失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
return
}
h.responseBuilder.Created(c, nil, "产品创建成功")
h.responseBuilder.Created(c, result, "产品创建成功")
}
// UpdateProduct 更新产品
@@ -1237,13 +1238,27 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
// 时间范围筛选
if startTime := c.Query("start_time"); startTime != "" {
// 处理URL编码的+号,转换为空格
startTime = strings.ReplaceAll(startTime, "+", " ")
if t, err := time.Parse("2006-01-02 15:04:05", startTime); err == nil {
filters["start_time"] = t
} else {
// 尝试其他格式
if t, err := time.Parse("2006-01-02T15:04:05", startTime); err == nil {
filters["start_time"] = t
}
}
}
if endTime := c.Query("end_time"); endTime != "" {
// 处理URL编码的+号,转换为空格
endTime = strings.ReplaceAll(endTime, "+", " ")
if t, err := time.Parse("2006-01-02 15:04:05", endTime); err == nil {
filters["end_time"] = t
} else {
// 尝试其他格式
if t, err := time.Parse("2006-01-02T15:04:05", endTime); err == nil {
filters["end_time"] = t
}
}
}
@@ -1262,6 +1277,11 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
filters["product_ids"] = productIds
}
// 企业名称筛选
if companyName := c.Query("company_name"); companyName != "" {
filters["company_name"] = companyName
}
// 金额范围筛选
if minAmount := c.Query("min_amount"); minAmount != "" {
filters["min_amount"] = minAmount
@@ -1281,7 +1301,16 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
fileData, err := h.financeAppService.ExportAdminWalletTransactions(c.Request.Context(), filters, format)
if err != nil {
h.logger.Error("导出消费记录失败", zap.Error(err))
h.responseBuilder.BadRequest(c, "导出消费记录失败")
// 根据错误信息返回具体的提示
errMsg := err.Error()
if strings.Contains(errMsg, "没有找到符合条件的数据") || strings.Contains(errMsg, "没有数据") {
h.responseBuilder.NotFound(c, "没有找到符合筛选条件的数据,请调整筛选条件后重试")
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
h.responseBuilder.BadRequest(c, errMsg)
} else {
h.responseBuilder.BadRequest(c, "导出消费记录失败:"+errMsg)
}
return
}
@@ -1308,7 +1337,7 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param user_id query string false "用户ID"
// @Param recharge_type query string false "充值类型" Enums(alipay, transfer, gift)
// @Param recharge_type query string false "充值类型" Enums(alipay, wechat, transfer, gift)
// @Param status query string false "状态" Enums(pending, success, failed)
// @Param min_amount query string false "最小金额"
// @Param max_amount query string false "最大金额"
@@ -1364,6 +1393,11 @@ func (h *ProductAdminHandler) GetAdminRechargeRecords(c *gin.Context) {
filters["max_amount"] = maxAmount
}
// 企业名称筛选
if companyName := c.Query("company_name"); companyName != "" {
filters["company_name"] = companyName
}
// 构建分页选项
options := interfaces.ListOptions{
Page: page,
@@ -1390,7 +1424,7 @@ func (h *ProductAdminHandler) GetAdminRechargeRecords(c *gin.Context) {
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv
// @Security Bearer
// @Param user_ids query string false "用户ID列表逗号分隔"
// @Param recharge_type query string false "充值类型" Enums(alipay, transfer, gift)
// @Param recharge_type query string false "充值类型" Enums(alipay, wechat, transfer, gift)
// @Param status query string false "状态" Enums(pending, success, failed)
// @Param start_time query string false "开始时间" format(date-time)
// @Param end_time query string false "结束时间" format(date-time)
@@ -1442,7 +1476,16 @@ func (h *ProductAdminHandler) ExportAdminRechargeRecords(c *gin.Context) {
fileData, err := h.financeAppService.ExportAdminRechargeRecords(c.Request.Context(), filters, format)
if err != nil {
h.logger.Error("导出充值记录失败", zap.Error(err))
h.responseBuilder.BadRequest(c, "导出充值记录失败")
// 根据错误信息返回具体的提示
errMsg := err.Error()
if strings.Contains(errMsg, "没有找到符合条件的数据") || strings.Contains(errMsg, "没有数据") {
h.responseBuilder.NotFound(c, "没有找到符合筛选条件的充值记录,请调整筛选条件后重试")
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
h.responseBuilder.BadRequest(c, errMsg)
} else {
h.responseBuilder.BadRequest(c, "导出充值记录失败:"+errMsg)
}
return
}
@@ -1468,7 +1511,10 @@ func (h *ProductAdminHandler) GetAdminApiCalls(c *gin.Context) {
// 构建筛选条件
filters := make(map[string]interface{})
// 用户ID筛选
// 用户ID筛选支持单个user_id和多个user_ids根据需求使用
if userId := c.Query("user_id"); userId != "" {
filters["user_id"] = userId
}
if userIds := c.Query("user_ids"); userIds != "" {
filters["user_ids"] = userIds
}
@@ -1498,15 +1544,37 @@ func (h *ProductAdminHandler) GetAdminApiCalls(c *gin.Context) {
filters["status"] = status
}
// 时间范围筛选
// 时间范围筛选 - 增强错误处理和日志
if startTime := c.Query("start_time"); startTime != "" {
// 处理URL编码的+号,转换为空格
startTime = strings.ReplaceAll(startTime, "+", " ")
if t, err := time.Parse("2006-01-02 15:04:05", startTime); err == nil {
filters["start_time"] = t
h.logger.Debug("解析start_time成功", zap.String("原始值", c.Query("start_time")), zap.Time("解析后", t))
} else {
// 尝试其他格式ISO格式
if t, err := time.Parse("2006-01-02T15:04:05", startTime); err == nil {
filters["start_time"] = t
h.logger.Debug("解析start_time成功ISO格式", zap.String("原始值", c.Query("start_time")), zap.Time("解析后", t))
} else {
h.logger.Warn("解析start_time失败", zap.String("原始值", c.Query("start_time")), zap.Error(err))
}
}
}
if endTime := c.Query("end_time"); endTime != "" {
// 处理URL编码的+号,转换为空格
endTime = strings.ReplaceAll(endTime, "+", " ")
if t, err := time.Parse("2006-01-02 15:04:05", endTime); err == nil {
filters["end_time"] = t
h.logger.Debug("解析end_time成功", zap.String("原始值", c.Query("end_time")), zap.Time("解析后", t))
} else {
// 尝试其他格式ISO格式
if t, err := time.Parse("2006-01-02T15:04:05", endTime); err == nil {
filters["end_time"] = t
h.logger.Debug("解析end_time成功ISO格式", zap.String("原始值", c.Query("end_time")), zap.Time("解析后", t))
} else {
h.logger.Warn("解析end_time失败", zap.String("原始值", c.Query("end_time")), zap.Error(err))
}
}
}
@@ -1566,7 +1634,16 @@ func (h *ProductAdminHandler) ExportAdminApiCalls(c *gin.Context) {
fileData, err := h.apiAppService.ExportAdminApiCalls(c.Request.Context(), filters, format)
if err != nil {
h.logger.Error("导出API调用记录失败", zap.Error(err))
h.responseBuilder.BadRequest(c, "导出API调用记录失败")
// 根据错误信息返回具体的提示
errMsg := err.Error()
if strings.Contains(errMsg, "没有找到符合条件的数据") || strings.Contains(errMsg, "没有数据") {
h.responseBuilder.NotFound(c, "没有找到符合筛选条件的API调用记录请调整筛选条件后重试")
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
h.responseBuilder.BadRequest(c, errMsg)
} else {
h.responseBuilder.BadRequest(c, "导出API调用记录失败"+errMsg)
}
return
}

View File

@@ -15,6 +15,7 @@ import (
"tyapi-server/internal/shared/pdf"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"go.uber.org/zap"
)
@@ -28,6 +29,7 @@ type ProductHandler struct {
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
pdfGenerator *pdf.PDFGenerator
pdfCacheManager *pdf.PDFCacheManager
logger *zap.Logger
}
@@ -41,6 +43,7 @@ func NewProductHandler(
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
pdfGenerator *pdf.PDFGenerator,
pdfCacheManager *pdf.PDFCacheManager,
logger *zap.Logger,
) *ProductHandler {
return &ProductHandler{
@@ -52,6 +55,7 @@ func NewProductHandler(
responseBuilder: responseBuilder,
validator: validator,
pdfGenerator: pdfGenerator,
pdfCacheManager: pdfCacheManager,
logger: logger,
}
}
@@ -742,6 +746,7 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
// 将响应类型转换为entity类型
var docEntity *entities.ProductDocumentation
var docVersion string
if doc != nil {
docEntity = &entities.ProductDocumentation{
ID: doc.ID,
@@ -755,12 +760,114 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
}
docVersion = doc.Version
} else {
// 如果没有文档,使用默认版本号
docVersion = "1.0"
}
// 使用数据库数据生成PDF
h.logger.Info("准备调用PDF生成器",
// 如果是组合包,获取子产品的文档信息
var subProductDocs []*entities.ProductDocumentation
if product.IsPackage && len(product.PackageItems) > 0 {
h.logger.Info("检测到组合包,开始获取子产品文档",
zap.String("product_id", productID),
zap.Int("sub_product_count", len(product.PackageItems)),
)
// 收集所有子产品的ID
subProductIDs := make([]string, 0, len(product.PackageItems))
for _, item := range product.PackageItems {
subProductIDs = append(subProductIDs, item.ProductID)
}
// 批量获取子产品的文档
subDocs, err := h.documentationAppService.GetDocumentationsByProductIDs(c.Request.Context(), subProductIDs)
if err != nil {
h.logger.Warn("获取组合包子产品文档失败",
zap.String("product_id", productID),
zap.Error(err),
)
} else {
// 转换为entity类型并按PackageItems的顺序排序
docMap := make(map[string]*entities.ProductDocumentation)
for i := range subDocs {
docMap[subDocs[i].ProductID] = &entities.ProductDocumentation{
ID: subDocs[i].ID,
ProductID: subDocs[i].ProductID,
RequestURL: subDocs[i].RequestURL,
RequestMethod: subDocs[i].RequestMethod,
BasicInfo: subDocs[i].BasicInfo,
RequestParams: subDocs[i].RequestParams,
ResponseFields: subDocs[i].ResponseFields,
ResponseExample: subDocs[i].ResponseExample,
ErrorCodes: subDocs[i].ErrorCodes,
Version: subDocs[i].Version,
}
}
// 按PackageItems的顺序构建子产品文档列表
for _, item := range product.PackageItems {
if subDoc, exists := docMap[item.ProductID]; exists {
subProductDocs = append(subProductDocs, subDoc)
}
}
h.logger.Info("成功获取组合包子产品文档",
zap.String("product_id", productID),
zap.Int("total_sub_products", len(product.PackageItems)),
zap.Int("docs_found", len(subProductDocs)),
)
}
}
// 尝试从缓存获取PDF
var pdfBytes []byte
var cacheHit bool
if h.pdfCacheManager != nil {
var cacheErr error
pdfBytes, cacheHit, cacheErr = h.pdfCacheManager.Get(productID, docVersion)
if cacheErr != nil {
h.logger.Warn("从缓存获取PDF失败将重新生成",
zap.String("product_id", productID),
zap.Error(cacheErr),
)
} else if cacheHit {
h.logger.Info("PDF缓存命中",
zap.String("product_id", productID),
zap.String("version", docVersion),
zap.Int("pdf_size", len(pdfBytes)),
)
// 直接返回缓存的PDF
fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
if product.Name == "" {
fileName = fmt.Sprintf("%s_接口文档.pdf", product.Code)
}
// 清理文件名中的非法字符
fileName = strings.ReplaceAll(fileName, "/", "_")
fileName = strings.ReplaceAll(fileName, "\\", "_")
fileName = strings.ReplaceAll(fileName, ":", "_")
fileName = strings.ReplaceAll(fileName, "*", "_")
fileName = strings.ReplaceAll(fileName, "?", "_")
fileName = strings.ReplaceAll(fileName, "\"", "_")
fileName = strings.ReplaceAll(fileName, "<", "_")
fileName = strings.ReplaceAll(fileName, ">", "_")
fileName = strings.ReplaceAll(fileName, "|", "_")
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
c.Header("X-Cache", "HIT") // 添加缓存命中标识
c.Data(http.StatusOK, "application/pdf", pdfBytes)
return
}
}
// 缓存未命中需要生成PDF
h.logger.Info("PDF缓存未命中开始生成PDF",
zap.String("product_id", productID),
zap.String("product_name", product.Name),
zap.String("version", docVersion),
zap.Bool("has_doc", docEntity != nil),
)
@@ -777,18 +884,61 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
}
}()
// 构建Product实体用于PDF生成
productEntity := &entities.Product{
ID: product.ID,
Name: product.Name,
Code: product.Code,
Description: product.Description,
Content: product.Content,
IsPackage: product.IsPackage,
Price: decimal.NewFromFloat(product.Price),
}
// 如果是组合包,添加子产品信息
if product.IsPackage && len(product.PackageItems) > 0 {
productEntity.PackageItems = make([]*entities.ProductPackageItem, len(product.PackageItems))
for i, item := range product.PackageItems {
productEntity.PackageItems[i] = &entities.ProductPackageItem{
ID: item.ID,
PackageID: product.ID,
ProductID: item.ProductID,
SortOrder: item.SortOrder,
Product: &entities.Product{
ID: item.ProductID,
Code: item.ProductCode,
Name: item.ProductName,
},
}
}
}
// 直接调用PDF生成器简化版本不使用goroutine
h.logger.Info("开始调用PDF生成器")
pdfBytes, genErr := h.pdfGenerator.GenerateProductPDF(
h.logger.Info("开始调用PDF生成器",
zap.Bool("is_package", product.IsPackage),
zap.Int("sub_product_docs_count", len(subProductDocs)),
)
// 使用重构后的生成器
refactoredGen := pdf.NewPDFGeneratorRefactored(h.logger)
var genErr error
if product.IsPackage && len(subProductDocs) > 0 {
// 组合包:使用支持子产品文档的方法
pdfBytes, genErr = refactoredGen.GenerateProductPDFWithSubProducts(
c.Request.Context(),
product.ID,
product.Name,
product.Code,
product.Description,
product.Content,
product.Price,
productEntity,
docEntity,
subProductDocs,
)
} else {
// 普通产品:使用标准方法
pdfBytes, genErr = refactoredGen.GenerateProductPDFFromEntity(
c.Request.Context(),
productEntity,
docEntity,
)
}
h.logger.Info("PDF生成器调用返回",
zap.String("product_id", productID),
zap.Bool("has_error", genErr != nil),
@@ -819,6 +969,19 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
return
}
// 保存到缓存(异步,不阻塞响应)
if h.pdfCacheManager != nil {
go func() {
if err := h.pdfCacheManager.Set(productID, docVersion, pdfBytes); err != nil {
h.logger.Warn("保存PDF到缓存失败",
zap.String("product_id", productID),
zap.String("version", docVersion),
zap.Error(err),
)
}
}()
}
// 生成文件名(清理文件名中的非法字符)
fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
if product.Name == "" {
@@ -840,11 +1003,13 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
zap.String("product_code", product.Code),
zap.String("file_name", fileName),
zap.Int("file_size", len(pdfBytes)),
zap.Bool("cached", false),
)
// 设置响应头并返回PDF文件
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
c.Header("X-Cache", "MISS") // 添加缓存未命中标识
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}

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

@@ -0,0 +1,73 @@
package routes
import (
"tyapi-server/internal/infrastructure/http/handlers"
sharedhttp "tyapi-server/internal/shared/http"
"tyapi-server/internal/shared/middleware"
"go.uber.org/zap"
)
// AnnouncementRoutes 公告路由
type AnnouncementRoutes struct {
handler *handlers.AnnouncementHandler
auth *middleware.JWTAuthMiddleware
admin *middleware.AdminAuthMiddleware
logger *zap.Logger
}
// NewAnnouncementRoutes 创建公告路由
func NewAnnouncementRoutes(
handler *handlers.AnnouncementHandler,
auth *middleware.JWTAuthMiddleware,
admin *middleware.AdminAuthMiddleware,
logger *zap.Logger,
) *AnnouncementRoutes {
return &AnnouncementRoutes{
handler: handler,
auth: auth,
admin: admin,
logger: logger,
}
}
// Register 注册路由
func (r *AnnouncementRoutes) Register(router *sharedhttp.GinRouter) {
engine := router.GetEngine()
// ==================== 用户端路由 ====================
// 公告相关路由 - 用户端(只显示已发布的公告)
announcementGroup := engine.Group("/api/v1/announcements")
{
// 公开路由 - 不需要认证
announcementGroup.GET("/:id", r.handler.GetAnnouncementByID) // 获取公告详情
announcementGroup.GET("", r.handler.ListAnnouncements) // 获取公告列表
}
// ==================== 管理员端路由 ====================
// 管理员公告管理路由
adminAnnouncementGroup := engine.Group("/api/v1/admin/announcements")
adminAnnouncementGroup.Use(r.admin.Handle())
{
// 统计信息
adminAnnouncementGroup.GET("/stats", r.handler.GetAnnouncementStats) // 获取公告统计
// 公告列表查询
adminAnnouncementGroup.GET("", r.handler.ListAnnouncements) // 获取公告列表(管理员端,包含所有状态)
// 公告管理
adminAnnouncementGroup.POST("", r.handler.CreateAnnouncement) // 创建公告
adminAnnouncementGroup.PUT("/:id", r.handler.UpdateAnnouncement) // 更新公告
adminAnnouncementGroup.DELETE("/:id", r.handler.DeleteAnnouncement) // 删除公告
// 公告状态管理
adminAnnouncementGroup.POST("/:id/publish", r.handler.PublishAnnouncement) // 发布公告
adminAnnouncementGroup.POST("/:id/withdraw", r.handler.WithdrawAnnouncement) // 撤回公告
adminAnnouncementGroup.POST("/:id/archive", r.handler.ArchiveAnnouncement) // 归档公告
adminAnnouncementGroup.POST("/:id/schedule-publish", r.handler.SchedulePublishAnnouncement) // 定时发布公告
adminAnnouncementGroup.POST("/:id/update-schedule-publish", r.handler.UpdateSchedulePublishAnnouncement) // 修改定时发布时间
adminAnnouncementGroup.POST("/:id/cancel-schedule", r.handler.CancelSchedulePublishAnnouncement) // 取消定时发布
}
r.logger.Info("公告路由注册完成")
}

View File

@@ -42,6 +42,18 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
alipayGroup.GET("/return", r.financeHandler.HandleAlipayReturn) // 支付宝同步回调
}
// 微信支付回调路由(不需要认证)
wechatPayGroup := engine.Group("/api/v1/pay/wechat")
{
wechatPayGroup.POST("/callback", r.financeHandler.HandleWechatPayCallback) // 微信支付异步回调
}
// 微信退款回调路由(不需要认证)
wechatRefundGroup := engine.Group("/api/v1/wechat")
{
wechatRefundGroup.POST("/refund_callback", r.financeHandler.HandleWechatRefundCallback) // 微信退款异步回调
}
// 财务路由组,需要用户认证
financeGroup := engine.Group("/api/v1/finance")
financeGroup.Use(r.authMiddleware.Handle())
@@ -53,8 +65,10 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
walletGroup.GET("/transactions", r.financeHandler.GetUserWalletTransactions) // 获取钱包交易记录
walletGroup.GET("/recharge-config", r.financeHandler.GetRechargeConfig) // 获取充值配置
walletGroup.POST("/alipay-recharge", r.financeHandler.CreateAlipayRecharge) // 创建支付宝充值订单
walletGroup.POST("/wechat-recharge", r.financeHandler.CreateWechatRecharge) // 创建微信充值订单
walletGroup.GET("/recharge-records", r.financeHandler.GetUserRechargeRecords) // 用户充值记录分页
walletGroup.GET("/alipay-order-status", r.financeHandler.GetAlipayOrderStatus) // 获取支付宝订单状态
walletGroup.GET("/wechat-order-status", r.financeHandler.GetWechatOrderStatus) // 获取微信订单状态
}
}

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,6 +12,7 @@ import (
// ProductRoutes 产品路由
type ProductRoutes struct {
productHandler *handlers.ProductHandler
componentReportHandler *component_report.ComponentReportHandler
auth *middleware.JWTAuthMiddleware
optionalAuth *middleware.OptionalAuthMiddleware
logger *zap.Logger
@@ -19,12 +21,14 @@ type ProductRoutes struct {
// NewProductRoutes 创建产品路由
func NewProductRoutes(
productHandler *handlers.ProductHandler,
componentReportHandler *component_report.ComponentReportHandler,
auth *middleware.JWTAuthMiddleware,
optionalAuth *middleware.OptionalAuthMiddleware,
logger *zap.Logger,
) *ProductRoutes {
return &ProductRoutes{
productHandler: productHandler,
componentReportHandler: componentReportHandler,
auth: auth,
optionalAuth: optionalAuth,
logger: logger,
@@ -57,6 +61,24 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct)
}
// 组件报告 - 需要认证
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)
}
// 分类 - 公开接口
categories := engine.Group("/api/v1/categories")
{

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

@@ -53,6 +53,33 @@ func (f *TaskFactory) CreateArticlePublishTask(articleID string, publishAt time.
return task, nil
}
// CreateAnnouncementPublishTask 创建公告发布任务
func (f *TaskFactory) CreateAnnouncementPublishTask(announcementID string, publishAt time.Time, userID string) (*AsyncTask, error) {
// 创建任务实体ID将由GORM的BeforeCreate钩子自动生成UUID
task := &AsyncTask{
Type: string(types.TaskTypeAnnouncementPublish),
Status: TaskStatusPending,
ScheduledAt: &publishAt,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 在payload中添加任务ID将在保存后更新
payloadWithID := map[string]interface{}{
"announcement_id": announcementID,
"publish_at": publishAt,
"user_id": userID,
}
payloadDataWithID, err := json.Marshal(payloadWithID)
if err != nil {
return nil, err
}
task.Payload = string(payloadDataWithID)
return task, nil
}
// CreateArticleCancelTask 创建文章取消任务
func (f *TaskFactory) CreateArticleCancelTask(articleID string, userID string) (*AsyncTask, error) {
// 创建任务实体ID将由GORM的BeforeCreate钩子自动生成UUID
@@ -240,6 +267,32 @@ func (f *TaskFactory) CreateAndEnqueueArticlePublishTask(ctx context.Context, ar
return fmt.Errorf("TaskManager类型不匹配")
}
// CreateAndEnqueueAnnouncementPublishTask 创建并入队公告发布任务
func (f *TaskFactory) CreateAndEnqueueAnnouncementPublishTask(ctx context.Context, announcementID string, publishAt time.Time, userID string) error {
if f.taskManager == nil {
return fmt.Errorf("TaskManager未初始化")
}
task, err := f.CreateAnnouncementPublishTask(announcementID, publishAt, userID)
if err != nil {
return err
}
delay := publishAt.Sub(time.Now())
if delay < 0 {
delay = 0
}
// 使用类型断言调用TaskManager方法
if tm, ok := f.taskManager.(interface {
CreateAndEnqueueDelayedTask(ctx context.Context, task *AsyncTask, delay time.Duration) error
}); ok {
return tm.CreateAndEnqueueDelayedTask(ctx, task, delay)
}
return fmt.Errorf("TaskManager类型不匹配")
}
// CreateAndEnqueueApiLogTask 创建并入队API日志任务
func (f *TaskFactory) CreateAndEnqueueApiLogTask(ctx context.Context, transactionID string, userID string, apiName string, productID string) error {
if f.taskManager == nil {

View File

@@ -19,14 +19,21 @@ import (
type ArticleTaskHandler struct {
logger *zap.Logger
articleApplicationService article.ArticleApplicationService
announcementApplicationService article.AnnouncementApplicationService
asyncTaskRepo repositories.AsyncTaskRepository
}
// NewArticleTaskHandler 创建文章任务处理器
func NewArticleTaskHandler(logger *zap.Logger, articleApplicationService article.ArticleApplicationService, asyncTaskRepo repositories.AsyncTaskRepository) *ArticleTaskHandler {
func NewArticleTaskHandler(
logger *zap.Logger,
articleApplicationService article.ArticleApplicationService,
announcementApplicationService article.AnnouncementApplicationService,
asyncTaskRepo repositories.AsyncTaskRepository,
) *ArticleTaskHandler {
return &ArticleTaskHandler{
logger: logger,
articleApplicationService: articleApplicationService,
announcementApplicationService: announcementApplicationService,
asyncTaskRepo: asyncTaskRepo,
}
}
@@ -112,6 +119,47 @@ func (h *ArticleTaskHandler) HandleArticleModify(ctx context.Context, t *asynq.T
return nil
}
// HandleAnnouncementPublish 处理公告发布任务
func (h *ArticleTaskHandler) HandleAnnouncementPublish(ctx context.Context, t *asynq.Task) error {
h.logger.Info("开始处理公告发布任务")
var payload AnnouncementPublishPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析公告发布任务载荷失败", zap.Error(err))
h.updateTaskStatus(ctx, t, "failed", "解析任务载荷失败")
return err
}
h.logger.Info("处理公告发布任务",
zap.String("announcement_id", payload.AnnouncementID),
zap.Time("publish_at", payload.PublishAt))
// 检查任务是否已被取消
if err := h.checkTaskStatus(ctx, t); err != nil {
h.logger.Info("任务已被取消,跳过执行", zap.String("announcement_id", payload.AnnouncementID))
return nil // 静默返回,不报错
}
// 调用公告应用服务发布公告
if h.announcementApplicationService != nil {
err := h.announcementApplicationService.PublishAnnouncementByID(ctx, payload.AnnouncementID)
if err != nil {
h.logger.Error("公告发布失败", zap.String("announcement_id", payload.AnnouncementID), zap.Error(err))
h.updateTaskStatus(ctx, t, "failed", "公告发布失败: "+err.Error())
return err
}
} else {
h.logger.Warn("公告应用服务未初始化,跳过发布", zap.String("announcement_id", payload.AnnouncementID))
h.updateTaskStatus(ctx, t, "failed", "公告应用服务未初始化")
return nil
}
// 更新任务状态为成功
h.updateTaskStatus(ctx, t, "completed", "")
h.logger.Info("公告发布任务处理完成", zap.String("announcement_id", payload.AnnouncementID))
return nil
}
// ArticlePublishPayload 文章发布任务载荷
type ArticlePublishPayload struct {
ArticleID string `json:"article_id"`
@@ -177,6 +225,28 @@ func (p *ArticleModifyPayload) FromJSON(data []byte) error {
return json.Unmarshal(data, p)
}
// AnnouncementPublishPayload 公告发布任务载荷
type AnnouncementPublishPayload struct {
AnnouncementID string `json:"announcement_id"`
PublishAt time.Time `json:"publish_at"`
UserID string `json:"user_id"`
}
// GetType 获取任务类型
func (p *AnnouncementPublishPayload) GetType() types.TaskType {
return types.TaskTypeAnnouncementPublish
}
// ToJSON 序列化为JSON
func (p *AnnouncementPublishPayload) ToJSON() ([]byte, error) {
return json.Marshal(p)
}
// FromJSON 从JSON反序列化
func (p *AnnouncementPublishPayload) FromJSON(data []byte) error {
return json.Unmarshal(data, p)
}
// updateTaskStatus 更新任务状态
func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task, status string, errorMsg string) {
// 从任务载荷中提取任务ID
@@ -189,9 +259,11 @@ func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task
// 尝试从payload中获取任务ID
taskID, ok := payload["task_id"].(string)
if !ok {
// 如果没有task_id尝试从article_id生成
// 如果没有task_id尝试从article_id或announcement_id生成
if articleID, ok := payload["article_id"].(string); ok {
taskID = fmt.Sprintf("article-publish-%s", articleID)
} else if announcementID, ok := payload["announcement_id"].(string); ok {
taskID = fmt.Sprintf("announcement-publish-%s", announcementID)
} else {
h.logger.Error("无法从任务载荷中获取任务ID")
return
@@ -278,9 +350,11 @@ func (h *ArticleTaskHandler) checkTaskStatus(ctx context.Context, t *asynq.Task)
// 尝试从payload中获取任务ID
taskID, ok := payload["task_id"].(string)
if !ok {
// 如果没有task_id尝试从article_id生成
// 如果没有task_id尝试从article_id或announcement_id生成
if articleID, ok := payload["article_id"].(string); ok {
taskID = fmt.Sprintf("article-publish-%s", articleID)
} else if announcementID, ok := payload["announcement_id"].(string); ok {
taskID = fmt.Sprintf("announcement-publish-%s", announcementID)
} else {
h.logger.Error("无法从任务载荷中获取任务ID")
return fmt.Errorf("无法获取任务ID")

View File

@@ -29,6 +29,7 @@ func NewAsynqWorker(
redisAddr string,
logger *zap.Logger,
articleApplicationService article.ArticleApplicationService,
announcementApplicationService article.AnnouncementApplicationService,
apiApplicationService api.ApiApplicationService,
walletService finance_services.WalletAggregateService,
subscriptionService *product_services.ProductSubscriptionService,
@@ -42,12 +43,13 @@ func NewAsynqWorker(
"default": 2, // 2个goroutine
"api": 3, // 3个goroutine (扣款任务)
"article": 1, // 1个goroutine
"announcement": 1, // 1个goroutine
},
},
)
// 创建任务处理器
articleHandler := handlers.NewArticleTaskHandler(logger, articleApplicationService, asyncTaskRepo)
articleHandler := handlers.NewArticleTaskHandler(logger, articleApplicationService, announcementApplicationService, asyncTaskRepo)
apiHandler := handlers.NewApiTaskHandler(logger, apiApplicationService, walletService, subscriptionService, asyncTaskRepo)
// 创建ServeMux
@@ -105,6 +107,9 @@ func (w *AsynqWorker) registerAllHandlers() {
w.mux.HandleFunc(string(types.TaskTypeArticleCancel), w.articleHandler.HandleArticleCancel)
w.mux.HandleFunc(string(types.TaskTypeArticleModify), w.articleHandler.HandleArticleModify)
// 注册公告任务处理器
w.mux.HandleFunc(string(types.TaskTypeAnnouncementPublish), w.articleHandler.HandleAnnouncementPublish)
// 注册API任务处理器
w.mux.HandleFunc(string(types.TaskTypeApiCall), w.apiHandler.HandleApiCall)
w.mux.HandleFunc(string(types.TaskTypeApiLog), w.apiHandler.HandleApiLog)
@@ -116,6 +121,7 @@ func (w *AsynqWorker) registerAllHandlers() {
zap.String("article_publish", string(types.TaskTypeArticlePublish)),
zap.String("article_cancel", string(types.TaskTypeArticleCancel)),
zap.String("article_modify", string(types.TaskTypeArticleModify)),
zap.String("announcement_publish", string(types.TaskTypeAnnouncementPublish)),
zap.String("api_call", string(types.TaskTypeApiCall)),
zap.String("api_log", string(types.TaskTypeApiLog)),
)

View File

@@ -274,8 +274,7 @@ func (tm *TaskManagerImpl) updatePayloadTaskID(task *entities.AsyncTask) error {
return nil
}
// findTask 查找任务支持taskID和articleID双重查找
// findTask 查找任务支持taskID、articleID和announcementID三重查找
func (tm *TaskManagerImpl) findTask(ctx context.Context, taskID string) (*entities.AsyncTask, error) {
// 先尝试通过任务ID查找
task, err := tm.asyncTaskRepo.GetByID(ctx, taskID)
@@ -287,17 +286,30 @@ func (tm *TaskManagerImpl) findTask(ctx context.Context, taskID string) (*entiti
tm.logger.Info("通过任务ID查找失败尝试通过文章ID查找", zap.String("task_id", taskID))
tasks, err := tm.asyncTaskRepo.GetByArticleID(ctx, taskID)
if err != nil || len(tasks) == 0 {
tm.logger.Error("通过文章ID也找不到任务",
if err == nil && len(tasks) > 0 {
// 使用找到的第一个任务
task = tasks[0]
tm.logger.Info("通过文章ID找到任务",
zap.String("article_id", taskID),
zap.String("task_id", task.ID))
return task, nil
}
// 如果通过文章ID也找不到尝试通过公告ID查找
tm.logger.Info("通过文章ID查找失败尝试通过公告ID查找", zap.String("task_id", taskID))
announcementTasks, err := tm.asyncTaskRepo.GetByAnnouncementID(ctx, taskID)
if err != nil || len(announcementTasks) == 0 {
tm.logger.Error("通过公告ID也找不到任务",
zap.String("announcement_id", taskID),
zap.Error(err))
return nil, fmt.Errorf("获取任务信息失败: %w", err)
}
// 使用找到的第一个任务
task = tasks[0]
tm.logger.Info("通过文章ID找到任务",
zap.String("article_id", taskID),
task = announcementTasks[0]
tm.logger.Info("通过公告ID找到任务",
zap.String("announcement_id", taskID),
zap.String("task_id", task.ID))
return task, nil
@@ -362,7 +374,8 @@ func (tm *TaskManagerImpl) enqueueTaskWithDelay(ctx context.Context, task *entit
// getQueueName 根据任务类型获取队列名称
func (tm *TaskManagerImpl) getQueueName(taskType string) string {
switch taskType {
case string(types.TaskTypeArticlePublish), string(types.TaskTypeArticleCancel), string(types.TaskTypeArticleModify):
case string(types.TaskTypeArticlePublish), string(types.TaskTypeArticleCancel), string(types.TaskTypeArticleModify),
string(types.TaskTypeAnnouncementPublish):
return "article"
case string(types.TaskTypeApiCall), string(types.TaskTypeApiLog), string(types.TaskTypeDeduction), string(types.TaskTypeUsageStats):
return "api"

View File

@@ -42,6 +42,9 @@ type AsyncTaskRepository interface {
GetByArticleID(ctx context.Context, articleID string) ([]*entities.AsyncTask, error)
CancelArticlePublishTask(ctx context.Context, articleID string) error
UpdateArticlePublishTaskSchedule(ctx context.Context, articleID string, newScheduledAt time.Time) error
// 公告任务专用方法
GetByAnnouncementID(ctx context.Context, announcementID string) ([]*entities.AsyncTask, error)
}
// AsyncTaskRepositoryImpl 异步任务仓库实现
@@ -265,3 +268,32 @@ func (r *AsyncTaskRepositoryImpl) UpdateArticlePublishTaskSchedule(ctx context.C
[]entities.TaskStatus{entities.TaskStatusPending, entities.TaskStatusRunning}).
Update("scheduled_at", newScheduledAt).Error
}
// GetPendingArticlePublishTaskByArticleID 根据公告ID获取待执行的公告发布任务
func (r *AsyncTaskRepositoryImpl) GetPendingAnnouncementPublishTaskByAnnouncementID(ctx context.Context, announcementID string) (*entities.AsyncTask, error) {
var task entities.AsyncTask
err := r.db.WithContext(ctx).
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeAnnouncementPublish,
"%\"announcement_id\":\""+announcementID+"\"%",
[]entities.TaskStatus{entities.TaskStatusPending, entities.TaskStatusRunning}).
First(&task).Error
if err != nil {
return nil, err
}
return &task, nil
}
// GetByAnnouncementID 根据公告ID获取所有相关任务
func (r *AsyncTaskRepositoryImpl) GetByAnnouncementID(ctx context.Context, announcementID string) ([]*entities.AsyncTask, error) {
var tasks []*entities.AsyncTask
err := r.db.WithContext(ctx).
Where("payload LIKE ? AND status IN ?",
"%\"announcement_id\":\""+announcementID+"\"%",
[]entities.TaskStatus{entities.TaskStatusPending, entities.TaskStatusRunning}).
Find(&tasks).Error
if err != nil {
return nil, err
}
return tasks, nil
}

View File

@@ -9,6 +9,9 @@ const (
TaskTypeArticleCancel TaskType = "article_cancel"
TaskTypeArticleModify TaskType = "article_modify"
// 公告相关任务
TaskTypeAnnouncementPublish TaskType = "announcement_publish"
// API相关任务
TaskTypeApiCall TaskType = "api_call"
TaskTypeApiLog TaskType = "api_log"

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

Some files were not shown because too many files have changed in this diff Show More