Compare commits

...

41 Commits

Author SHA1 Message Date
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
9f669a9c94 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-04 16:19:20 +08:00
0f5c4f4303 18278715334@163.com 2025-12-04 16:17:27 +08:00
d9c2d9f103 18278715334@163.com 2025-12-04 16:17:04 +08:00
7e0d58b295 change esign template 2025-12-04 15:11:25 +08:00
a17ff2140e fix 2025-12-04 14:26:56 +08:00
6a2241bc66 fix 2025-12-04 14:21:58 +08:00
e57bef6609 fix 2025-12-04 14:00:22 +08:00
81639a81e6 fix 2025-12-04 13:42:32 +08:00
aaf17321ff fix 2025-12-04 13:28:03 +08:00
a8a4ff2d37 fix 2025-12-04 13:20:03 +08:00
619deeb456 fix pdf path 2025-12-04 13:09:59 +08:00
f12c3fb8ad fix pdf 2025-12-04 12:56:39 +08:00
4ce8fe4023 18278715334@163.com 2025-12-04 12:30:33 +08:00
7b45b43a0e 2 2025-12-04 10:47:58 +08:00
752b90b048 1 2025-12-04 10:41:07 +08:00
68def7e08b 18287815334@163.com 2025-12-04 10:35:11 +08:00
b0e8974d6c 将字体文件添加到 .gitignore,不再进行版本控制 2025-12-04 10:27:31 +08:00
117 changed files with 7488 additions and 927 deletions

6
.gitignore vendored
View File

@@ -26,6 +26,7 @@ Thumbs.db
tmp/
temp/
console
worker
# 依赖目录
vendor/
@@ -34,6 +35,11 @@ vendor/
coverage.out
coverage.html
# 字体文件(大文件,不进行版本控制)
internal/shared/pdf/fonts/*.ttf
internal/shared/pdf/fonts/*.ttc
internal/shared/pdf/fonts/*.otf
# 其他
*.exe
*.dll

View File

@@ -53,10 +53,8 @@ COPY --from=builder /app/tyapi-server .
COPY config.yaml .
COPY configs/ ./configs/
# 复制字体文件(PDF生成需要- 创建目录结构并复制字体文件
RUN mkdir -p ./internal/shared/pdf/fonts
COPY --from=builder /app/internal/shared/pdf/fonts/ ./internal/shared/pdf/fonts/
# 复制资源文件(直接从构建上下文复制,与配置文件一致)
COPY resources ./resources
# 暴露端口
EXPOSE 8080

View File

@@ -19,7 +19,8 @@ import (
)
const (
TaskTypeArticlePublish = "article:publish"
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

@@ -243,7 +243,7 @@ esign:
app_id: "7439073138"
app_secret: "d76e27fdd169b391e09262a0959dac5c"
server_url: "https://smlopenapi.esign.cn"
template_id: "1fd7ed9c6d134d1db7b5af9582633d76"
template_id: "9f7a3f63cc5a48b085b127ba027d234d"
contract:
name: "天远数据API合作协议"
expire_days: 7
@@ -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

@@ -43,7 +43,7 @@ esign:
app_id: "7439073713"
app_secret: "c7d8cb0d701f7890601d221e9b6edfef"
server_url: "https://smlopenapi.esign.cn"
template_id: "1fd7ed9c6d134d1db7b5af9582633d76"
template_id: "9f7a3f63cc5a48b085b127ba027d234d"
contract:
name: "天远数据API合作协议"
expire_days: 7
@@ -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,34 +135,42 @@ 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 # 令牌桶突发容量
# ===========================================
# 🚀 开发环境频率限制配置(放宽限制)
# ===========================================
daily_ratelimit:
max_requests_per_day: 1000000 # 开发环境每日最大请求次数
max_requests_per_ip: 10000000 # 开发环境每个IP每日最大请求次数
max_concurrent: 50 # 开发环境最大并发请求数
max_requests_per_day: 1000000 # 开发环境每日最大请求次数
max_requests_per_ip: 10000000 # 开发环境每个IP每日最大请求次数
max_concurrent: 50 # 开发环境最大并发请求数
# 排除频率限制的路径
exclude_paths:
- "/health" # 健康检查接口
- "/metrics" # 监控指标接口
- "/health" # 健康检查接口
- "/metrics" # 监控指标接口
# 排除频率限制的域名
exclude_domains:
- "api.*" # API二级域名不受频率限制
- "*.api.*" # 支持多级API域名
- "api.*" # API二级域名不受频率限制
- "*.api.*" # 支持多级API域名
# 开发环境安全配置(放宽限制)
enable_ip_whitelist: true # 启用IP白名单
ip_whitelist: # 开发环境IP白名单
- "127.0.0.1" # 本地回环
- "localhost" # 本地主机
- "192.168.*" # 内网IP段
- "10.*" # 内网IP段
- "172.16.*" # 内网IP段
enable_ip_blacklist: false # 开发环境禁用IP黑名单
enable_user_agent: false # 开发环境禁用User-Agent检查
enable_referer: false # 开发环境禁用Referer检查
enable_proxy_check: false # 开发环境禁用代理检查
enable_ip_whitelist: true # 启用IP白名单
ip_whitelist: # 开发环境IP白名单
- "127.0.0.1" # 本地回环
- "localhost" # 本地主机
- "192.168.*" # 内网IP段
- "10.*" # 内网IP段
- "172.16.*" # 内网IP段
enable_ip_blacklist: false # 开发环境禁用IP黑名单
enable_user_agent: false # 开发环境禁用User-Agent检查
enable_referer: false # 开发环境禁用Referer检查
enable_proxy_check: false # 开发环境禁用代理检查

View File

@@ -75,7 +75,7 @@ esign:
app_id: "5112008003"
app_secret: "d487672273e7aa70c800804a1d9499b9"
server_url: "https://openapi.esign.cn"
template_id: "c82af4df2790430299c81321f309eef3"
template_id: "9f7a3f63cc5a48b085b127ba027d234d"
contract:
name: "天远数据API合作协议"
expire_days: 7

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缓存管理器
- 集成到下载接口
- 支持版本控制和自动过期

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

@@ -245,6 +245,8 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
&articleEntities.Category{},
&articleEntities.Tag{},
&articleEntities.ScheduledTask{},
// 公告
&articleEntities.Announcement{},
// 统计域
&statisticsEntities.StatisticsMetric{},

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"tyapi-server/internal/application/api/commands"
"tyapi-server/internal/application/api/dto"
@@ -37,8 +39,8 @@ type ApiApplicationService interface {
GetUserApiKeys(ctx context.Context, userID string) (*dto.ApiKeysResponse, error)
// 用户白名单管理
GetUserWhiteList(ctx context.Context, userID string) (*dto.WhiteListListResponse, error)
AddWhiteListIP(ctx context.Context, userID string, ipAddress string) error
GetUserWhiteList(ctx context.Context, userID string, remarkKeyword string) (*dto.WhiteListListResponse, error)
AddWhiteListIP(ctx context.Context, userID string, ipAddress string, remark string) error
DeleteWhiteListIP(ctx context.Context, userID string, ipAddress string) error
// 获取用户API调用记录
@@ -466,7 +468,7 @@ func (s *ApiApplicationServiceImpl) GetUserApiKeys(ctx context.Context, userID s
}
// GetUserWhiteList 获取用户白名单列表
func (s *ApiApplicationServiceImpl) GetUserWhiteList(ctx context.Context, userID string) (*dto.WhiteListListResponse, error) {
func (s *ApiApplicationServiceImpl) GetUserWhiteList(ctx context.Context, userID string, remarkKeyword string) (*dto.WhiteListListResponse, error) {
apiUser, err := s.apiUserService.LoadApiUserByUserId(ctx, userID)
if err != nil {
return nil, err
@@ -474,28 +476,49 @@ func (s *ApiApplicationServiceImpl) GetUserWhiteList(ctx context.Context, userID
// 确保WhiteList不为nil
if apiUser.WhiteList == nil {
apiUser.WhiteList = []string{}
apiUser.WhiteList = entities.WhiteList{}
}
// 将白名单字符串数组转换为响应格式
// 将白名单转换为响应格式
var items []dto.WhiteListResponse
for _, ip := range apiUser.WhiteList {
for _, item := range apiUser.WhiteList {
// 如果提供了备注关键词,进行模糊匹配过滤
if remarkKeyword != "" {
if !contains(item.Remark, remarkKeyword) {
continue // 不匹配则跳过
}
}
items = append(items, dto.WhiteListResponse{
ID: apiUser.ID, // 使用API用户ID作为标识
UserID: apiUser.UserId,
IPAddress: ip,
CreatedAt: apiUser.CreatedAt, // 使用API用户创建时间
IPAddress: item.IPAddress,
Remark: item.Remark, // 备注
CreatedAt: item.AddedAt, // 使用每个IP的实际添加时间
})
}
// 按添加时间降序排序(新的排在前面)
sort.Slice(items, func(i, j int) bool {
return items[i].CreatedAt.After(items[j].CreatedAt)
})
return &dto.WhiteListListResponse{
Items: items,
Total: len(items),
}, nil
}
// contains 检查字符串是否包含子字符串(不区分大小写)
func contains(s, substr string) bool {
if substr == "" {
return true
}
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
// AddWhiteListIP 添加白名单IP
func (s *ApiApplicationServiceImpl) AddWhiteListIP(ctx context.Context, userID string, ipAddress string) error {
func (s *ApiApplicationServiceImpl) AddWhiteListIP(ctx context.Context, userID string, ipAddress string, remark string) error {
apiUser, err := s.apiUserService.LoadApiUserByUserId(ctx, userID)
if err != nil {
return err
@@ -503,11 +526,11 @@ func (s *ApiApplicationServiceImpl) AddWhiteListIP(ctx context.Context, userID s
// 确保WhiteList不为nil
if apiUser.WhiteList == nil {
apiUser.WhiteList = []string{}
apiUser.WhiteList = entities.WhiteList{}
}
// 使用实体的领域方法添加IP到白名单
err = apiUser.AddToWhiteList(ipAddress)
// 使用实体的领域方法添加IP到白名单(会自动记录添加时间和备注)
err = apiUser.AddToWhiteList(ipAddress, remark)
if err != nil {
return err
}
@@ -530,7 +553,7 @@ func (s *ApiApplicationServiceImpl) DeleteWhiteListIP(ctx context.Context, userI
// 确保WhiteList不为nil
if apiUser.WhiteList == nil {
apiUser.WhiteList = []string{}
apiUser.WhiteList = entities.WhiteList{}
}
// 使用实体的领域方法删除IP

View File

@@ -26,11 +26,13 @@ type WhiteListResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
IPAddress string `json:"ip_address"`
Remark string `json:"remark"` // 备注
CreatedAt time.Time `json:"created_at"`
}
type WhiteListRequest struct {
IPAddress string `json:"ip_address" binding:"required,ip"`
Remark string `json:"remark"` // 备注(可选)
}
type WhiteListListResponse struct {

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,17 +156,15 @@ 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))
record.MarkAsFailed(err.Error())
saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record)
if saveErr != nil {
return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error())
}
return nil, fmt.Errorf("企业信息验证失败, %s", err.Error())
err = s.enterpriseInfoSubmitRecordService.ValidateWithWestdex(ctx, enterpriseInfo)
if err != nil {
s.logger.Error("企业信息验证失败", zap.Error(err))
record.MarkAsFailed(err.Error())
saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record)
if saveErr != nil {
return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error())
}
return nil, fmt.Errorf("企业信息验证失败, %s", err.Error())
}
record.MarkAsVerified()
saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record)

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"`
@@ -16,16 +15,24 @@ type TransferRechargeCommand struct {
// GiftRechargeCommand 赠送充值命令
type GiftRechargeCommand struct {
UserID string `json:"user_id" binding:"required,uuid"`
Amount string `json:"amount" binding:"required"`
Notes string `json:"notes" binding:"omitempty,max=500" comment:"备注信息"`
UserID string `json:"user_id" binding:"required,uuid"`
Amount string `json:"amount" binding:"required"`
Notes string `json:"notes" binding:"omitempty,max=500" comment:"备注信息"`
}
// CreateAlipayRechargeCommand 创建支付宝充值订单命令
type CreateAlipayRechargeCommand struct {
UserID string `json:"-"` // 用户ID从token获取
Amount string `json:"amount" binding:"required"` // 充值金额
Subject string `json:"-"` // 订单标题
UserID string `json:"-"` // 用户ID从token获取
Amount string `json:"amount" binding:"required"` // 充值金额
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

@@ -8,15 +8,15 @@ import (
// WalletResponse 钱包响应
type WalletResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
IsActive bool `json:"is_active"`
Balance decimal.Decimal `json:"balance"`
BalanceStatus string `json:"balance_status"` // normal, low, arrears
IsArrears bool `json:"is_arrears"` // 是否欠费
IsLowBalance bool `json:"is_low_balance"` // 是否余额较低
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
IsActive bool `json:"is_active"`
Balance decimal.Decimal `json:"balance"`
BalanceStatus string `json:"balance_status"` // normal, low, arrears
IsArrears bool `json:"is_arrears"` // 是否欠费
IsLowBalance bool `json:"is_low_balance"` // 是否余额较低
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TransactionResponse 交易响应
@@ -49,34 +49,36 @@ type WalletStatsResponse struct {
// RechargeRecordResponse 充值记录响应
type RechargeRecordResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Amount decimal.Decimal `json:"amount"`
RechargeType string `json:"recharge_type"`
Status string `json:"status"`
AlipayOrderID string `json:"alipay_order_id,omitempty"`
TransferOrderID string `json:"transfer_order_id,omitempty"`
Notes string `json:"notes,omitempty"`
OperatorID string `json:"operator_id,omitempty"`
CompanyName string `json:"company_name,omitempty"`
User *UserSimpleResponse `json:"user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
Amount decimal.Decimal `json:"amount"`
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"`
User *UserSimpleResponse `json:"user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WalletTransactionResponse 钱包交易记录响应
type WalletTransactionResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ApiCallID string `json:"api_call_id"`
TransactionID string `json:"transaction_id"`
ProductID string `json:"product_id"`
ProductName string `json:"product_name"`
Amount decimal.Decimal `json:"amount"`
CompanyName string `json:"company_name,omitempty"`
ID string `json:"id"`
UserID string `json:"user_id"`
ApiCallID string `json:"api_call_id"`
TransactionID string `json:"transaction_id"`
ProductID string `json:"product_id"`
ProductName string `json:"product_name"`
Amount decimal.Decimal `json:"amount"`
CompanyName string `json:"company_name,omitempty"`
User *UserSimpleResponse `json:"user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WalletTransactionListResponse 钱包交易记录列表响应
@@ -97,17 +99,17 @@ type RechargeRecordListResponse struct {
// AlipayRechargeOrderResponse 支付宝充值订单响应
type AlipayRechargeOrderResponse struct {
PayURL string `json:"pay_url"` // 支付链接
OutTradeNo string `json:"out_trade_no"` // 商户订单号
Amount decimal.Decimal `json:"amount"` // 充值金额
Platform string `json:"platform"` // 支付平台
Subject string `json:"subject"` // 订单标题
PayURL string `json:"pay_url"` // 支付链接
OutTradeNo string `json:"out_trade_no"` // 商户订单号
Amount decimal.Decimal `json:"amount"` // 充值金额
Platform string `json:"platform"` // 支付平台
Subject string `json:"subject"` // 订单标题
}
// RechargeConfigResponse 充值配置响应
type RechargeConfigResponse struct {
MinAmount string `json:"min_amount"` // 最低充值金额
MaxAmount string `json:"max_amount"` // 最高充值金额
MinAmount string `json:"min_amount"` // 最低充值金额
MaxAmount string `json:"max_amount"` // 最高充值金额
AlipayRechargeBonus []AlipayRechargeBonusRuleResponse `json:"alipay_recharge_bonus"`
}

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,13 +17,14 @@ 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)
// 交易记录
GetUserWalletTransactions(ctx context.Context, userID string, filters map[string]interface{}, options interfaces.ListOptions) (*responses.WalletTransactionListResponse, error)
GetAdminWalletTransactions(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.WalletTransactionListResponse, error)
// 导出功能
ExportAdminWalletTransactions(ctx context.Context, filters map[string]interface{}, format string) ([]byte, error)
ExportAdminRechargeRecords(ctx context.Context, filters map[string]interface{}, format string) ([]byte, 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

@@ -3,7 +3,12 @@ package finance
import (
"context"
"fmt"
"github.com/shopspring/decimal"
"github.com/smartwalle/alipay/v3"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"go.uber.org/zap"
"net/http"
"time"
"tyapi-server/internal/application/finance/dto/commands"
"tyapi-server/internal/application/finance/dto/queries"
"tyapi-server/internal/application/finance/dto/responses"
@@ -16,19 +21,18 @@ import (
"tyapi-server/internal/shared/export"
"tyapi-server/internal/shared/interfaces"
"tyapi-server/internal/shared/payment"
"github.com/shopspring/decimal"
"github.com/smartwalle/alipay/v3"
"go.uber.org/zap"
)
// FinanceApplicationServiceImpl 财务应用服务实现
type FinanceApplicationServiceImpl struct {
aliPayClient *payment.AliPayService
wechatPayService *payment.WechatPayService
walletService finance_services.WalletAggregateService
rechargeRecordService finance_services.RechargeRecordService
walletTransactionRepository finance_repositories.WalletTransactionRepository
alipayOrderRepo finance_repositories.AlipayOrderRepository
wechatOrderRepo finance_repositories.WechatOrderRepository
rechargeRecordRepo finance_repositories.RechargeRecordRepository
userRepo user_repositories.UserRepository
txManager *database.TransactionManager
exportManager *export.ExportManager
@@ -39,10 +43,13 @@ type FinanceApplicationServiceImpl struct {
// NewFinanceApplicationService 创建财务应用服务
func NewFinanceApplicationService(
aliPayClient *payment.AliPayService,
wechatPayService *payment.WechatPayService,
walletService finance_services.WalletAggregateService,
rechargeRecordService finance_services.RechargeRecordService,
walletTransactionRepository finance_repositories.WalletTransactionRepository,
alipayOrderRepo finance_repositories.AlipayOrderRepository,
wechatOrderRepo finance_repositories.WechatOrderRepository,
rechargeRecordRepo finance_repositories.RechargeRecordRepository,
userRepo user_repositories.UserRepository,
txManager *database.TransactionManager,
logger *zap.Logger,
@@ -51,10 +58,13 @@ func NewFinanceApplicationService(
) FinanceApplicationService {
return &FinanceApplicationServiceImpl{
aliPayClient: aliPayClient,
wechatPayService: wechatPayService,
walletService: walletService,
rechargeRecordService: rechargeRecordService,
walletTransactionRepository: walletTransactionRepository,
alipayOrderRepo: alipayOrderRepo,
wechatOrderRepo: wechatOrderRepo,
rechargeRecordRepo: rechargeRecordRepo,
userRepo: userRepo,
txManager: txManager,
exportManager: exportManager,
@@ -100,8 +110,9 @@ func (s *FinanceApplicationServiceImpl) GetWallet(ctx context.Context, query *qu
BalanceStatus: wallet.GetBalanceStatus(),
IsArrears: wallet.IsArrears(),
IsLowBalance: wallet.IsLowBalance(),
CreatedAt: wallet.CreatedAt,
UpdatedAt: wallet.UpdatedAt,
CreatedAt: wallet.CreatedAt,
UpdatedAt: wallet.UpdatedAt,
}, nil
}
@@ -188,6 +199,168 @@ func (s *FinanceApplicationServiceImpl) CreateAlipayRechargeOrder(ctx context.Co
}, nil
}
// CreateWechatRechargeOrder 创建微信充值订单(完整流程编排)
func (s *FinanceApplicationServiceImpl) CreateWechatRechargeOrder(ctx context.Context, cmd *commands.CreateWechatRechargeCommand) (*responses.WechatRechargeOrderResponse, error) {
cmd.Subject = "天远数据API充值"
amount, err := decimal.NewFromString(cmd.Amount)
if err != nil {
s.logger.Error("金额格式错误", zap.String("amount", cmd.Amount), zap.Error(err))
return nil, fmt.Errorf("金额格式错误: %w", err)
}
if amount.LessThanOrEqual(decimal.Zero) {
return nil, fmt.Errorf("充值金额必须大于0")
}
minAmount, err := decimal.NewFromString(s.config.Wallet.MinAmount)
if err != nil {
s.logger.Error("配置中的最低充值金额格式错误", zap.String("min_amount", s.config.Wallet.MinAmount), zap.Error(err))
return nil, fmt.Errorf("系统配置错误: %w", err)
}
maxAmount, err := decimal.NewFromString(s.config.Wallet.MaxAmount)
if err != nil {
s.logger.Error("配置中的最高充值金额格式错误", zap.String("max_amount", s.config.Wallet.MaxAmount), zap.Error(err))
return nil, fmt.Errorf("系统配置错误: %w", err)
}
if amount.LessThan(minAmount) {
return nil, fmt.Errorf("充值金额不能少于%s元", minAmount.String())
}
if amount.GreaterThan(maxAmount) {
return nil, fmt.Errorf("单次充值金额不能超过%s元", maxAmount.String())
}
platform := normalizeWechatPlatform(cmd.Platform)
if platform != payment.PlatformWxNative && platform != payment.PlatformWxH5 {
return nil, fmt.Errorf("不支持的支付平台: %s", cmd.Platform)
}
if s.wechatPayService == nil {
return nil, fmt.Errorf("微信支付服务未初始化")
}
outTradeNo := s.wechatPayService.GenerateOutTradeNo()
s.logger.Info("开始创建微信充值订单",
zap.String("user_id", cmd.UserID),
zap.String("out_trade_no", outTradeNo),
zap.String("amount", amount.String()),
zap.String("platform", cmd.Platform),
zap.String("subject", cmd.Subject),
)
var prepayData interface{}
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
// 创建微信充值记录
rechargeRecord := finance_entities.NewWechatRechargeRecord(cmd.UserID, amount, outTradeNo)
createdRecord, createErr := s.rechargeRecordRepo.Create(txCtx, *rechargeRecord)
if createErr != nil {
s.logger.Error("创建微信充值记录失败",
zap.String("out_trade_no", outTradeNo),
zap.String("user_id", cmd.UserID),
zap.String("amount", amount.String()),
zap.Error(createErr),
)
return fmt.Errorf("创建微信充值记录失败: %w", createErr)
}
s.logger.Info("创建微信充值记录成功",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", createdRecord.ID),
zap.String("user_id", cmd.UserID),
)
// 创建微信订单本地记录
wechatOrder := finance_entities.NewWechatOrder(createdRecord.ID, outTradeNo, cmd.Subject, amount, platform)
createdOrder, orderErr := s.wechatOrderRepo.Create(txCtx, *wechatOrder)
if orderErr != nil {
s.logger.Error("创建微信订单记录失败",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", createdRecord.ID),
zap.Error(orderErr),
)
return fmt.Errorf("创建微信订单记录失败: %w", orderErr)
}
s.logger.Info("创建微信订单记录成功",
zap.String("out_trade_no", outTradeNo),
zap.String("order_id", createdOrder.ID),
zap.String("recharge_id", createdRecord.ID),
)
return nil
})
if err != nil {
return nil, err
}
payCtx := context.WithValue(ctx, "platform", platform)
payCtx = context.WithValue(payCtx, "user_id", cmd.UserID)
s.logger.Info("调用微信支付接口创建订单",
zap.String("out_trade_no", outTradeNo),
zap.String("platform", platform),
)
prepayData, err = s.wechatPayService.CreateWechatOrder(payCtx, amount.InexactFloat64(), cmd.Subject, outTradeNo)
if err != nil {
s.logger.Error("微信下单失败",
zap.String("out_trade_no", outTradeNo),
zap.String("user_id", cmd.UserID),
zap.String("amount", amount.String()),
zap.Error(err),
)
// 回写失败状态
_ = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
order, getErr := s.wechatOrderRepo.GetByOutTradeNo(txCtx, outTradeNo)
if getErr == nil && order != nil {
order.MarkFailed("create_failed", err.Error())
updateErr := s.wechatOrderRepo.Update(txCtx, *order)
if updateErr != nil {
s.logger.Error("回写微信订单失败状态失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(updateErr),
)
} else {
s.logger.Info("回写微信订单失败状态成功",
zap.String("out_trade_no", outTradeNo),
)
}
}
return nil
})
return nil, fmt.Errorf("创建微信支付订单失败: %w", err)
}
s.logger.Info("微信充值订单创建成功",
zap.String("user_id", cmd.UserID),
zap.String("out_trade_no", outTradeNo),
zap.String("amount", amount.String()),
zap.String("platform", cmd.Platform),
)
return &responses.WechatRechargeOrderResponse{
OutTradeNo: outTradeNo,
Amount: amount,
Platform: platform,
Subject: cmd.Subject,
PrepayData: prepayData,
}, nil
}
// normalizeWechatPlatform 将兼容写法(h5/mini)转换为系统内使用的wx_h5/wx_mini
func normalizeWechatPlatform(p string) string {
switch p {
case "h5", payment.PlatformWxH5:
return payment.PlatformWxNative
case "native":
return payment.PlatformWxNative
default:
return p
}
}
// TransferRecharge 对公转账充值
func (s *FinanceApplicationServiceImpl) TransferRecharge(ctx context.Context, cmd *commands.TransferRechargeCommand) (*responses.RechargeRecordResponse, error) {
// 将字符串金额转换为 decimal.Decimal
@@ -507,8 +680,8 @@ func (s *FinanceApplicationServiceImpl) ExportAdminRechargeRecords(ctx context.C
}
// 准备导出数据
headers := []string{"企业名称", "充值金额", "充值类型", "状态", "支付宝订单号", "转账订单号", "备注", "充值时间"}
columnWidths := []float64{25, 15, 15, 10, 20, 20, 20, 20}
headers := []string{"企业名称", "充值金额", "充值类型", "状态", "支付宝订单号", "微信订单号", "转账订单号", "备注", "充值时间"}
columnWidths := []float64{25, 15, 15, 10, 20, 20, 20, 20, 20}
data := make([][]interface{}, len(allRecords))
for i, record := range allRecords {
@@ -523,6 +696,10 @@ func (s *FinanceApplicationServiceImpl) ExportAdminRechargeRecords(ctx context.C
if record.AlipayOrderID != nil && *record.AlipayOrderID != "" {
alipayOrderID = *record.AlipayOrderID
}
wechatOrderID := ""
if record.WechatOrderID != nil && *record.WechatOrderID != "" {
wechatOrderID = *record.WechatOrderID
}
transferOrderID := ""
if record.TransferOrderID != nil && *record.TransferOrderID != "" {
transferOrderID = *record.TransferOrderID
@@ -543,6 +720,7 @@ func (s *FinanceApplicationServiceImpl) ExportAdminRechargeRecords(ctx context.C
translateRechargeType(record.RechargeType),
translateRechargeStatus(record.Status),
alipayOrderID,
wechatOrderID,
transferOrderID,
notes,
createdAt,
@@ -566,6 +744,8 @@ func translateRechargeType(rechargeType finance_entities.RechargeType) string {
switch rechargeType {
case finance_entities.RechargeTypeAlipay:
return "支付宝充值"
case finance_entities.RechargeTypeWechat:
return "微信充值"
case finance_entities.RechargeTypeTransfer:
return "对公转账"
case finance_entities.RechargeTypeGift:
@@ -890,15 +1070,27 @@ func (s *FinanceApplicationServiceImpl) GetAlipayOrderStatus(ctx context.Context
// GetUserRechargeRecords 获取用户充值记录
func (s *FinanceApplicationServiceImpl) GetUserRechargeRecords(ctx context.Context, userID string, filters map[string]interface{}, options interfaces.ListOptions) (*responses.RechargeRecordListResponse, error) {
// 查询用户充值记录
records, err := s.rechargeRecordService.GetByUserID(ctx, userID)
// 确保 filters 不为 nil
if filters == nil {
filters = make(map[string]interface{})
}
// 添加 user_id 筛选条件,确保只能查询当前用户的记录
filters["user_id"] = userID
// 查询用户充值记录(使用筛选和分页功能)
records, err := s.rechargeRecordService.GetAll(ctx, filters, options)
if err != nil {
s.logger.Error("查询用户充值记录失败", zap.Error(err), zap.String("userID", userID))
return nil, err
}
// 计算总数
total := int64(len(records))
// 获取总数(使用筛选条件)
total, err := s.rechargeRecordService.Count(ctx, filters)
if err != nil {
s.logger.Error("统计用户充值记录失败", zap.Error(err), zap.String("userID", userID))
return nil, err
}
// 转换为响应DTO
var items []responses.RechargeRecordResponse
@@ -914,9 +1106,20 @@ func (s *FinanceApplicationServiceImpl) GetUserRechargeRecords(ctx context.Conte
UpdatedAt: record.UpdatedAt,
}
// 根据充值类型设置相应的订单号
// 根据充值类型设置相应的订单号和平台信息
if record.AlipayOrderID != nil {
item.AlipayOrderID = *record.AlipayOrderID
// 通过订单号获取平台信息
if alipayOrder, err := s.alipayOrderRepo.GetByOutTradeNo(ctx, *record.AlipayOrderID); err == nil && alipayOrder != nil {
item.Platform = alipayOrder.Platform
}
}
if record.WechatOrderID != nil {
item.WechatOrderID = *record.WechatOrderID
// 通过订单号获取平台信息
if wechatOrder, err := s.wechatOrderRepo.GetByOutTradeNo(ctx, *record.WechatOrderID); err == nil && wechatOrder != nil {
item.Platform = wechatOrder.Platform
}
}
if record.TransferOrderID != nil {
item.TransferOrderID = *record.TransferOrderID
@@ -963,9 +1166,20 @@ func (s *FinanceApplicationServiceImpl) GetAdminRechargeRecords(ctx context.Cont
UpdatedAt: record.UpdatedAt,
}
// 根据充值类型设置相应的订单号
// 根据充值类型设置相应的订单号和平台信息
if record.AlipayOrderID != nil {
item.AlipayOrderID = *record.AlipayOrderID
// 通过订单号获取平台信息
if alipayOrder, err := s.alipayOrderRepo.GetByOutTradeNo(ctx, *record.AlipayOrderID); err == nil && alipayOrder != nil {
item.Platform = alipayOrder.Platform
}
}
if record.WechatOrderID != nil {
item.WechatOrderID = *record.WechatOrderID
// 通过订单号获取平台信息
if wechatOrder, err := s.wechatOrderRepo.GetByOutTradeNo(ctx, *record.WechatOrderID); err == nil && wechatOrder != nil {
item.Platform = wechatOrder.Platform
}
}
if record.TransferOrderID != nil {
item.TransferOrderID = *record.TransferOrderID
@@ -1012,3 +1226,445 @@ func (s *FinanceApplicationServiceImpl) GetRechargeConfig(ctx context.Context) (
AlipayRechargeBonus: bonus,
}, nil
}
// GetWechatOrderStatus 获取微信订单状态
func (s *FinanceApplicationServiceImpl) GetWechatOrderStatus(ctx context.Context, outTradeNo string) (*responses.WechatOrderStatusResponse, error) {
if outTradeNo == "" {
return nil, fmt.Errorf("缺少商户订单号")
}
// 查找微信订单
wechatOrder, err := s.wechatOrderRepo.GetByOutTradeNo(ctx, outTradeNo)
if err != nil {
s.logger.Error("查找微信订单失败", zap.String("out_trade_no", outTradeNo), zap.Error(err))
return nil, fmt.Errorf("查找微信订单失败: %w", err)
}
if wechatOrder == nil {
s.logger.Error("微信订单不存在", zap.String("out_trade_no", outTradeNo))
return nil, fmt.Errorf("微信订单不存在")
}
// 如果订单状态为pending主动查询微信订单状态
if wechatOrder.Status == finance_entities.WechatOrderStatusPending {
s.logger.Info("订单状态为pending主动查询微信订单状态",
zap.String("out_trade_no", outTradeNo),
)
// 调用微信查询接口
transaction, err := s.wechatPayService.QueryOrderStatus(ctx, outTradeNo)
if err != nil {
s.logger.Error("查询微信订单状态失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
// 查询失败不影响返回,继续使用数据库中的状态
} else {
// 解析微信返回的状态
tradeState := ""
transactionID := ""
if transaction.TradeState != nil {
tradeState = *transaction.TradeState
}
if transaction.TransactionId != nil {
transactionID = *transaction.TransactionId
}
s.logger.Info("微信查询订单状态返回",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_state", tradeState),
zap.String("transaction_id", transactionID),
)
// 使用公共方法更新订单状态
err = s.updateWechatOrderStatus(ctx, outTradeNo, tradeState, transaction)
if err != nil {
s.logger.Error("更新微信订单状态失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_state", tradeState),
zap.Error(err),
)
}
// 重新获取更新后的订单信息
updatedOrder, err := s.wechatOrderRepo.GetByOutTradeNo(ctx, outTradeNo)
if err == nil && updatedOrder != nil {
wechatOrder = updatedOrder
}
}
}
// 判断是否处理中
isProcessing := wechatOrder.Status == finance_entities.WechatOrderStatusPending
// 判断是否可以重试(失败状态可以重试)
canRetry := wechatOrder.Status == finance_entities.WechatOrderStatusFailed
// 转换为响应DTO
response := &responses.WechatOrderStatusResponse{
OutTradeNo: wechatOrder.OutTradeNo,
TransactionID: wechatOrder.TradeNo,
Status: string(wechatOrder.Status),
Amount: wechatOrder.Amount,
Subject: wechatOrder.Subject,
Platform: wechatOrder.Platform,
CreatedAt: wechatOrder.CreatedAt,
UpdatedAt: wechatOrder.UpdatedAt,
NotifyTime: wechatOrder.NotifyTime,
ReturnTime: wechatOrder.ReturnTime,
ErrorCode: &wechatOrder.ErrorCode,
ErrorMessage: &wechatOrder.ErrorMessage,
IsProcessing: isProcessing,
CanRetry: canRetry,
}
// 如果错误码为空设置为nil
if wechatOrder.ErrorCode == "" {
response.ErrorCode = nil
}
if wechatOrder.ErrorMessage == "" {
response.ErrorMessage = nil
}
s.logger.Info("查询微信订单状态完成",
zap.String("out_trade_no", outTradeNo),
zap.String("status", string(wechatOrder.Status)),
zap.Bool("is_processing", isProcessing),
zap.Bool("can_retry", canRetry),
)
return response, nil
}
// updateWechatOrderStatus 根据微信状态更新本地订单状态
func (s *FinanceApplicationServiceImpl) updateWechatOrderStatus(ctx context.Context, outTradeNo string, tradeState string, transaction *payments.Transaction) error {
// 查找微信订单
wechatOrder, err := s.wechatOrderRepo.GetByOutTradeNo(ctx, outTradeNo)
if err != nil {
s.logger.Error("查找微信订单失败", zap.String("out_trade_no", outTradeNo), zap.Error(err))
return fmt.Errorf("查找微信订单失败: %w", err)
}
if wechatOrder == nil {
s.logger.Error("微信订单不存在", zap.String("out_trade_no", outTradeNo))
return fmt.Errorf("微信订单不存在")
}
switch tradeState {
case payment.TradeStateSuccess:
// 支付成功,调用公共处理逻辑
transactionID := ""
if transaction.TransactionId != nil {
transactionID = *transaction.TransactionId
}
payAmount := decimal.Zero
if transaction.Amount != nil && transaction.Amount.Total != nil {
// 将分转换为元
payAmount = decimal.NewFromInt(*transaction.Amount.Total).Div(decimal.NewFromInt(100))
}
return s.processWechatPaymentSuccess(ctx, outTradeNo, transactionID, payAmount)
case payment.TradeStateClosed:
// 交易关闭
s.logger.Info("微信订单交易关闭",
zap.String("out_trade_no", outTradeNo),
)
wechatOrder.MarkClosed()
err = s.wechatOrderRepo.Update(ctx, *wechatOrder)
if err != nil {
s.logger.Error("更新微信订单关闭状态失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return err
}
s.logger.Info("微信订单关闭状态更新成功",
zap.String("out_trade_no", outTradeNo),
)
case payment.TradeStateNotPay:
// 未支付保持pending状态
s.logger.Info("微信订单未支付",
zap.String("out_trade_no", outTradeNo),
)
default:
// 其他状态,记录日志
s.logger.Info("微信订单其他状态",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_state", tradeState),
)
}
return nil
}
// HandleWechatPayCallback 处理微信支付回调
func (s *FinanceApplicationServiceImpl) HandleWechatPayCallback(ctx context.Context, r *http.Request) error {
if s.wechatPayService == nil {
s.logger.Error("微信支付服务未初始化")
return fmt.Errorf("微信支付服务未初始化")
}
// 解析并验证微信支付回调通知
transaction, err := s.wechatPayService.HandleWechatPayNotification(ctx, r)
if err != nil {
s.logger.Error("微信支付回调验证失败", zap.Error(err))
return err
}
// 提取回调数据
outTradeNo := ""
if transaction.OutTradeNo != nil {
outTradeNo = *transaction.OutTradeNo
}
transactionID := ""
if transaction.TransactionId != nil {
transactionID = *transaction.TransactionId
}
tradeState := ""
if transaction.TradeState != nil {
tradeState = *transaction.TradeState
}
totalAmount := decimal.Zero
if transaction.Amount != nil && transaction.Amount.Total != nil {
// 将分转换为元
totalAmount = decimal.NewFromInt(*transaction.Amount.Total).Div(decimal.NewFromInt(100))
}
// 记录回调数据
s.logger.Info("微信支付回调数据",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("trade_state", tradeState),
zap.String("total_amount", totalAmount.String()),
)
// 检查交易状态
if tradeState != payment.TradeStateSuccess {
s.logger.Warn("微信支付交易未成功",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_state", tradeState),
)
return nil // 不返回错误,因为这是正常的业务状态
}
// 处理支付成功逻辑
err = s.processWechatPaymentSuccess(ctx, outTradeNo, transactionID, totalAmount)
if err != nil {
s.logger.Error("处理微信支付成功失败",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("amount", totalAmount.String()),
zap.Error(err),
)
return err
}
return nil
}
// processWechatPaymentSuccess 处理微信支付成功的公共逻辑
func (s *FinanceApplicationServiceImpl) processWechatPaymentSuccess(ctx context.Context, outTradeNo, transactionID string, amount decimal.Decimal) error {
// 查找微信订单
wechatOrder, err := s.wechatOrderRepo.GetByOutTradeNo(ctx, outTradeNo)
if err != nil {
s.logger.Error("查找微信订单失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return fmt.Errorf("查找微信订单失败: %w", err)
}
if wechatOrder == nil {
s.logger.Error("微信订单不存在",
zap.String("out_trade_no", outTradeNo),
)
return fmt.Errorf("微信订单不存在")
}
// 查找对应的充值记录
rechargeRecord, err := s.rechargeRecordService.GetByID(ctx, wechatOrder.RechargeID)
if err != nil {
s.logger.Error("查找充值记录失败",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", wechatOrder.RechargeID),
zap.Error(err),
)
return fmt.Errorf("查找充值记录失败: %w", err)
}
// 检查订单和充值记录状态,如果都已成功则跳过(只记录一次日志)
if wechatOrder.Status == finance_entities.WechatOrderStatusSuccess && rechargeRecord.Status == finance_entities.RechargeStatusSuccess {
s.logger.Info("微信支付订单已处理成功,跳过重复处理",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("order_id", wechatOrder.ID),
zap.String("recharge_id", rechargeRecord.ID),
)
return nil
}
// 计算充值赠送金额(复用支付宝的赠送逻辑)
bonusAmount := decimal.Zero
if len(s.config.Wallet.AliPayRechargeBonus) > 0 {
for i := len(s.config.Wallet.AliPayRechargeBonus) - 1; i >= 0; i-- {
rule := s.config.Wallet.AliPayRechargeBonus[i]
if amount.GreaterThanOrEqual(decimal.NewFromFloat(rule.RechargeAmount)) {
bonusAmount = decimal.NewFromFloat(rule.BonusAmount)
break
}
}
}
// 记录开始处理支付成功
s.logger.Info("开始处理微信支付成功",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("amount", amount.String()),
zap.String("user_id", rechargeRecord.UserID),
zap.String("bonus_amount", bonusAmount.String()),
)
// 在事务中处理支付成功逻辑
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
// 更新微信订单状态
wechatOrder.MarkSuccess(transactionID, "", "", amount, amount)
now := time.Now()
wechatOrder.NotifyTime = &now
err := s.wechatOrderRepo.Update(txCtx, *wechatOrder)
if err != nil {
s.logger.Error("更新微信订单状态失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return err
}
// 更新充值记录状态为成功
rechargeRecord.MarkSuccess()
err = s.rechargeRecordRepo.Update(txCtx, *rechargeRecord)
if err != nil {
s.logger.Error("更新充值记录状态失败",
zap.String("out_trade_no", outTradeNo),
zap.String("recharge_id", rechargeRecord.ID),
zap.Error(err),
)
return err
}
// 如果有赠送金额,创建赠送充值记录
if bonusAmount.GreaterThan(decimal.Zero) {
giftRechargeRecord := finance_entities.NewGiftRechargeRecord(rechargeRecord.UserID, bonusAmount, "充值活动赠送")
createdGift, err := s.rechargeRecordRepo.Create(txCtx, *giftRechargeRecord)
if err != nil {
s.logger.Error("创建赠送充值记录失败",
zap.String("out_trade_no", outTradeNo),
zap.String("user_id", rechargeRecord.UserID),
zap.String("bonus_amount", bonusAmount.String()),
zap.Error(err),
)
return err
}
s.logger.Info("创建赠送充值记录成功",
zap.String("out_trade_no", outTradeNo),
zap.String("gift_recharge_id", createdGift.ID),
zap.String("bonus_amount", bonusAmount.String()),
)
}
// 充值到钱包(包含赠送金额)
totalRechargeAmount := amount.Add(bonusAmount)
err = s.walletService.Recharge(txCtx, rechargeRecord.UserID, totalRechargeAmount)
if err != nil {
s.logger.Error("充值到钱包失败",
zap.String("out_trade_no", outTradeNo),
zap.String("user_id", rechargeRecord.UserID),
zap.String("total_amount", totalRechargeAmount.String()),
zap.Error(err),
)
return err
}
return nil
})
if err != nil {
s.logger.Error("处理微信支付成功失败",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("amount", amount.String()),
zap.Error(err),
)
return err
}
s.logger.Info("微信支付成功处理完成",
zap.String("out_trade_no", outTradeNo),
zap.String("transaction_id", transactionID),
zap.String("amount", amount.String()),
zap.String("bonus_amount", bonusAmount.String()),
zap.String("user_id", rechargeRecord.UserID),
)
return nil
}
// HandleWechatRefundCallback 处理微信退款回调
func (s *FinanceApplicationServiceImpl) HandleWechatRefundCallback(ctx context.Context, r *http.Request) error {
if s.wechatPayService == nil {
s.logger.Error("微信支付服务未初始化")
return fmt.Errorf("微信支付服务未初始化")
}
// 解析并验证微信退款回调通知
refund, err := s.wechatPayService.HandleRefundNotification(ctx, r)
if err != nil {
s.logger.Error("微信退款回调验证失败", zap.Error(err))
return err
}
// 记录回调数据
s.logger.Info("微信退款回调数据",
zap.String("out_trade_no", func() string {
if refund.OutTradeNo != nil {
return *refund.OutTradeNo
}
return ""
}()),
zap.String("out_refund_no", func() string {
if refund.OutRefundNo != nil {
return *refund.OutRefundNo
}
return ""
}()),
zap.String("refund_id", func() string {
if refund.RefundId != nil {
return *refund.RefundId
}
return ""
}()),
zap.Any("status", func() interface{} {
if refund.Status != nil {
return *refund.Status
}
return nil
}()),
)
// 处理退款逻辑
// 这里可以根据实际业务需求实现退款处理逻辑
s.logger.Info("微信退款回调处理完成",
zap.String("out_trade_no", func() string {
if refund.OutTradeNo != nil {
return *refund.OutTradeNo
}
return ""
}()),
zap.String("refund_id", func() string {
if refund.RefundId != nil {
return *refund.RefundId
}
return ""
}()),
)
return nil
}

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

@@ -20,6 +20,7 @@ type SubscriptionApplicationService interface {
// 我的订阅(用户专用)
ListMySubscriptions(ctx context.Context, userID string, query *queries.ListSubscriptionsQuery) (*responses.SubscriptionListResponse, error)
GetMySubscriptionStats(ctx context.Context, userID string) (*responses.SubscriptionStatsResponse, error)
CancelMySubscription(ctx context.Context, userID string, subscriptionID string) error
// 业务查询
GetUserSubscriptions(ctx context.Context, query *queries.GetUserSubscriptionsQuery) ([]*responses.SubscriptionInfoResponse, error)

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
}
@@ -304,6 +321,38 @@ func (s *SubscriptionApplicationServiceImpl) GetMySubscriptionStats(ctx context.
}, nil
}
// CancelMySubscription 取消我的订阅
// 业务流程1. 验证订阅是否属于当前用户 2. 取消订阅
func (s *SubscriptionApplicationServiceImpl) CancelMySubscription(ctx context.Context, userID string, subscriptionID string) error {
// 1. 获取订阅信息
subscription, err := s.productSubscriptionService.GetSubscriptionByID(ctx, subscriptionID)
if err != nil {
s.logger.Error("获取订阅信息失败", zap.String("subscription_id", subscriptionID), zap.Error(err))
return fmt.Errorf("订阅不存在")
}
// 2. 验证订阅是否属于当前用户
if subscription.UserID != userID {
s.logger.Warn("用户尝试取消不属于自己的订阅",
zap.String("user_id", userID),
zap.String("subscription_id", subscriptionID),
zap.String("subscription_user_id", subscription.UserID))
return fmt.Errorf("无权取消此订阅")
}
// 3. 取消订阅(软删除)
if err := s.productSubscriptionService.CancelSubscription(ctx, subscriptionID); err != nil {
s.logger.Error("取消订阅失败", zap.String("subscription_id", subscriptionID), zap.Error(err))
return fmt.Errorf("取消订阅失败: %w", err)
}
s.logger.Info("用户取消订阅成功",
zap.String("user_id", userID),
zap.String("subscription_id", subscriptionID))
return nil
}
// convertToSubscriptionInfoResponse 转换为订阅信息响应
func (s *SubscriptionApplicationServiceImpl) convertToSubscriptionInfoResponse(subscription *entities.Subscription) *responses.SubscriptionInfoResponse {
// 查询用户信息

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))
@@ -2370,7 +2426,7 @@ func (s *StatisticsApplicationServiceImpl) AdminGetConsumptionDomainStatistics(c
defaultEndDate := time.Now()
defaultStartDate := defaultEndDate.AddDate(0, 0, -7)
consumptionTrend, err = s.walletTransactionRepo.GetSystemDailyStats(ctx, defaultStartDate, defaultEndDate)
if err != nil {
if err != nil {
s.logger.Error("获取消费每日趋势失败", zap.Error(err))
return nil, 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))
@@ -2716,15 +2786,15 @@ func (s *StatisticsApplicationServiceImpl) AdminGetTodayCertifiedEnterprises(ctx
}
enterprise := map[string]interface{}{
"id": cert.ID,
"user_id": cert.UserID,
"username": user.Username,
"enterprise_name": enterpriseInfo.CompanyName,
"legal_person_name": enterpriseInfo.LegalPersonName,
"legal_person_phone": enterpriseInfo.LegalPersonPhone,
"unified_social_code": enterpriseInfo.UnifiedSocialCode,
"enterprise_address": enterpriseInfo.EnterpriseAddress,
"certified_at": cert.CompletedAt.Format(time.RFC3339),
"id": cert.ID,
"user_id": cert.UserID,
"username": user.Username,
"enterprise_name": enterpriseInfo.CompanyName,
"legal_person_name": enterpriseInfo.LegalPersonName,
"legal_person_phone": enterpriseInfo.LegalPersonPhone,
"unified_social_code": enterpriseInfo.UnifiedSocialCode,
"enterprise_address": enterpriseInfo.EnterpriseAddress,
"certified_at": cert.CompletedAt.Format(time.RFC3339),
}
enterprises = append(enterprises, enterprise)
}

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"
@@ -305,6 +307,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 +522,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,
@@ -572,6 +589,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 +698,8 @@ func NewContainer() *Container {
certification_service.NewEnterpriseInfoSubmitRecordService,
// 文章领域服务
article_service.NewArticleService,
// 公告领域服务
article_service.NewAnnouncementService,
// 统计领域服务
statistics_service.NewStatisticsAggregateService,
statistics_service.NewStatisticsCalculationService,
@@ -776,6 +800,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 +811,7 @@ func NewContainer() *Container {
redisAddr,
logger,
articleApplicationService,
announcementApplicationService,
apiApplicationService,
walletService,
subscriptionService,
@@ -844,10 +870,13 @@ 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,
@@ -856,10 +885,13 @@ func NewContainer() *Container {
) finance.FinanceApplicationService {
return finance.NewFinanceApplicationService(
aliPayClient,
wechatPayService,
walletService,
rechargeRecordService,
walletTransactionRepo,
alipayOrderRepo,
wechatOrderRepo,
rechargeRecordRepo,
userRepo,
txManager,
logger,
@@ -942,6 +974,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(
@@ -999,6 +1048,44 @@ 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
},
),
// HTTP处理器
fx.Provide(
// 用户HTTP处理器
@@ -1024,6 +1111,15 @@ 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)
},
),
// 路由注册
@@ -1040,6 +1136,8 @@ func NewContainer() *Container {
routes.NewProductAdminRoutes,
// 文章路由
routes.NewArticleRoutes,
// 公告路由
routes.NewAnnouncementRoutes,
// API路由
routes.NewApiRoutes,
// 统计路由
@@ -1151,6 +1249,7 @@ func RegisterRoutes(
productRoutes *routes.ProductRoutes,
productAdminRoutes *routes.ProductAdminRoutes,
articleRoutes *routes.ArticleRoutes,
announcementRoutes *routes.AnnouncementRoutes,
apiRoutes *routes.ApiRoutes,
statisticsRoutes *routes.StatisticsRoutes,
cfg *config.Config,
@@ -1168,6 +1267,7 @@ func RegisterRoutes(
productRoutes.Register(router)
productAdminRoutes.Register(router)
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

@@ -2,7 +2,9 @@ package entities
import (
"crypto/rand"
"database/sql/driver"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
@@ -18,14 +20,86 @@ const (
ApiUserStatusFrozen = "frozen"
)
// WhiteListItem 白名单项包含IP地址、添加时间和备注
type WhiteListItem struct {
IPAddress string `json:"ip_address"` // IP地址
AddedAt time.Time `json:"added_at"` // 添加时间
Remark string `json:"remark"` // 备注
}
// WhiteList 白名单类型,支持向后兼容(旧的字符串数组格式)
type WhiteList []WhiteListItem
// Value 实现 driver.Valuer 接口,用于数据库写入
func (w WhiteList) Value() (driver.Value, error) {
if w == nil {
return "[]", nil
}
data, err := json.Marshal(w)
if err != nil {
return nil, err
}
return string(data), nil
}
// Scan 实现 sql.Scanner 接口,用于数据库读取(支持向后兼容)
func (w *WhiteList) Scan(value interface{}) error {
if value == nil {
*w = WhiteList{}
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("无法扫描 WhiteList 类型")
}
if len(bytes) == 0 || string(bytes) == "[]" || string(bytes) == "null" {
*w = WhiteList{}
return nil
}
// 首先尝试解析为新格式(结构体数组)
var items []WhiteListItem
if err := json.Unmarshal(bytes, &items); err == nil {
// 成功解析为新格式
*w = WhiteList(items)
return nil
}
// 如果失败,尝试解析为旧格式(字符串数组)
var oldFormat []string
if err := json.Unmarshal(bytes, &oldFormat); err != nil {
return err
}
// 将旧格式转换为新格式
now := time.Now()
items = make([]WhiteListItem, 0, len(oldFormat))
for _, ip := range oldFormat {
items = append(items, WhiteListItem{
IPAddress: ip,
AddedAt: now, // 使用当前时间作为添加时间(因为旧数据没有时间信息)
Remark: "", // 旧数据没有备注信息
})
}
*w = WhiteList(items)
return nil
}
// ApiUser API用户聚合根
type ApiUser struct {
ID string `gorm:"primaryKey;type:varchar(64)" json:"id"`
UserId string `gorm:"type:varchar(36);not null;uniqueIndex" json:"user_id"`
AccessId string `gorm:"type:varchar(64);not null;uniqueIndex" json:"access_id"`
SecretKey string `gorm:"type:varchar(128);not null" json:"secret_key"`
Status string `gorm:"type:varchar(20);not null;default:'normal'" json:"status"`
WhiteList []string `gorm:"type:json;serializer:json;default:'[]'" json:"white_list"` // 支持多个白名单
ID string `gorm:"primaryKey;type:varchar(64)" json:"id"`
UserId string `gorm:"type:varchar(36);not null;uniqueIndex" json:"user_id"`
AccessId string `gorm:"type:varchar(64);not null;uniqueIndex" json:"access_id"`
SecretKey string `gorm:"type:varchar(128);not null" json:"secret_key"`
Status string `gorm:"type:varchar(20);not null;default:'normal'" json:"status"`
WhiteList WhiteList `gorm:"type:json;default:'[]'" json:"white_list"` // 支持多个白名单包含IP和添加时间支持向后兼容
// 余额预警配置
BalanceAlertEnabled bool `gorm:"default:true" json:"balance_alert_enabled" comment:"是否启用余额预警"`
@@ -41,7 +115,7 @@ type ApiUser struct {
// IsWhiteListed 校验IP/域名是否在白名单
func (u *ApiUser) IsWhiteListed(target string) bool {
for _, w := range u.WhiteList {
if w == target {
if w.IPAddress == target {
return true
}
}
@@ -77,7 +151,7 @@ func NewApiUser(userId string, defaultAlertEnabled bool, defaultAlertThreshold f
AccessId: accessId,
SecretKey: secretKey,
Status: ApiUserStatusNormal,
WhiteList: []string{},
WhiteList: WhiteList{},
BalanceAlertEnabled: defaultAlertEnabled,
BalanceAlertThreshold: defaultAlertThreshold,
}, nil
@@ -90,12 +164,12 @@ func (u *ApiUser) Freeze() {
func (u *ApiUser) Unfreeze() {
u.Status = ApiUserStatusNormal
}
func (u *ApiUser) UpdateWhiteList(list []string) {
u.WhiteList = list
func (u *ApiUser) UpdateWhiteList(list []WhiteListItem) {
u.WhiteList = WhiteList(list)
}
// AddToWhiteList 新增白名单项(防御性校验)
func (u *ApiUser) AddToWhiteList(entry string) error {
func (u *ApiUser) AddToWhiteList(entry string, remark string) error {
if len(u.WhiteList) >= 10 {
return errors.New("白名单最多只能有10个")
}
@@ -103,27 +177,31 @@ func (u *ApiUser) AddToWhiteList(entry string) error {
return errors.New("非法IP")
}
for _, w := range u.WhiteList {
if w == entry {
if w.IPAddress == entry {
return errors.New("白名单已存在")
}
}
u.WhiteList = append(u.WhiteList, entry)
u.WhiteList = append(u.WhiteList, WhiteListItem{
IPAddress: entry,
AddedAt: time.Now(),
Remark: remark,
})
return nil
}
// BeforeUpdate GORM钩子更新前确保WhiteList不为nil
func (u *ApiUser) BeforeUpdate(tx *gorm.DB) error {
if u.WhiteList == nil {
u.WhiteList = []string{}
u.WhiteList = WhiteList{}
}
return nil
}
// RemoveFromWhiteList 删除白名单项
func (u *ApiUser) RemoveFromWhiteList(entry string) error {
newList := make([]string, 0, len(u.WhiteList))
newList := make([]WhiteListItem, 0, len(u.WhiteList))
for _, w := range u.WhiteList {
if w != entry {
if w.IPAddress != entry {
newList = append(newList, w)
}
}
@@ -216,9 +294,9 @@ func (u *ApiUser) Validate() error {
if len(u.WhiteList) > 10 {
return errors.New("白名单最多只能有10个")
}
for _, ip := range u.WhiteList {
if net.ParseIP(ip) == nil {
return errors.New("白名单项必须为合法IP地址: " + ip)
for _, item := range u.WhiteList {
if net.ParseIP(item.IPAddress) == nil {
return errors.New("白名单项必须为合法IP地址: " + item.IPAddress)
}
}
return nil
@@ -259,7 +337,26 @@ func (c *ApiUser) BeforeCreate(tx *gorm.DB) error {
c.ID = uuid.New().String()
}
if c.WhiteList == nil {
c.WhiteList = []string{}
c.WhiteList = WhiteList{}
}
return nil
}
// AfterFind GORM钩子查询后处理数据确保AddedAt不为零值
func (u *ApiUser) AfterFind(tx *gorm.DB) error {
// 如果 WhiteList 为空,初始化为空数组
if u.WhiteList == nil {
u.WhiteList = WhiteList{}
return nil
}
// 确保所有项的AddedAt不为零值处理可能从旧数据迁移的情况
now := time.Now()
for i := range u.WhiteList {
if u.WhiteList[i].AddedAt.IsZero() {
u.WhiteList[i].AddedAt = now
}
}
return nil
}

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

@@ -2,6 +2,7 @@ package services
import (
"context"
"time"
"tyapi-server/internal/config"
"tyapi-server/internal/domains/api/entities"
repo "tyapi-server/internal/domains/api/repositories"
@@ -10,7 +11,7 @@ import (
type ApiUserAggregateService interface {
CreateApiUser(ctx context.Context, apiUserId string) error
UpdateWhiteList(ctx context.Context, apiUserId string, whiteList []string) error
AddToWhiteList(ctx context.Context, apiUserId string, entry string) error
AddToWhiteList(ctx context.Context, apiUserId string, entry string, remark string) error
RemoveFromWhiteList(ctx context.Context, apiUserId string, entry string) error
FreezeApiUser(ctx context.Context, apiUserId string) error
UnfreezeApiUser(ctx context.Context, apiUserId string) error
@@ -44,16 +45,25 @@ func (s *ApiUserAggregateServiceImpl) UpdateWhiteList(ctx context.Context, apiUs
if err != nil {
return err
}
apiUser.UpdateWhiteList(whiteList)
// 将字符串数组转换为WhiteListItem数组
items := make([]entities.WhiteListItem, 0, len(whiteList))
now := time.Now()
for _, ip := range whiteList {
items = append(items, entities.WhiteListItem{
IPAddress: ip,
AddedAt: now, // 批量更新时使用当前时间
})
}
apiUser.UpdateWhiteList(items) // UpdateWhiteList 会转换为 WhiteList 类型
return s.repo.Update(ctx, apiUser)
}
func (s *ApiUserAggregateServiceImpl) AddToWhiteList(ctx context.Context, apiUserId string, entry string) error {
func (s *ApiUserAggregateServiceImpl) AddToWhiteList(ctx context.Context, apiUserId string, entry string, remark string) error {
apiUser, err := s.repo.FindByUserId(ctx, apiUserId)
if err != nil {
return err
}
err = apiUser.AddToWhiteList(entry)
err = apiUser.AddToWhiteList(entry, remark)
if err != nil {
return err
}
@@ -90,7 +100,6 @@ func (s *ApiUserAggregateServiceImpl) UnfreezeApiUser(ctx context.Context, apiUs
return s.repo.Update(ctx, apiUser)
}
func (s *ApiUserAggregateServiceImpl) LoadApiUserByAccessId(ctx context.Context, accessId string) (*entities.ApiUser, error) {
return s.repo.FindByAccessId(ctx, accessId)
}
@@ -100,12 +109,12 @@ func (s *ApiUserAggregateServiceImpl) LoadApiUserByUserId(ctx context.Context, a
if err != nil {
return nil, err
}
// 确保WhiteList不为nil
if apiUser.WhiteList == nil {
apiUser.WhiteList = []string{}
apiUser.WhiteList = entities.WhiteList{}
}
return apiUser, nil
}
@@ -117,10 +126,10 @@ func (s *ApiUserAggregateServiceImpl) SaveApiUser(ctx context.Context, apiUser *
if exists != nil {
// 确保WhiteList不为nil
if apiUser.WhiteList == nil {
apiUser.WhiteList = []string{}
apiUser.WhiteList = []entities.WhiteListItem{}
}
return s.repo.Update(ctx, apiUser)
} else {
return s.repo.Create(ctx, apiUser)
}
}
}

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 {
@@ -44,7 +49,7 @@ func ProcessIVYZ9363Request(ctx context.Context, params []byte, deps *processors
reqData := map[string]interface{}{
"data": map[string]interface{}{
"name_man": encryptedManName,
"idcard_man": encryptedManIDCard,
"idcard_man": encryptedManIDCard,
"name_woman": encryptedWomanName,
"idcard_woman": encryptedWomanIDCard,
},
@@ -60,4 +65,4 @@ func ProcessIVYZ9363Request(ctx context.Context, params []byte, deps *processors
}
return respBytes, 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.EntName != "" {
nameValue = paramsDto.EntName
} else {
nameValue = paramsDto.EntCode
}
}
// 确定使用哪个值作为 name
if paramsDto.Type == "ent" && 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,140 +1,28 @@
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 (
AlipayOrderPlatformApp = "app" // 支付宝APP支付
AlipayOrderPlatformH5 = "h5" // 支付宝H5支付
AlipayOrderPlatformPC = "pc" // 支付宝PC支付
AlipayOrderPlatformApp = "app" // 支付宝APP支付
AlipayOrderPlatformH5 = "h5" // 支付宝H5支付
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
@@ -153,6 +174,17 @@ func NewAlipayRechargeRecord(userID string, amount decimal.Decimal, alipayOrderI
}
}
// NewWechatRechargeRecord 工厂方法 - 创建微信充值记录
func NewWechatRechargeRecord(userID string, amount decimal.Decimal, wechatOrderID string) *RechargeRecord {
return &RechargeRecord{
UserID: userID,
Amount: amount,
RechargeType: RechargeTypeWechat,
Status: RechargeStatusPending,
WechatOrderID: &wechatOrderID,
}
}
// NewTransferRechargeRecord 工厂方法 - 创建对公转账充值记录
func NewTransferRechargeRecord(userID string, amount decimal.Decimal, transferOrderID, notes string) *RechargeRecord {
return &RechargeRecord{

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

@@ -17,4 +17,4 @@ type AlipayOrderRepository interface {
UpdateStatus(ctx context.Context, id string, status entities.AlipayOrderStatus) error
Delete(ctx context.Context, id string) error
Exists(ctx context.Context, id string) (bool, error)
}
}

View File

@@ -17,20 +17,20 @@ type RechargeRecordRepository interface {
GetByTransferOrderID(ctx context.Context, transferOrderID string) (*entities.RechargeRecord, error)
Update(ctx context.Context, record entities.RechargeRecord) error
UpdateStatus(ctx context.Context, id string, status entities.RechargeStatus) error
// 管理员查询方法
List(ctx context.Context, options interfaces.ListOptions) ([]entities.RechargeRecord, error)
Count(ctx context.Context, options interfaces.CountOptions) (int64, error)
// 统计相关方法
GetTotalAmountByUserId(ctx context.Context, userId string) (float64, error)
GetTotalAmountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (float64, error)
GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error)
// 系统级别统计方法
GetSystemTotalAmount(ctx context.Context) (float64, error)
GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error)
GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
}
}

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

@@ -20,8 +20,8 @@ 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:"组合包项目列表"`
@@ -62,7 +62,6 @@ func (p *Product) CanBeSubscribed() bool {
return p.IsValid()
}
// UpdateSEO 更新SEO信息
func (p *Product) UpdateSEO(title, description, keywords string) {
p.SEOTitle = title
@@ -115,4 +114,4 @@ func (p *Product) GetOldID() string {
// HasOldID 检查是否有旧ID
func (p *Product) HasOldID() bool {
return p.OldID != nil && *p.OldID != ""
}
}

View File

@@ -18,9 +18,9 @@ import (
// ProductManagementService 产品管理领域服务
// 负责产品的基本管理操作,包括创建、查询、更新等
type ProductManagementService struct {
productRepo repositories.ProductRepository
categoryRepo repositories.ProductCategoryRepository
logger *zap.Logger
productRepo repositories.ProductRepository
categoryRepo repositories.ProductCategoryRepository
logger *zap.Logger
}
// NewProductManagementService 创建产品管理领域服务
@@ -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)
@@ -326,9 +327,9 @@ func (s *ProductManagementService) ValidateProductCode(code string, excludeID st
func (s *ProductManagementService) ListProducts(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) ([]*entities.Product, int64, error) {
// 构建查询条件
query := &queries.ListProductsQuery{
Page: options.Page,
PageSize: options.PageSize,
SortBy: options.Sort,
Page: options.Page,
PageSize: options.PageSize,
SortBy: options.Sort,
SortOrder: options.Order,
}
@@ -370,9 +371,9 @@ func (s *ProductManagementService) ListProducts(ctx context.Context, filters map
func (s *ProductManagementService) ListProductsWithSubscriptionStatus(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) ([]*entities.Product, map[string]bool, int64, error) {
// 构建查询条件
query := &queries.ListProductsQuery{
Page: options.Page,
PageSize: options.PageSize,
SortBy: options.Sort,
Page: options.Page,
PageSize: options.PageSize,
SortBy: options.Sort,
SortOrder: options.Order,
}
@@ -415,4 +416,4 @@ func (s *ProductManagementService) ListProductsWithSubscriptionStatus(ctx contex
)
return products, subscriptionStatusMap, total, nil
}
}

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)
@@ -48,13 +47,15 @@ type UserRepository interface {
// 统计信息
GetStats(ctx context.Context) (*UserStats, error)
GetStatsByDateRange(ctx context.Context, startDate, endDate string) (*UserStats, error)
// 系统级别统计方法
GetSystemUserStats(ctx context.Context) (*UserStats, error)
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)
GetUserCallRankingByConsumption(ctx context.Context, period string, limit int) ([]map[string]interface{}, error)
@@ -117,4 +118,4 @@ type EnterpriseInfoRepository interface {
Count(ctx context.Context, options interfaces.CountOptions) (int64, error)
List(ctx context.Context, options interfaces.ListOptions) ([]entities.EnterpriseInfo, error)
Exists(ctx context.Context, id string) (bool, error)
}
}

View File

@@ -46,7 +46,7 @@ func NewConnection(config Config) (*DB, error) {
NowFunc: func() time.Time {
return time.Now().In(time.FixedZone("CST", 8*3600)) // 强制使用北京时间
},
PrepareStmt: true,
PrepareStmt: true,
DisableAutomaticPing: false,
}

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"
@@ -14,7 +15,7 @@ import (
)
const (
ApiCallsTable = "api_calls"
ApiCallsTable = "api_calls"
ApiCallCacheTTL = 10 * time.Minute
)
@@ -212,7 +213,7 @@ func (r *GormApiCallRepository) ListByUserIdWithFiltersAndProductName(ctx contex
// 转换为entities.ApiCall并构建产品名称映射
var calls []*entities.ApiCall
productNameMap := make(map[string]string)
for _, c := range callsWithProduct {
call := c.ApiCall
calls = append(calls, &call)
@@ -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)
@@ -237,7 +243,7 @@ func (r *GormApiCallRepository) CountByUserIdAndDateRange(ctx context.Context, u
// GetDailyStatsByUserId 获取用户每日API调用统计
func (r *GormApiCallRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -250,19 +256,19 @@ func (r *GormApiCallRepository) GetDailyStatsByUserId(ctx context.Context, userI
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetMonthlyStatsByUserId 获取用户每月API调用统计
func (r *GormApiCallRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -275,12 +281,12 @@ func (r *GormApiCallRepository) GetMonthlyStatsByUserId(ctx context.Context, use
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -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)
}
@@ -332,6 +359,12 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
whereArgs = append(whereArgs, "%"+productName+"%")
}
// 企业名称筛选
if companyName, ok := filters["company_name"].(string); ok && companyName != "" {
whereCondition += " AND ei.company_name LIKE ?"
whereArgs = append(whereArgs, "%"+companyName+"%")
}
// 状态筛选
if status, ok := filters["status"].(string); ok && status != "" {
whereCondition += " AND ac.status = ?"
@@ -340,9 +373,12 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
}
// 构建JOIN查询
// 需要JOIN product表获取产品名称JOIN users和enterprise_infos表获取企业名称
query := r.GetDB(ctx).Table("api_calls ac").
Select("ac.*, p.name as product_name").
Joins("LEFT JOIN product p ON ac.product_id = p.id").
Joins("LEFT JOIN users u ON ac.user_id = u.id").
Joins("LEFT JOIN enterprise_infos ei ON u.id = ei.user_id").
Where(whereCondition, whereArgs...)
// 获取总数
@@ -374,7 +410,7 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
// 转换为entities.ApiCall并构建产品名称映射
var calls []*entities.ApiCall
productNameMap := make(map[string]string)
for _, c := range callsWithProduct {
call := c.ApiCall
calls = append(calls, &call)
@@ -395,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
}
@@ -406,7 +443,7 @@ func (r *GormApiCallRepository) GetSystemCallsByDateRange(ctx context.Context, s
// GetSystemDailyStats 获取系统每日API调用统计
func (r *GormApiCallRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
DATE(created_at) as date,
@@ -417,35 +454,35 @@ func (r *GormApiCallRepository) GetSystemDailyStats(ctx context.Context, startDa
GROUP BY DATE(created_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
}
// GetSystemMonthlyStats 获取系统每月API调用统计
func (r *GormApiCallRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
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
`
err := r.GetDB(ctx).Raw(sql, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -453,7 +490,7 @@ func (r *GormApiCallRepository) GetSystemMonthlyStats(ctx context.Context, start
func (r *GormApiCallRepository) GetApiPopularityRanking(ctx context.Context, period string, limit int) ([]map[string]interface{}, error) {
var sql string
var args []interface{}
switch period {
case "today":
sql = `
@@ -508,12 +545,12 @@ func (r *GormApiCallRepository) GetApiPopularityRanking(ctx context.Context, per
default:
return nil, fmt.Errorf("不支持的时间周期: %s", period)
}
var results []map[string]interface{}
err := r.GetDB(ctx).Raw(sql, args...).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
}

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
}
@@ -95,4 +95,4 @@ func (r *GormAlipayOrderRepository) Exists(ctx context.Context, id string) (bool
var count int64
err := r.GetDB(ctx).Model(&entities.AlipayOrder{}).Where("id = ?", id).Count(&count).Error
return count > 0, 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 {
query = query.Where("created_at >= ?", startTime)
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 {
query = query.Where("created_at <= ?", endTime)
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 {
query = query.Where("amount >= ?", amount)
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 {
query = query.Where("amount <= ?", amount)
if hasCompanyNameFilter {
query = query.Where("rr.amount <= ?", amount)
} else {
query = query.Where("amount <= ?", amount)
}
}
} else {
// 其他过滤器使用等值查询
query = query.Where(key+" = ?", value)
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 != "" {
query = query.Where("user_id IN ?", strings.Split(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 {
query = query.Where("created_at >= ?", startTime)
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 {
query = query.Where("created_at <= ?", endTime)
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 {
query = query.Where("amount >= ?", amount)
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 {
query = query.Where("amount <= ?", amount)
if hasCompanyNameFilter {
query = query.Where("rr.amount <= ?", amount)
} else {
query = query.Where("amount <= ?", amount)
}
}
} else {
// 其他过滤器使用等值查询
query = query.Where(key+" = ?", value)
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,9 +280,17 @@ func (r *GormRechargeRecordRepository) List(ctx context.Context, options interfa
if options.Order == "desc" || options.Order == "DESC" {
order = "DESC"
}
query = query.Order(options.Sort + " " + order)
if hasCompanyNameFilter {
query = query.Order("rr." + options.Sort + " " + order)
} else {
query = query.Order(options.Sort + " " + order)
}
} else {
query = query.Order("created_at DESC")
if hasCompanyNameFilter {
query = query.Order("rr.created_at DESC")
} else {
query = query.Order("created_at DESC")
}
}
if options.Page > 0 && options.PageSize > 0 {
@@ -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

@@ -179,7 +179,7 @@ func (r *GormWalletTransactionRepository) GetTotalAmountByUserIdAndDateRange(ctx
// GetDailyStatsByUserId 获取用户每日消费统计
func (r *GormWalletTransactionRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -192,19 +192,19 @@ func (r *GormWalletTransactionRepository) GetDailyStatsByUserId(ctx context.Cont
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetMonthlyStatsByUserId 获取用户每月消费统计
func (r *GormWalletTransactionRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -217,12 +217,12 @@ func (r *GormWalletTransactionRepository) GetMonthlyStatsByUserId(ctx context.Co
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -358,7 +358,7 @@ func (r *GormWalletTransactionRepository) ListByUserIdWithFiltersAndProductName(
// 转换为entities.WalletTransaction并构建产品名称映射
var transactions []*entities.WalletTransaction
productNameMap := make(map[string]string)
for _, t := range transactionsWithProduct {
transaction := t.WalletTransaction
transactions = append(transactions, &transaction)
@@ -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 >= ?"
@@ -410,6 +424,12 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
whereArgs = append(whereArgs, "%"+productName+"%")
}
// 企业名称筛选
if companyName, ok := filters["company_name"].(string); ok && companyName != "" {
whereCondition += " AND ei.company_name LIKE ?"
whereArgs = append(whereArgs, "%"+companyName+"%")
}
// 金额范围筛选
if minAmount, ok := filters["min_amount"].(string); ok && minAmount != "" {
whereCondition += " AND wt.amount >= ?"
@@ -422,9 +442,12 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
}
// 构建JOIN查询
// 需要JOIN product表获取产品名称JOIN users和enterprise_infos表获取企业名称
query := r.GetDB(ctx).Table("wallet_transactions wt").
Select("wt.*, p.name as product_name").
Joins("LEFT JOIN product p ON wt.product_id = p.id").
Joins("LEFT JOIN users u ON wt.user_id = u.id").
Joins("LEFT JOIN enterprise_infos ei ON u.id = ei.user_id").
Where(whereCondition, whereArgs...)
// 获取总数
@@ -456,7 +479,7 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
// 转换为entities.WalletTransaction并构建产品名称映射
var transactions []*entities.WalletTransaction
productNameMap := make(map[string]string)
for _, t := range transactionsWithProduct {
transaction := t.WalletTransaction
transactions = append(transactions, &transaction)
@@ -563,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
@@ -575,7 +599,7 @@ func (r *GormWalletTransactionRepository) GetSystemAmountByDateRange(ctx context
// GetSystemDailyStats 获取系统每日消费统计
func (r *GormWalletTransactionRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
DATE(created_at) as date,
@@ -586,34 +610,34 @@ func (r *GormWalletTransactionRepository) GetSystemDailyStats(ctx context.Contex
GROUP BY DATE(created_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
}
// GetSystemMonthlyStats 获取系统每月消费统计
func (r *GormWalletTransactionRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
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
`
err := r.GetDB(ctx).Raw(sql, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
}

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

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

@@ -34,7 +34,7 @@ type ZhichaResp struct {
type ZhichaConfig struct {
URL string
AppID string
AppSecret string
AppSecret string
EncryptKey string
}
@@ -133,14 +133,13 @@ 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" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
isTimeout = true
}
if isTimeout {
// 超时错误应该返回数据源异常,而不是系统异常
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))

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

@@ -110,7 +110,10 @@ func (h *ApiHandler) GetUserWhiteList(c *gin.Context) {
return
}
result, err := h.appService.GetUserWhiteList(c.Request.Context(), userID)
// 获取查询参数
remarkKeyword := c.Query("remark") // 备注模糊查询关键词
result, err := h.appService.GetUserWhiteList(c.Request.Context(), userID, remarkKeyword)
if err != nil {
h.logger.Error("获取用户白名单失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
@@ -134,7 +137,7 @@ func (h *ApiHandler) AddWhiteListIP(c *gin.Context) {
return
}
err := h.appService.AddWhiteListIP(c.Request.Context(), userID, req.IPAddress)
err := h.appService.AddWhiteListIP(c.Request.Context(), userID, req.IPAddress, req.Remark)
if err != nil {
h.logger.Error("添加白名单IP失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())

View File

@@ -2,7 +2,9 @@
package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"time"
@@ -19,12 +21,12 @@ import (
// FinanceHandler 财务HTTP处理器
type FinanceHandler struct {
appService finance.FinanceApplicationService
invoiceAppService finance.InvoiceApplicationService
adminInvoiceAppService finance.AdminInvoiceApplicationService
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
appService finance.FinanceApplicationService
invoiceAppService finance.InvoiceApplicationService
adminInvoiceAppService finance.AdminInvoiceApplicationService
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
}
// NewFinanceHandler 创建财务HTTP处理器
@@ -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 处理支付宝同步支付通知,跳转到前端成功页面
@@ -240,7 +359,7 @@ func (h *FinanceHandler) HandleAlipayReturn(c *gin.Context) {
// 通过应用服务处理同步回调,查询订单状态
orderStatus, err := h.appService.HandleAlipayReturn(c.Request.Context(), outTradeNo)
if err != nil {
h.logger.Error("支付宝同步回调处理失败",
h.logger.Error("支付宝同步回调处理失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err))
h.redirectToFailPage(c, outTradeNo, "订单处理失败")
@@ -257,7 +376,7 @@ func (h *FinanceHandler) HandleAlipayReturn(c *gin.Context) {
switch orderStatus {
case "TRADE_SUCCESS":
// 支付成功,跳转到前端成功页面
successURL := fmt.Sprintf("%s/finance/wallet/success?out_trade_no=%s&trade_no=%s&amount=%s",
successURL := fmt.Sprintf("%s/finance/wallet/success?out_trade_no=%s&trade_no=%s&amount=%s",
frontendDomain, outTradeNo, tradeNo, totalAmount)
c.Redirect(http.StatusFound, successURL)
case "WAIT_BUYER_PAY":
@@ -275,8 +394,8 @@ func (h *FinanceHandler) redirectToFailPage(c *gin.Context, outTradeNo, reason s
if gin.Mode() == gin.DebugMode {
frontendDomain = "http://localhost:5173"
}
failURL := fmt.Sprintf("%s/finance/wallet/fail?out_trade_no=%s&reason=%s",
failURL := fmt.Sprintf("%s/finance/wallet/fail?out_trade_no=%s&reason=%s",
frontendDomain, outTradeNo, reason)
c.Redirect(http.StatusFound, failURL)
}
@@ -287,8 +406,8 @@ func (h *FinanceHandler) redirectToProcessingPage(c *gin.Context, outTradeNo, am
if gin.Mode() == gin.DebugMode {
frontendDomain = "http://localhost:5173"
}
processingURL := fmt.Sprintf("%s/finance/wallet/processing?out_trade_no=%s&amount=%s",
processingURL := fmt.Sprintf("%s/finance/wallet/processing?out_trade_no=%s&amount=%s",
frontendDomain, outTradeNo, amount)
c.Redirect(http.StatusFound, processingURL)
}
@@ -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, "通过发票申请成功")
}
@@ -932,14 +1095,14 @@ func (h *FinanceHandler) AdminDownloadInvoiceFile(c *gin.Context) {
// @Router /api/v1/debug/event-system [post]
func (h *FinanceHandler) DebugEventSystem(c *gin.Context) {
h.logger.Info("🔍 请求事件系统调试信息")
// 这里可以添加事件系统的状态信息
// 暂时返回基本信息
debugInfo := map[string]interface{}{
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"message": "事件系统调试端点已启用",
"handler": "FinanceHandler",
"message": "事件系统调试端点已启用",
"handler": "FinanceHandler",
}
h.responseBuilder.Success(c, debugInfo, "事件系统调试信息")
}

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 更新产品
@@ -797,9 +798,9 @@ func (h *ProductAdminHandler) BatchUpdateSubscriptionPrices(c *gin.Context) {
}
h.responseBuilder.Success(c, map[string]interface{}{
"user_id": cmd.UserID,
"user_id": cmd.UserID,
"discount": cmd.Discount,
"scope": cmd.Scope,
"scope": cmd.Scope,
}, "一键改价成功")
}
@@ -1113,8 +1114,6 @@ func (h *ProductAdminHandler) DeleteProductDocumentation(c *gin.Context) {
h.responseBuilder.Success(c, nil, "文档删除成功")
}
// GetAdminWalletTransactions 获取管理端消费记录
// @Summary 获取管理端消费记录
// @Description 管理员获取消费记录,支持筛选和分页
@@ -1173,6 +1172,11 @@ func (h *ProductAdminHandler) GetAdminWalletTransactions(c *gin.Context) {
filters["product_name"] = productName
}
// 企业名称筛选
if companyName := c.Query("company_name"); companyName != "" {
filters["company_name"] = companyName
}
// 金额范围筛选
if minAmount := c.Query("min_amount"); minAmount != "" {
filters["min_amount"] = minAmount
@@ -1234,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
}
}
}
@@ -1259,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
@@ -1278,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
}
@@ -1305,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 "最大金额"
@@ -1361,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,
@@ -1387,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)
@@ -1439,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
}
@@ -1465,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
}
@@ -1475,15 +1524,57 @@ func (h *ProductAdminHandler) GetAdminApiCalls(c *gin.Context) {
filters["product_ids"] = productIds
}
// 时间范围筛选
// 产品名称筛选
if productName := c.Query("product_name"); productName != "" {
filters["product_name"] = productName
}
// 企业名称筛选
if companyName := c.Query("company_name"); companyName != "" {
filters["company_name"] = companyName
}
// 交易ID筛选
if transactionId := c.Query("transaction_id"); transactionId != "" {
filters["transaction_id"] = transactionId
}
// 状态筛选
if status := c.Query("status"); status != "" {
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))
}
}
}
@@ -1543,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,
}
}
@@ -582,32 +586,77 @@ func (h *ProductHandler) GetMySubscriptionUsage(c *gin.Context) {
return
}
// 先获取订阅信息以验证权限
result, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID)
if err != nil {
h.logger.Error("获取我的订阅使用情况失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
h.responseBuilder.NotFound(c, "订阅不存在")
return
}
// 验证订阅是否属于当前用户(通过获取订阅详情来验证)
var query queries.GetSubscriptionQuery
query.ID = subscriptionID
subscription, err := h.subAppService.GetSubscriptionByID(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取订阅信息失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
h.logger.Error("获取订阅详情失败", zap.Error(err), zap.String("subscription_id", subscriptionID))
h.responseBuilder.NotFound(c, "订阅不存在")
return
}
// 验证订阅是否属于当前用户
if subscription.UserID != userID {
h.logger.Error("用户尝试访问不属于自己的订阅使用情况", zap.String("user_id", userID), zap.String("subscription_user_id", subscription.UserID), zap.String("subscription_id", subscriptionID))
h.logger.Error("用户尝试访问不属于自己的订阅", zap.String("user_id", userID), zap.String("subscription_user_id", subscription.UserID), zap.String("subscription_id", subscriptionID))
h.responseBuilder.Forbidden(c, "无权访问此订阅")
return
}
usage, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID)
if err != nil {
h.logger.Error("获取订阅使用情况失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
h.responseBuilder.Success(c, result, "获取我的订阅使用情况成功")
}
// CancelMySubscription 取消我的订阅
// @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 403 {object} map[string]interface{} "无权操作"
// @Failure 404 {object} map[string]interface{} "订阅不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/my/subscriptions/{id}/cancel [post]
func (h *ProductHandler) CancelMySubscription(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
h.responseBuilder.Success(c, usage, "获取使用情况成功")
subscriptionID := c.Param("id")
if subscriptionID == "" {
h.responseBuilder.BadRequest(c, "订阅ID不能为空")
return
}
err := h.subAppService.CancelMySubscription(c.Request.Context(), userID, subscriptionID)
if err != nil {
h.logger.Error("取消订阅失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
// 根据错误类型返回不同的响应
if err.Error() == "订阅不存在" {
h.responseBuilder.NotFound(c, "订阅不存在")
} else if err.Error() == "无权取消此订阅" {
h.responseBuilder.Forbidden(c, "无权取消此订阅")
} else {
h.responseBuilder.BadRequest(c, err.Error())
}
return
}
h.responseBuilder.Success(c, nil, "取消订阅成功")
}
// GetProductDocumentation 获取产品文档
@@ -697,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,
@@ -710,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),
)
@@ -732,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(
c.Request.Context(),
product.ID,
product.Name,
product.Code,
product.Description,
product.Content,
product.Price,
docEntity,
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(),
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),
@@ -774,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 == "" {
@@ -795,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,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())
@@ -49,12 +61,14 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
// 钱包相关路由
walletGroup := financeGroup.Group("/wallet")
{
walletGroup.GET("", r.financeHandler.GetWallet) // 获取钱包信息
walletGroup.GET("/transactions", r.financeHandler.GetUserWalletTransactions) // 获取钱包交易记录
walletGroup.GET("/recharge-config", r.financeHandler.GetRechargeConfig) // 获取充值配置
walletGroup.POST("/alipay-recharge", r.financeHandler.CreateAlipayRecharge) // 创建支付宝充值订单
walletGroup.GET("/recharge-records", r.financeHandler.GetUserRechargeRecords) // 用户充值记录分页
walletGroup.GET("", r.financeHandler.GetWallet) // 获取钱包信息
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) // 获取微信订单状态
}
}
@@ -62,11 +76,11 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
invoiceGroup := engine.Group("/api/v1/invoices")
invoiceGroup.Use(r.authMiddleware.Handle())
{
invoiceGroup.POST("/apply", r.financeHandler.ApplyInvoice) // 申请开票
invoiceGroup.GET("/info", r.financeHandler.GetUserInvoiceInfo) // 获取用户发票信息
invoiceGroup.PUT("/info", r.financeHandler.UpdateUserInvoiceInfo) // 更新用户发票信息
invoiceGroup.GET("/records", r.financeHandler.GetUserInvoiceRecords) // 获取用户开票记录
invoiceGroup.GET("/available-amount", r.financeHandler.GetAvailableAmount) // 获取可开票金额
invoiceGroup.POST("/apply", r.financeHandler.ApplyInvoice) // 申请开票
invoiceGroup.GET("/info", r.financeHandler.GetUserInvoiceInfo) // 获取用户发票信息
invoiceGroup.PUT("/info", r.financeHandler.UpdateUserInvoiceInfo) // 更新用户发票信息
invoiceGroup.GET("/records", r.financeHandler.GetUserInvoiceRecords) // 获取用户开票记录
invoiceGroup.GET("/available-amount", r.financeHandler.GetAvailableAmount) // 获取可开票金额
invoiceGroup.GET("/:application_id/download", r.financeHandler.DownloadInvoiceFile) // 下载发票文件
}
@@ -74,8 +88,8 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
adminFinanceGroup := engine.Group("/api/v1/admin/finance")
adminFinanceGroup.Use(r.adminAuthMiddleware.Handle())
{
adminFinanceGroup.POST("/transfer-recharge", r.financeHandler.TransferRecharge) // 对公转账充值
adminFinanceGroup.POST("/gift-recharge", r.financeHandler.GiftRecharge) // 赠送充值
adminFinanceGroup.POST("/transfer-recharge", r.financeHandler.TransferRecharge) // 对公转账充值
adminFinanceGroup.POST("/gift-recharge", r.financeHandler.GiftRecharge) // 赠送充值
adminFinanceGroup.GET("/recharge-records", r.financeHandler.GetAdminRechargeRecords) // 管理员充值记录分页
}
@@ -83,7 +97,7 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
adminInvoiceGroup := engine.Group("/api/v1/admin/invoices")
adminInvoiceGroup.Use(r.adminAuthMiddleware.Handle())
{
adminInvoiceGroup.GET("/pending", r.financeHandler.GetPendingApplications) // 获取待处理申请列表
adminInvoiceGroup.GET("/pending", r.financeHandler.GetPendingApplications) // 获取待处理申请列表
adminInvoiceGroup.POST("/:application_id/approve", r.financeHandler.ApproveInvoiceApplication) // 通过发票申请
adminInvoiceGroup.POST("/:application_id/reject", r.financeHandler.RejectInvoiceApplication) // 拒绝发票申请
adminInvoiceGroup.GET("/:application_id/download", r.financeHandler.AdminDownloadInvoiceFile) // 下载发票文件

View File

@@ -83,6 +83,9 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
// 获取订阅使用情况
subscriptions.GET("/:id/usage", r.productHandler.GetMySubscriptionUsage)
// 取消订阅
subscriptions.POST("/:id/cancel", r.productHandler.CancelMySubscription)
}
}

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

@@ -17,17 +17,24 @@ import (
// ArticleTaskHandler 文章任务处理器
type ArticleTaskHandler struct {
logger *zap.Logger
articleApplicationService article.ArticleApplicationService
asyncTaskRepo repositories.AsyncTaskRepository
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,
asyncTaskRepo: asyncTaskRepo,
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"`
@@ -157,9 +205,9 @@ func (p *ArticleCancelPayload) FromJSON(data []byte) error {
// ArticleModifyPayload 文章修改任务载荷
type ArticleModifyPayload struct {
ArticleID string `json:"article_id"`
NewPublishAt time.Time `json:"new_publish_at"`
UserID string `json:"user_id"`
ArticleID string `json:"article_id"`
NewPublishAt time.Time `json:"new_publish_at"`
UserID string `json:"user_id"`
}
// GetType 获取任务类型
@@ -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
@@ -205,7 +277,7 @@ func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task
} else if status == "completed" {
// 成功时:清除错误信息并更新状态
if err := h.asyncTaskRepo.UpdateStatusWithSuccess(ctx, taskID, entities.TaskStatus(status)); err != nil {
h.logger.Error("更新任务状态失败",
h.logger.Error("更新任务状态失败",
zap.String("task_id", taskID),
zap.String("status", status),
zap.Error(err))
@@ -213,14 +285,14 @@ func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task
} else {
// 其他状态:只更新状态
if err := h.asyncTaskRepo.UpdateStatus(ctx, taskID, entities.TaskStatus(status)); err != nil {
h.logger.Error("更新任务状态失败",
h.logger.Error("更新任务状态失败",
zap.String("task_id", taskID),
zap.String("status", status),
zap.Error(err))
}
}
h.logger.Info("任务状态已更新",
h.logger.Info("任务状态已更新",
zap.String("task_id", taskID),
zap.String("status", status),
zap.String("error_msg", errorMsg))
@@ -237,29 +309,29 @@ func (h *ArticleTaskHandler) handleTaskFailure(ctx context.Context, taskID strin
// 增加重试次数
newRetryCount := task.RetryCount + 1
// 检查是否达到最大重试次数
if newRetryCount >= task.MaxRetries {
// 达到最大重试次数,标记为最终失败
if err := h.asyncTaskRepo.UpdateStatusWithRetryAndError(ctx, taskID, entities.TaskStatusFailed, errorMsg); err != nil {
h.logger.Error("更新任务状态失败",
h.logger.Error("更新任务状态失败",
zap.String("task_id", taskID),
zap.String("status", "failed"),
zap.Error(err))
}
h.logger.Info("任务最终失败,已达到最大重试次数",
h.logger.Info("任务最终失败,已达到最大重试次数",
zap.String("task_id", taskID),
zap.Int("retry_count", newRetryCount),
zap.Int("max_retries", task.MaxRetries))
} else {
// 未达到最大重试次数保持pending状态记录错误信息
if err := h.asyncTaskRepo.UpdateRetryCountAndError(ctx, taskID, newRetryCount, errorMsg); err != nil {
h.logger.Error("更新任务重试次数失败",
h.logger.Error("更新任务重试次数失败",
zap.String("task_id", taskID),
zap.Int("retry_count", newRetryCount),
zap.Error(err))
}
h.logger.Info("任务失败,准备重试",
h.logger.Info("任务失败,准备重试",
zap.String("task_id", taskID),
zap.Int("retry_count", newRetryCount),
zap.Int("max_retries", task.MaxRetries))
@@ -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")
@@ -301,4 +375,4 @@ func (h *ArticleTaskHandler) checkTaskStatus(ctx context.Context, t *asynq.Task)
}
return nil
}
}

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,
@@ -39,15 +40,16 @@ func NewAsynqWorker(
asynq.Config{
Concurrency: 6, // 降低总并发数
Queues: map[string]int{
"default": 2, // 2个goroutine
"api": 3, // 3个goroutine (扣款任务)
"article": 1, // 1个goroutine
"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

@@ -17,10 +17,10 @@ import (
// TaskManagerImpl 任务管理器实现
type TaskManagerImpl struct {
asynqClient *asynq.Client
asyncTaskRepo repositories.AsyncTaskRepository
logger *zap.Logger
config *interfaces.TaskManagerConfig
asynqClient *asynq.Client
asyncTaskRepo repositories.AsyncTaskRepository
logger *zap.Logger
config *interfaces.TaskManagerConfig
}
// NewTaskManager 创建任务管理器
@@ -42,7 +42,7 @@ func NewTaskManager(
func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entities.AsyncTask) error {
// 1. 保存任务到数据库GORM会自动生成UUID
if err := tm.asyncTaskRepo.Create(ctx, task); err != nil {
tm.logger.Error("保存任务到数据库失败",
tm.logger.Error("保存任务到数据库失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("保存任务失败: %w", err)
@@ -50,7 +50,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entit
// 2. 更新payload中的task_id
if err := tm.updatePayloadTaskID(task); err != nil {
tm.logger.Error("更新payload中的任务ID失败",
tm.logger.Error("更新payload中的任务ID失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新payload中的任务ID失败: %w", err)
@@ -58,7 +58,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entit
// 3. 更新数据库中的payload
if err := tm.asyncTaskRepo.Update(ctx, task); err != nil {
tm.logger.Error("更新任务payload失败",
tm.logger.Error("更新任务payload失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新任务payload失败: %w", err)
@@ -71,7 +71,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entit
return fmt.Errorf("任务入队失败: %w", err)
}
tm.logger.Info("任务创建并入队成功",
tm.logger.Info("任务创建并入队成功",
zap.String("task_id", task.ID),
zap.String("task_type", task.Type))
@@ -86,7 +86,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
// 2. 保存任务到数据库GORM会自动生成UUID
if err := tm.asyncTaskRepo.Create(ctx, task); err != nil {
tm.logger.Error("保存延时任务到数据库失败",
tm.logger.Error("保存延时任务到数据库失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("保存延时任务失败: %w", err)
@@ -94,7 +94,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
// 3. 更新payload中的task_id
if err := tm.updatePayloadTaskID(task); err != nil {
tm.logger.Error("更新payload中的任务ID失败",
tm.logger.Error("更新payload中的任务ID失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新payload中的任务ID失败: %w", err)
@@ -102,7 +102,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
// 4. 更新数据库中的payload
if err := tm.asyncTaskRepo.Update(ctx, task); err != nil {
tm.logger.Error("更新任务payload失败",
tm.logger.Error("更新任务payload失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新任务payload失败: %w", err)
@@ -115,7 +115,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
return fmt.Errorf("延时任务入队失败: %w", err)
}
tm.logger.Info("延时任务创建并入队成功",
tm.logger.Info("延时任务创建并入队成功",
zap.String("task_id", task.ID),
zap.String("task_type", task.Type),
zap.Duration("delay", delay))
@@ -131,13 +131,13 @@ func (tm *TaskManagerImpl) CancelTask(ctx context.Context, taskID string) error
}
if err := tm.asyncTaskRepo.UpdateStatus(ctx, task.ID, entities.TaskStatusCancelled); err != nil {
tm.logger.Error("更新任务状态为取消失败",
tm.logger.Error("更新任务状态为取消失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新任务状态失败: %w", err)
}
tm.logger.Info("任务已标记为取消",
tm.logger.Info("任务已标记为取消",
zap.String("task_id", task.ID),
zap.String("task_type", task.Type))
@@ -152,14 +152,14 @@ func (tm *TaskManagerImpl) UpdateTaskSchedule(ctx context.Context, taskID string
return err
}
tm.logger.Info("找到要更新的任务",
tm.logger.Info("找到要更新的任务",
zap.String("task_id", task.ID),
zap.String("current_status", string(task.Status)),
zap.Time("current_scheduled_at", *task.ScheduledAt))
// 2. 取消旧任务
if err := tm.asyncTaskRepo.UpdateStatus(ctx, task.ID, entities.TaskStatusCancelled); err != nil {
tm.logger.Error("取消旧任务失败",
tm.logger.Error("取消旧任务失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("取消旧任务失败: %w", err)
@@ -173,7 +173,7 @@ func (tm *TaskManagerImpl) UpdateTaskSchedule(ctx context.Context, taskID string
return err
}
tm.logger.Info("新任务已创建",
tm.logger.Info("新任务已创建",
zap.String("new_task_id", newTask.ID),
zap.Time("new_scheduled_at", newScheduledAt))
@@ -189,7 +189,7 @@ func (tm *TaskManagerImpl) UpdateTaskSchedule(ctx context.Context, taskID string
return fmt.Errorf("重新入队任务失败: %w", err)
}
tm.logger.Info("任务调度时间更新成功",
tm.logger.Info("任务调度时间更新成功",
zap.String("old_task_id", task.ID),
zap.String("new_task_id", newTask.ID),
zap.Time("new_scheduled_at", newScheduledAt))
@@ -237,7 +237,7 @@ func (tm *TaskManagerImpl) RetryTask(ctx context.Context, taskID string) error {
return fmt.Errorf("重试任务入队失败: %w", err)
}
tm.logger.Info("任务重试成功",
tm.logger.Info("任务重试成功",
zap.String("task_id", taskID),
zap.Int("retry_count", task.RetryCount))
@@ -248,7 +248,7 @@ func (tm *TaskManagerImpl) RetryTask(ctx context.Context, taskID string) error {
func (tm *TaskManagerImpl) CleanupExpiredTasks(ctx context.Context, olderThan time.Time) error {
// 这里可以实现清理逻辑,比如删除超过一定时间的已完成任务
tm.logger.Info("开始清理过期任务", zap.Time("older_than", olderThan))
// TODO: 实现清理逻辑
return nil
}
@@ -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)
@@ -285,21 +284,34 @@ func (tm *TaskManagerImpl) findTask(ctx context.Context, taskID string) (*entiti
// 如果通过任务ID找不到尝试通过文章ID查找
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
}
@@ -317,7 +329,7 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
// 保存到数据库GORM会自动生成UUID
if err := tm.asyncTaskRepo.Create(ctx, newTask); err != nil {
tm.logger.Error("创建新任务失败",
tm.logger.Error("创建新任务失败",
zap.String("new_task_id", newTask.ID),
zap.Error(err))
return nil, fmt.Errorf("创建新任务失败: %w", err)
@@ -325,7 +337,7 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
// 更新payload中的task_id
if err := tm.updatePayloadTaskID(newTask); err != nil {
tm.logger.Error("更新payload中的任务ID失败",
tm.logger.Error("更新payload中的任务ID失败",
zap.String("new_task_id", newTask.ID),
zap.Error(err))
return nil, fmt.Errorf("更新payload中的任务ID失败: %w", err)
@@ -333,7 +345,7 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
// 更新数据库中的payload
if err := tm.asyncTaskRepo.Update(ctx, newTask); err != nil {
tm.logger.Error("更新新任务payload失败",
tm.logger.Error("更新新任务payload失败",
zap.String("new_task_id", newTask.ID),
zap.Error(err))
return nil, fmt.Errorf("更新新任务payload失败: %w", err)
@@ -346,23 +358,24 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
func (tm *TaskManagerImpl) enqueueTaskWithDelay(ctx context.Context, task *entities.AsyncTask, delay time.Duration) error {
queueName := tm.getQueueName(task.Type)
asynqTask := asynq.NewTask(task.Type, []byte(task.Payload))
var err error
if delay > 0 {
_, err = tm.asynqClient.EnqueueContext(ctx, asynqTask,
_, err = tm.asynqClient.EnqueueContext(ctx, asynqTask,
asynq.Queue(queueName),
asynq.ProcessIn(delay))
} else {
_, err = tm.asynqClient.EnqueueContext(ctx, asynqTask, asynq.Queue(queueName))
}
return err
}
// 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 异步任务仓库实现
@@ -219,8 +222,8 @@ func (r *AsyncTaskRepositoryImpl) DeleteBatch(ctx context.Context, ids []string)
func (r *AsyncTaskRepositoryImpl) GetArticlePublishTask(ctx context.Context, articleID string) (*entities.AsyncTask, error) {
var task entities.AsyncTask
err := r.db.WithContext(ctx).
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeArticlePublish,
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeArticlePublish,
"%\"article_id\":\""+articleID+"\"%",
[]entities.TaskStatus{entities.TaskStatusPending, entities.TaskStatusRunning}).
First(&task).Error
@@ -234,7 +237,7 @@ func (r *AsyncTaskRepositoryImpl) GetArticlePublishTask(ctx context.Context, art
func (r *AsyncTaskRepositoryImpl) GetByArticleID(ctx context.Context, articleID string) ([]*entities.AsyncTask, error) {
var tasks []*entities.AsyncTask
err := r.db.WithContext(ctx).
Where("payload LIKE ? AND status IN ?",
Where("payload LIKE ? AND status IN ?",
"%\"article_id\":\""+articleID+"\"%",
[]entities.TaskStatus{entities.TaskStatusPending, entities.TaskStatusRunning}).
Find(&tasks).Error
@@ -248,8 +251,8 @@ func (r *AsyncTaskRepositoryImpl) GetByArticleID(ctx context.Context, articleID
func (r *AsyncTaskRepositoryImpl) CancelArticlePublishTask(ctx context.Context, articleID string) error {
return r.db.WithContext(ctx).
Model(&entities.AsyncTask{}).
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeArticlePublish,
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeArticlePublish,
"%\"article_id\":\""+articleID+"\"%",
[]entities.TaskStatus{entities.TaskStatusPending, entities.TaskStatusRunning}).
Update("status", entities.TaskStatusCancelled).Error
@@ -259,9 +262,38 @@ func (r *AsyncTaskRepositoryImpl) CancelArticlePublishTask(ctx context.Context,
func (r *AsyncTaskRepositoryImpl) UpdateArticlePublishTaskSchedule(ctx context.Context, articleID string, newScheduledAt time.Time) error {
return r.db.WithContext(ctx).
Model(&entities.AsyncTask{}).
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeArticlePublish,
Where("type = ? AND payload LIKE ? AND status IN ?",
types.TaskTypeArticlePublish,
"%\"article_id\":\""+articleID+"\"%",
[]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,12 +9,15 @@ const (
TaskTypeArticleCancel TaskType = "article_cancel"
TaskTypeArticleModify TaskType = "article_modify"
// 公告相关任务
TaskTypeAnnouncementPublish TaskType = "announcement_publish"
// API相关任务
TaskTypeApiCall TaskType = "api_call"
TaskTypeApiLog TaskType = "api_log"
// 财务相关任务
TaskTypeDeduction TaskType = "deduction"
TaskTypeDeduction TaskType = "deduction"
TaskTypeCompensation TaskType = "compensation"
// 产品相关任务
@@ -26,4 +29,4 @@ type TaskPayload interface {
GetType() TaskType
ToJSON() ([]byte, error)
FromJSON(data []byte) error
}
}

View File

@@ -76,7 +76,7 @@ func TestWestDexDecryptOutput(t *testing.T) {
}{
{
name: "测试数据1",
data: "DLrbtEki5o/5yTvQWR+dWWUZYEo5s58D8LTnhhlAl99SwZbECa34KpStmR+Qr0gbbKzh3y4t5+/vbFFZgv03DtnYlLQcQt+rSgtxkCN/PCBPaFE0QZRTufd7djJfUww0Eh6DMHD7NS9pcuCa0PHGVoE+Vwo2YSwOnh2gtx3Bt0Qhs+w76tfCwIeufZ8tcpFs/nb84HIZxk+0cH1bTfNE6VsXI6vMpKvnS02O3oE2642ozeHgglCNuiOFMcCL8Erw4FKPnfRCUYdeKc2dZ7OF2IZqt0t4WiJBxjB/6k4tgAj/HepE2gaulWU8RVvAF+vPF5i3ekHHq8T7226rNlVfuagodaRXiOqO5E1h6Mx9ygcDL0HXvQKsxxJdl/bUP+t/+rOjA+k/IR/vF1UJGrGrkSJVfkcWXPP85cgws18gE9rIs2Ji1HGjvOmnez370L0+",
data: "0IdH/7L/ybMY00dne6clsk7VYBXPHkFfDagilHTzSHt9wTxref38uX8cDe7fJCGksbDQnMGo8GfsyEIpiCfj+w==",
secretKey: "121a1e41fc1690dd6b90afbcacd80cf4",
},
{

View File

@@ -0,0 +1,25 @@
package payment
import (
"context"
"fmt"
)
// GetUidFromCtx 从context中获取用户ID
func GetUidFromCtx(ctx context.Context) (string, error) {
userID := ctx.Value("user_id")
if userID == nil {
return "", fmt.Errorf("用户ID不存在于上下文中")
}
id, ok := userID.(string)
if !ok {
return "", fmt.Errorf("用户ID类型错误")
}
if id == "" {
return "", fmt.Errorf("用户ID为空")
}
return id, nil
}

View File

@@ -0,0 +1,48 @@
package payment
import (
"context"
"fmt"
)
// UserAuthModel 用户认证模型接口
// 用于存储和管理用户的第三方认证信息如微信OpenID
type UserAuthModel interface {
FindOneByUserIdAuthType(ctx context.Context, userID string, authType string) (*UserAuth, error)
UpsertUserAuth(ctx context.Context, userID, authType, authKey string) error
}
// UserAuth 用户认证信息
type UserAuth struct {
UserID string // 用户ID
AuthType string // 认证类型
AuthKey string // 认证密钥如OpenID
}
// Platform 支付平台常量
const (
PlatformWxMini = "wx_mini" // 微信小程序
PlatformWxH5 = "wx_h5" // 微信H5
PlatformApp = "app" // APP
PlatformWxNative = "wx_native" // 微信Native扫码
)
// UserAuthType 用户认证类型常量
const (
UserAuthTypeWxMiniOpenID = "wx_mini_openid" // 微信小程序OpenID
UserAuthTypeWxh5OpenID = "wx_h5_openid" // 微信H5 OpenID
)
// DefaultUserAuthModel 默认实现(如果不需要实际数据库查询,可以返回错误)
type DefaultUserAuthModel struct{}
// FindOneByUserIdAuthType 查找用户认证信息
// 注意:这是一个占位实现,实际使用时需要注入真实的实现
func (m *DefaultUserAuthModel) FindOneByUserIdAuthType(ctx context.Context, userID string, authType string) (*UserAuth, error) {
return nil, fmt.Errorf("UserAuthModel未实现请注入真实的实现")
}
// UpsertUserAuth 占位实现
func (m *DefaultUserAuthModel) UpsertUserAuth(ctx context.Context, userID, authType, authKey string) error {
return fmt.Errorf("UserAuthModel未实现请注入真实的实现")
}

View File

@@ -0,0 +1,7 @@
package payment
// ToWechatAmount 将金额转换为微信支付金额(单位:分)
// 微信支付金额以分为单位,需要将元转换为分
func ToWechatAmount(amount float64) int64 {
return int64(amount * 100)
}

View File

@@ -0,0 +1,353 @@
package payment
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"tyapi-server/internal/config"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/native"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
"go.uber.org/zap"
)
const (
TradeStateSuccess = "SUCCESS" // 支付成功
TradeStateRefund = "REFUND" // 转入退款
TradeStateNotPay = "NOTPAY" // 未支付
TradeStateClosed = "CLOSED" // 已关闭
TradeStateRevoked = "REVOKED" // 已撤销(付款码支付)
TradeStateUserPaying = "USERPAYING" // 用户支付中(付款码支付)
TradeStatePayError = "PAYERROR" // 支付失败(其他原因,如银行返回失败)
)
// resolveCertPath 解析证书文件路径,支持相对路径和绝对路径
// 如果是相对路径,会从多个候选位置查找文件
func resolveCertPath(relativePath string, logger *zap.Logger) (string, error) {
if relativePath == "" {
return "", fmt.Errorf("证书路径为空")
}
// 如果已经是绝对路径,直接返回
if filepath.IsAbs(relativePath) {
if _, err := os.Stat(relativePath); err == nil {
return relativePath, nil
}
return "", fmt.Errorf("证书文件不存在: %s", relativePath)
}
// 候选路径列表(按优先级排序)
var candidatePaths []string
// 优先级1: 从可执行文件所在目录查找(生产环境)
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
// 处理符号链接
if realPath, err := filepath.EvalSymlinks(execPath); err == nil {
execDir = filepath.Dir(realPath)
}
candidatePaths = append(candidatePaths, filepath.Join(execDir, relativePath))
}
// 优先级2: 从工作目录查找(开发环境)
if workDir, err := os.Getwd(); err == nil {
candidatePaths = append(candidatePaths,
filepath.Join(workDir, relativePath),
filepath.Join(workDir, "tyapi-server", relativePath),
)
}
// 尝试每个候选路径
for _, candidatePath := range candidatePaths {
absPath, err := filepath.Abs(candidatePath)
if err != nil {
continue
}
if logger != nil {
logger.Debug("尝试查找证书文件", zap.String("path", absPath))
}
// 检查文件是否存在
if info, err := os.Stat(absPath); err == nil && !info.IsDir() {
if logger != nil {
logger.Info("找到证书文件", zap.String("path", absPath))
}
return absPath, nil
}
}
// 所有候选路径都不存在,返回错误
return "", fmt.Errorf("证书文件不存在,已尝试的路径: %v", candidatePaths)
}
// InitType 初始化类型
type InitType string
const (
InitTypePlatformCert InitType = "platform_cert" // 平台证书初始化
InitTypeWxPayPubKey InitType = "wxpay_pubkey" // 微信支付公钥初始化
)
type WechatPayService struct {
config config.Config
wechatClient *core.Client
notifyHandler *notify.Handler
logger *zap.Logger
}
// NewWechatPayService 创建微信支付服务实例
func NewWechatPayService(c config.Config, initType InitType, logger *zap.Logger) *WechatPayService {
switch initType {
case InitTypePlatformCert:
return newWechatPayServiceWithPlatformCert(c, logger)
case InitTypeWxPayPubKey:
return newWechatPayServiceWithWxPayPubKey(c, logger)
default:
logger.Error("不支持的初始化类型", zap.String("init_type", string(initType)))
panic(fmt.Sprintf("初始化失败,服务停止: %s", initType))
}
}
// newWechatPayServiceWithPlatformCert 使用平台证书初始化微信支付服务
func newWechatPayServiceWithPlatformCert(c config.Config, logger *zap.Logger) *WechatPayService {
// 从配置中加载商户信息
mchID := c.Wxpay.MchID
mchCertificateSerialNumber := c.Wxpay.MchCertificateSerialNumber
mchAPIv3Key := c.Wxpay.MchApiv3Key
// 解析证书路径
privateKeyPath, err := resolveCertPath(c.Wxpay.MchPrivateKeyPath, logger)
if err != nil {
logger.Error("解析商户私钥路径失败", zap.Error(err), zap.String("path", c.Wxpay.MchPrivateKeyPath))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 从文件中加载商户私钥
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(privateKeyPath)
if err != nil {
logger.Error("加载商户私钥失败", zap.Error(err), zap.String("path", privateKeyPath))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 使用商户私钥和其他参数初始化微信支付客户端
opts := []core.ClientOption{
option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
}
client, err := core.NewClient(context.Background(), opts...)
if err != nil {
logger.Error("创建微信支付客户端失败", zap.Error(err))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 在初始化时获取证书访问器并创建 notifyHandler
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
notifyHandler, err := notify.NewRSANotifyHandler(mchAPIv3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
if err != nil {
logger.Error("获取证书访问器失败", zap.Error(err))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
logger.Info("微信支付客户端初始化成功(平台证书方式)")
return &WechatPayService{
config: c,
wechatClient: client,
notifyHandler: notifyHandler,
logger: logger,
}
}
// newWechatPayServiceWithWxPayPubKey 使用微信支付公钥初始化微信支付服务
func newWechatPayServiceWithWxPayPubKey(c config.Config, logger *zap.Logger) *WechatPayService {
// 从配置中加载商户信息
mchID := c.Wxpay.MchID
mchCertificateSerialNumber := c.Wxpay.MchCertificateSerialNumber
mchAPIv3Key := c.Wxpay.MchApiv3Key
mchPublicKeyID := c.Wxpay.MchPublicKeyID
// 解析证书路径
privateKeyPath, err := resolveCertPath(c.Wxpay.MchPrivateKeyPath, logger)
if err != nil {
logger.Error("解析商户私钥路径失败", zap.Error(err), zap.String("path", c.Wxpay.MchPrivateKeyPath))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
publicKeyPath, err := resolveCertPath(c.Wxpay.MchPublicKeyPath, logger)
if err != nil {
logger.Error("解析微信支付平台证书路径失败", zap.Error(err), zap.String("path", c.Wxpay.MchPublicKeyPath))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 从文件中加载商户私钥
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(privateKeyPath)
if err != nil {
logger.Error("加载商户私钥失败", zap.Error(err), zap.String("path", privateKeyPath))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 从文件中加载微信支付平台证书
mchPublicKey, err := utils.LoadPublicKeyWithPath(publicKeyPath)
if err != nil {
logger.Error("加载微信支付平台证书失败", zap.Error(err), zap.String("path", publicKeyPath))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 使用商户私钥和其他参数初始化微信支付客户端
opts := []core.ClientOption{
option.WithWechatPayPublicKeyAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchPublicKeyID, mchPublicKey),
}
client, err := core.NewClient(context.Background(), opts...)
if err != nil {
logger.Error("创建微信支付客户端失败", zap.Error(err))
panic(fmt.Sprintf("初始化失败,服务停止: %v", err))
}
// 初始化 notify.Handler
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
notifyHandler := notify.NewNotifyHandler(
mchAPIv3Key,
verifiers.NewSHA256WithRSACombinedVerifier(certificateVisitor, mchPublicKeyID, *mchPublicKey))
logger.Info("微信支付客户端初始化成功(微信支付公钥方式)")
return &WechatPayService{
config: c,
wechatClient: client,
notifyHandler: notifyHandler,
logger: logger,
}
}
// CreateWechatNativeOrder 创建微信Native扫码支付订单
func (w *WechatPayService) CreateWechatNativeOrder(ctx context.Context, amount float64, description string, outTradeNo string) (interface{}, error) {
totalAmount := ToWechatAmount(amount)
req := native.PrepayRequest{
Appid: core.String(w.config.Wxpay.AppID),
Mchid: core.String(w.config.Wxpay.MchID),
Description: core.String(description),
OutTradeNo: core.String(outTradeNo),
NotifyUrl: core.String(w.config.Wxpay.NotifyUrl),
Amount: &native.Amount{
Total: core.Int64(totalAmount),
},
}
svc := native.NativeApiService{Client: w.wechatClient}
resp, result, err := svc.Prepay(ctx, req)
if err != nil {
statusCode := 0
if result != nil && result.Response != nil {
statusCode = result.Response.StatusCode
}
return "", fmt.Errorf("微信扫码下单失败: %v, 状态码: %d", err, statusCode)
}
if resp.CodeUrl == nil || *resp.CodeUrl == "" {
return "", fmt.Errorf("微信扫码下单成功但未返回code_url")
}
// 返回二维码链接,由前端生成二维码
return map[string]string{"code_url": *resp.CodeUrl}, nil
}
// CreateWechatOrder 创建微信支付订单(仅 Native 扫码)
func (w *WechatPayService) CreateWechatOrder(ctx context.Context, amount float64, description string, outTradeNo string) (interface{}, error) {
return w.CreateWechatNativeOrder(ctx, amount, description, outTradeNo)
}
// HandleWechatPayNotification 处理微信支付回调
func (w *WechatPayService) HandleWechatPayNotification(ctx context.Context, req *http.Request) (*payments.Transaction, error) {
transaction := new(payments.Transaction)
_, err := w.notifyHandler.ParseNotifyRequest(ctx, req, transaction)
if err != nil {
return nil, fmt.Errorf("微信支付通知处理失败: %v", err)
}
// 返回交易信息
return transaction, nil
}
// HandleRefundNotification 处理微信退款回调
func (w *WechatPayService) HandleRefundNotification(ctx context.Context, req *http.Request) (*refunddomestic.Refund, error) {
refund := new(refunddomestic.Refund)
_, err := w.notifyHandler.ParseNotifyRequest(ctx, req, refund)
if err != nil {
return nil, fmt.Errorf("微信退款回调通知处理失败: %v", err)
}
return refund, nil
}
// QueryOrderStatus 主动查询订单状态(根据商户订单号)
func (w *WechatPayService) QueryOrderStatus(ctx context.Context, outTradeNo string) (*payments.Transaction, error) {
svc := native.NativeApiService{Client: w.wechatClient}
// 调用 QueryOrderByOutTradeNo 方法查询订单状态
resp, result, err := svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{
OutTradeNo: core.String(outTradeNo),
Mchid: core.String(w.config.Wxpay.MchID),
})
if err != nil {
return nil, fmt.Errorf("订单查询失败: %v, 状态码: %d", err, result.Response.StatusCode)
}
return resp, nil
}
// WeChatRefund 申请微信退款
func (w *WechatPayService) WeChatRefund(ctx context.Context, outTradeNo string, refundAmount float64, totalAmount float64) error {
// 生成唯一的退款单号
outRefundNo := fmt.Sprintf("%s-refund", outTradeNo)
// 初始化退款服务
svc := refunddomestic.RefundsApiService{Client: w.wechatClient}
// 创建退款请求
resp, result, err := svc.Create(ctx, refunddomestic.CreateRequest{
OutTradeNo: core.String(outTradeNo),
OutRefundNo: core.String(outRefundNo),
NotifyUrl: core.String(w.config.Wxpay.RefundNotifyUrl),
Amount: &refunddomestic.AmountReq{
Currency: core.String("CNY"),
Refund: core.Int64(ToWechatAmount(refundAmount)),
Total: core.Int64(ToWechatAmount(totalAmount)),
},
})
if err != nil {
return fmt.Errorf("微信订单申请退款错误: %v", err)
}
// 打印退款结果
w.logger.Info("退款申请成功",
zap.Int("status_code", result.Response.StatusCode),
zap.String("out_refund_no", *resp.OutRefundNo),
zap.String("refund_id", *resp.RefundId))
return nil
}
// GenerateOutTradeNo 生成唯一订单号
func (w *WechatPayService) GenerateOutTradeNo() string {
length := 16
timestamp := time.Now().UnixNano()
timeStr := strconv.FormatInt(timestamp, 10)
randomPart := strconv.Itoa(int(timestamp % 1e6))
combined := timeStr + randomPart
if len(combined) >= length {
return combined[:length]
}
for len(combined) < length {
combined += strconv.Itoa(int(timestamp % 10))
}
return combined
}

View File

@@ -91,34 +91,17 @@ func (r *DatabaseTableReader) ReadTableFromDocumentation(ctx context.Context, do
}
// 如果不是JSON格式先解析为markdown表格然后转换为JSON格式
r.logger.Info("开始解析markdown表格并转换为JSON",
zap.String("field_type", fieldType),
zap.Int("content_length", len(content)),
zap.String("content_preview", r.getContentPreview(content, 200)))
tableData, err := r.parseMarkdownTable(content)
if err != nil {
r.logger.Error("解析markdown表格失败",
zap.String("field_type", fieldType),
zap.Error(err),
zap.String("content_preview", r.getContentPreview(content, 500)))
// 错误已返回,不记录日志
return nil, fmt.Errorf("解析markdown表格失败: %w", err)
}
r.logger.Info("markdown表格解析成功",
zap.String("field_type", fieldType),
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Strings("headers", tableData.Headers))
// 将markdown表格数据转换为JSON格式保持列顺序
r.logger.Debug("开始将表格数据转换为JSON格式", zap.String("field_type", fieldType))
jsonArray = r.convertTableDataToJSON(tableData)
r.logger.Info("表格数据已转换为JSON格式",
zap.String("field_type", fieldType),
zap.Int("json_array_length", len(jsonArray)))
// 记录转换后的JSON用于调试
jsonBytes, marshalErr := json.MarshalIndent(jsonArray, "", " ")
if marshalErr != nil {
@@ -425,9 +408,6 @@ func (r *DatabaseTableReader) parseMarkdownTablesWithTitles(content string) ([]T
return nil, fmt.Errorf("无法解析表格:未找到表头")
}
r.logger.Info("解析多个表格完成",
zap.Int("table_count", len(result)))
return result, nil
}

View File

@@ -31,11 +31,6 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa
return nil
}
r.logger.Info("开始渲染表格",
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Strings("headers", tableData.Headers))
// 检查表头是否有有效内容
hasValidHeader := false
for _, header := range tableData.Headers {
@@ -109,11 +104,6 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa
r.logger.Debug("没有数据行,只渲染表头")
}
r.logger.Info("表格渲染完成",
zap.Int("header_count", len(tableData.Headers)),
zap.Int("row_count", len(tableData.Rows)),
zap.Float64("current_y", pdf.GetY()))
return nil
}
@@ -270,25 +260,31 @@ func (r *DatabaseTableRenderer) safeSplitText(pdf *gofpdf.Fpdf, text string, wid
// 如果文本为空或宽度无效,返回单行
if text == "" || width <= 0 {
if text == "" {
return []string{""}
}
return []string{text}
}
// 检查文本长度,如果太短可能不需要分割
if len([]rune(text)) <= 1 {
return []string{text}
}
// 使用匿名函数和recover保护SplitText调用
var lines []string
var panicOccurred bool
func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Warn("SplitText发生panic使用估算值",
zap.Any("error", rec),
zap.String("text_preview", func() string {
if len(text) > 50 {
return text[:50] + "..."
}
return text
}()),
zap.Float64("width", width))
panicOccurred = true
// 静默处理,不记录日志
// 如果panic发生使用估算值
charCount := len([]rune(text))
if charCount == 0 {
lines = []string{""}
return
}
estimatedLines := math.Max(1, math.Ceil(float64(charCount)/20))
lines = make([]string, int(estimatedLines))
if estimatedLines == 1 {
@@ -316,12 +312,27 @@ func (r *DatabaseTableRenderer) safeSplitText(pdf *gofpdf.Fpdf, text string, wid
lines = pdf.SplitText(text, width)
}()
// 如果lines为nil或空返回单行
if lines == nil || len(lines) == 0 {
// 如果panic发生或lines为nil或空使用后备方案
if panicOccurred || lines == nil || len(lines) == 0 {
// 如果文本不为空,至少返回一行
if text != "" {
return []string{text}
}
return []string{""}
}
// 过滤掉空行(但保留至少一行)
nonEmptyLines := make([]string, 0, len(lines))
for _, line := range lines {
if strings.TrimSpace(line) != "" {
nonEmptyLines = append(nonEmptyLines, line)
}
}
if len(nonEmptyLines) == 0 {
return []string{text}
}
return lines
return nonEmptyLines
}
// renderHeader 渲染表头
@@ -450,6 +461,10 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co
validRowIndex++
// 计算这一行的最大高度
// 确保lineHt有效
if lineHt <= 0 {
lineHt = 5.0 // 默认行高
}
maxCellHeight := lineHt * 2.0 // 使用合理的最小高度
for j := 0; j < numCols && j < len(row); j++ {
cell := row[j]
@@ -471,12 +486,32 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co
lines = []string{cell}
}
cellHeight := float64(len(lines)) * lineHt
// 确保lines不为空且有效
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
lines = []string{cell}
if lines[0] == "" {
lines[0] = " " // 至少保留一个空格避免高度为0
}
}
// 计算单元格高度确保不会出现Inf或NaN
lineCount := float64(len(lines))
if lineCount <= 0 {
lineCount = 1
}
if lineHt <= 0 {
lineHt = 5.0 // 默认行高
}
cellHeight := lineCount * lineHt
// 添加上下内边距
cellHeight += lineHt * 0.8 // 上下各0.4倍行高的内边距
if cellHeight < lineHt*2.0 {
cellHeight = lineHt * 2.0
}
// 检查是否为有效数值
if math.IsInf(cellHeight, 0) || math.IsNaN(cellHeight) {
cellHeight = lineHt * 2.0
}
if cellHeight > maxCellHeight {
maxCellHeight = cellHeight
}
@@ -545,10 +580,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co
func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Warn("MultiCell渲染失败",
zap.Any("error", rec),
zap.Int("row_index", rowIndex),
zap.Int("col_index", j))
// 静默处理,不记录日志
}
}()
// 使用正常的行高,文本已经垂直居中
@@ -561,8 +593,20 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co
currentX += colW
}
// 检查maxCellHeight是否为有效数值
if math.IsInf(maxCellHeight, 0) || math.IsNaN(maxCellHeight) {
maxCellHeight = lineHt * 2.0
}
if maxCellHeight <= 0 {
maxCellHeight = lineHt * 2.0
}
// 移动到下一行
pdf.SetXY(15.0, startY+maxCellHeight)
nextY := startY + maxCellHeight
if math.IsInf(nextY, 0) || math.IsNaN(nextY) {
nextY = pdf.GetY() + lineHt*2.0
}
pdf.SetXY(15.0, nextY)
}
}

View File

@@ -3,7 +3,6 @@ package pdf
import (
"os"
"path/filepath"
"runtime"
"github.com/jung-kurt/gofpdf/v2"
"go.uber.org/zap"
@@ -16,28 +15,15 @@ type FontManager struct {
chineseFontLoaded bool
watermarkFontName string
watermarkFontLoaded bool
baseDir string // 缓存基础目录,避免重复计算
}
// NewFontManager 创建字体管理器
func NewFontManager(logger *zap.Logger) *FontManager {
// 获取当前文件所在目录
_, filename, _, _ := runtime.Caller(0)
baseDir := filepath.Dir(filename)
fm := &FontManager{
return &FontManager{
logger: logger,
chineseFontName: "ChineseFont",
watermarkFontName: "WatermarkFont",
baseDir: baseDir,
}
// 记录基础目录(用于调试)
logger.Debug("字体管理器初始化",
zap.String("base_dir", baseDir),
zap.String("caller_file", filename))
return fm
}
// LoadChineseFont 加载中文字体到PDF
@@ -48,27 +34,19 @@ func (fm *FontManager) LoadChineseFont(pdf *gofpdf.Fpdf) bool {
fontPaths := fm.getChineseFontPaths()
if len(fontPaths) == 0 {
fm.logger.Warn("未找到中文字体文件PDF中的中文可能显示为空白",
zap.String("base_dir", fm.baseDir),
zap.String("hint", "请确保字体文件存在于 internal/shared/pdf/fonts/ 目录下,或设置 PDF_FONT_DIR 环境变量"))
// 字体文件不存在,使用系统默认字体,不记录警告
return false
}
// 尝试加载字体
for _, fontPath := range fontPaths {
fm.logger.Debug("尝试加载字体", zap.String("font_path", fontPath))
if fm.tryAddFont(pdf, fontPath, fm.chineseFontName) {
fm.chineseFontLoaded = true
fm.logger.Info("成功加载中文字体", zap.String("font_path", fontPath))
return true
} else {
fm.logger.Debug("字体加载失败", zap.String("font_path", fontPath))
}
}
fm.logger.Warn("所有中文字体文件都无法加载PDF中的中文可能显示为空白",
zap.Int("tried_paths", len(fontPaths)),
zap.Strings("font_paths", fontPaths))
// 无法加载字体,使用系统默认字体,不记录警告
return false
}
@@ -80,7 +58,6 @@ func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool {
fontPaths := fm.getWatermarkFontPaths()
if len(fontPaths) == 0 {
fm.logger.Warn("未找到水印字体文件,将使用主字体")
return false
}
@@ -88,12 +65,10 @@ func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool {
for _, fontPath := range fontPaths {
if fm.tryAddFont(pdf, fontPath, fm.watermarkFontName) {
fm.watermarkFontLoaded = true
fm.logger.Info("成功加载水印字体", zap.String("font_path", fontPath))
return true
}
}
fm.logger.Warn("所有水印字体文件都无法加载,将使用主字体")
return false
}
@@ -102,32 +77,126 @@ func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath, fontName string) b
defer func() {
if r := recover(); r != nil {
fm.logger.Error("添加字体时发生panic",
zap.Any("panic", r),
zap.String("font_path", fontPath),
zap.String("font_name", fontName))
zap.String("font_name", fontName),
zap.Any("panic_value", r),
)
}
}()
// 确保路径是绝对路径
absFontPath, err := filepath.Abs(fontPath)
if err != nil {
fm.logger.Warn("无法获取字体文件绝对路径",
zap.String("font_path", fontPath),
zap.Error(err),
)
absFontPath = fontPath
}
fm.logger.Debug("尝试添加字体",
zap.String("font_path", absFontPath),
zap.String("font_name", fontName),
)
// 检查文件是否存在
if _, err := os.Stat(fontPath); err != nil {
fileInfo, err := os.Stat(absFontPath)
if err != nil {
fm.logger.Debug("字体文件不存在",
zap.String("font_path", absFontPath),
zap.Error(err),
)
return false
}
if !fileInfo.Mode().IsRegular() {
fm.logger.Warn("字体路径不是普通文件",
zap.String("font_path", absFontPath),
)
return false
}
// 确保路径是绝对路径gofpdf在Output时需要绝对路径
// 首先确保absFontPath是绝对路径
if !filepath.IsAbs(absFontPath) {
fm.logger.Warn("字体路径不是绝对路径,重新转换",
zap.String("original_path", absFontPath),
)
if newAbsPath, err := filepath.Abs(absFontPath); err == nil {
absFontPath = newAbsPath
}
}
// 使用filepath.ToSlash统一路径分隔符Linux下使用/
// 注意ToSlash不会改变路径的绝对/相对性质,只统一分隔符
normalizedPath := filepath.ToSlash(absFontPath)
// 在Linux下绝对路径必须以/开头
// 如果normalizedPath不是以/开头,说明转换有问题
if len(normalizedPath) == 0 || normalizedPath[0] != '/' {
fm.logger.Error("字体路径转换后不是绝对路径(不以/开头)",
zap.String("abs_font_path", absFontPath),
zap.String("normalized_path", normalizedPath),
)
// 重新转换为绝对路径
if newAbsPath, err := filepath.Abs(absFontPath); err == nil {
absFontPath = newAbsPath
normalizedPath = filepath.ToSlash(newAbsPath)
fm.logger.Info("重新转换后的路径",
zap.String("new_normalized_path", normalizedPath),
)
}
}
fm.logger.Debug("准备添加字体到gofpdf",
zap.String("original_path", fontPath),
zap.String("abs_path", absFontPath),
zap.String("normalized_path", normalizedPath),
zap.String("font_name", fontName),
)
// gofpdf v2使用AddUTF8Font添加支持UTF-8的字体
pdf.AddUTF8Font(fontName, "", fontPath) // 常规样式
pdf.AddUTF8Font(fontName, "B", fontPath) // 粗体样式
// 注意gofpdf在Output时可能会重新解析路径必须确保路径格式正确
// 关键:确保路径是绝对路径且以/开头并使用filepath.ToSlash统一分隔符
// 如果normalizedPath不是以/开头,说明路径有问题,需要重新处理
if len(normalizedPath) == 0 || normalizedPath[0] != '/' {
fm.logger.Error("字体路径格式错误无法添加到PDF",
zap.String("normalized_path", normalizedPath),
zap.String("font_name", fontName),
)
return false
}
// 记录传递给gofpdf的实际路径
fm.logger.Info("添加字体到gofpdf",
zap.String("font_path", normalizedPath),
zap.String("font_name", fontName),
zap.Bool("is_absolute", len(normalizedPath) > 0 && normalizedPath[0] == '/'),
)
pdf.AddUTF8Font(fontName, "", normalizedPath) // 常规样式
pdf.AddUTF8Font(fontName, "B", normalizedPath) // 粗体样式
// 验证字体是否可用
// 注意gofpdf可能在AddUTF8Font时不会立即加载字体而是在Output时才加载
// 所以这里验证可能失败,但不一定代表字体无法使用
pdf.SetFont(fontName, "", 12)
testWidth := pdf.GetStringWidth("测试")
if testWidth == 0 {
fm.logger.Debug("字体加载后无法使用",
zap.String("font_path", fontPath),
fm.logger.Warn("字体添加后验证失败测试文本宽度为0但会在Output时重新尝试",
zap.String("font_path", normalizedPath),
zap.String("font_name", fontName),
zap.Float64("test_width", testWidth))
return false
)
// 注意即使验证失败也返回true因为gofpdf在Output时才会真正加载字体文件
// 这里的验证可能不准确
}
fm.logger.Info("字体已添加到PDF将在Output时加载",
zap.String("font_path", normalizedPath),
zap.String("font_name", fontName),
zap.Float64("test_width", testWidth),
)
return true
}
@@ -145,140 +214,73 @@ func (fm *FontManager) getChineseFontPaths() []string {
// getWatermarkFontPaths 获取水印字体路径列表仅TTF格式
func (fm *FontManager) getWatermarkFontPaths() []string {
// 水印字体文件名(支持大小写变体)
// 水印字体文件名(尝试大小写变体)
fontNames := []string{
"yunfengfeiyunti-2.ttf",
"YunFengFeiYunTi-2.ttf", // 写版本(兼容)
"YunFengFeiYunTi-2.ttf", // 优先尝试大写版本
"yunfengfeiyunti-2.ttf", // 写版本(兼容)
}
return fm.buildFontPaths(fontNames)
}
// buildFontPaths 构建字体文件路径列表(支持多种路径查找方式
// buildFontPaths 构建字体文件路径列表(仅从resources/pdf/fonts加载
// 返回所有存在的字体文件的绝对路径
func (fm *FontManager) buildFontPaths(fontNames []string) []string {
var fontPaths []string
// 方式1: 使用 pdf/fonts/ 目录(相对于当前文件)
for _, fontName := range fontNames {
fontPaths = append(fontPaths, filepath.Join(fm.baseDir, "fonts", fontName))
// 获取resources/pdf目录已返回绝对路径
resourcesPDFDir := GetResourcesPDFDir()
if resourcesPDFDir == "" {
fm.logger.Error("无法获取resources/pdf目录路径")
return []string{}
}
// 方式2: 尝试相对于工作目录路径(适用于编译后的二进制文件
// 这是最常用的方式,适用于直接运行二进制文件的情况
if workDir, err := os.Getwd(); err == nil {
fm.logger.Debug("工作目录", zap.String("work_dir", workDir))
for _, fontName := range fontNames {
// 尝试多个可能的相对路径
paths := []string{
filepath.Join(workDir, "internal", "shared", "pdf", "fonts", fontName),
filepath.Join(workDir, "fonts", fontName),
filepath.Join(workDir, fontName),
// 如果工作目录是项目根目录的父目录
filepath.Join(workDir, "tyapi-server", "internal", "shared", "pdf", "fonts", fontName),
}
fontPaths = append(fontPaths, paths...)
}
} else {
fm.logger.Warn("无法获取工作目录", zap.Error(err))
}
// 构建字体目录路径(resourcesPDFDir已经是绝对路径
fontsDir := filepath.Join(resourcesPDFDir, "fonts")
// 方式3: 尝试使用可执行文件所在目录
// 适用于systemd服务或直接运行二进制文件的情况
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
// 解析符号链接获取真实路径Linux上很重要
if realPath, err := filepath.EvalSymlinks(execPath); err == nil {
execDir = filepath.Dir(realPath)
}
fm.logger.Debug("可执行文件目录",
zap.String("exec_dir", execDir),
zap.String("exec_path", execPath))
for _, fontName := range fontNames {
paths := []string{
filepath.Join(execDir, "internal", "shared", "pdf", "fonts", fontName),
filepath.Join(execDir, "fonts", fontName),
filepath.Join(execDir, fontName),
// 如果可执行文件在bin目录尝试上一级目录
filepath.Join(filepath.Dir(execDir), "internal", "shared", "pdf", "fonts", fontName),
}
fontPaths = append(fontPaths, paths...)
}
} else {
fm.logger.Warn("无法获取可执行文件路径", zap.Error(err))
}
// 方式4: 尝试使用环境变量指定的路径(如果设置了)
if fontDir := os.Getenv("PDF_FONT_DIR"); fontDir != "" {
fm.logger.Info("使用环境变量指定的字体目录", zap.String("font_dir", fontDir))
for _, fontName := range fontNames {
fontPaths = append(fontPaths, filepath.Join(fontDir, fontName))
}
}
// 方式5: 尝试常见的服务器部署路径(硬编码路径,作为后备方案)
// 支持多种Linux服务器部署场景
commonServerPaths := []string{
"/www/tyapi-server/internal/shared/pdf/fonts", // 用户提供的路径
"/app/internal/shared/pdf/fonts", // Docker常见路径
"/usr/local/tyapi-server/internal/shared/pdf/fonts", // 标准安装路径
"/opt/tyapi-server/internal/shared/pdf/fonts", // 可选安装路径
"/home/ubuntu/tyapi-server/internal/shared/pdf/fonts", // Ubuntu用户目录
"/root/tyapi-server/internal/shared/pdf/fonts", // root用户目录
"/var/www/tyapi-server/internal/shared/pdf/fonts", // Web服务器常见路径
}
for _, basePath := range commonServerPaths {
for _, fontName := range fontNames {
fontPaths = append(fontPaths, filepath.Join(basePath, fontName))
}
}
// 记录所有尝试的路径(用于调试)
fm.logger.Debug("查找字体文件",
zap.String("resources_pdf_dir", resourcesPDFDir),
zap.String("fonts_dir", fontsDir),
zap.Strings("font_names", fontNames),
zap.Int("total_paths", len(fontPaths)),
zap.Strings("paths", fontPaths))
)
// 过滤出实际存在的字体文件(并检查权限
// 构建字体文件路径列表(都是绝对路径
var fontPaths []string
for _, fontName := range fontNames {
fontPath := filepath.Join(fontsDir, fontName)
// 确保是绝对路径
if absPath, err := filepath.Abs(fontPath); err == nil {
fontPaths = append(fontPaths, absPath)
} else {
fm.logger.Warn("无法获取字体文件绝对路径",
zap.String("font_path", fontPath),
zap.Error(err),
)
}
}
// 过滤出实际存在的字体文件
var existingFonts []string
for _, fontPath := range fontPaths {
// 检查文件是否存在
fileInfo, err := os.Stat(fontPath)
if err == nil {
// 检查是否为常规文件(不是目录)
if !fileInfo.Mode().IsRegular() {
fm.logger.Debug("路径不是常规文件",
zap.String("font_path", fontPath),
zap.String("mode", fileInfo.Mode().String()))
continue
}
// 检查是否有读取权限Linux上很重要
if fileInfo.Mode().Perm()&0444 == 0 {
fm.logger.Warn("字体文件无读取权限",
zap.String("font_path", fontPath),
zap.String("mode", fileInfo.Mode().String()))
// 仍然尝试添加,因为可能通过其他方式有权限
}
if info, err := os.Stat(fontPath); err == nil && info.Mode().IsRegular() {
existingFonts = append(existingFonts, fontPath)
fm.logger.Debug("找到字体文件",
zap.String("font_path", fontPath),
zap.Int64("size", fileInfo.Size()))
fm.logger.Debug("找到字体文件", zap.String("font_path", fontPath))
} else {
// 只在Debug级别记录避免日志过多
fm.logger.Debug("字体文件不存在",
zap.String("font_path", fontPath),
zap.Error(err))
zap.Error(err),
)
}
}
if len(existingFonts) == 0 {
fm.logger.Warn("未找到任何字体文件",
zap.Strings("font_names", fontNames),
zap.String("base_dir", fm.baseDir),
zap.Int("total_paths_checked", len(fontPaths)))
zap.String("fonts_dir", fontsDir),
zap.Strings("attempted_fonts", fontPaths),
)
} else {
fm.logger.Info("找到字体文件",
zap.Int("count", len(existingFonts)),
zap.Strings("paths", existingFonts))
zap.Strings("font_paths", existingFonts),
)
}
return existingFonts

Binary file not shown.

View File

@@ -5,11 +5,13 @@ import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
"tyapi-server/internal/domains/product/entities"
"github.com/jung-kurt/gofpdf/v2"
qrcode "github.com/skip2/go-qrcode"
"go.uber.org/zap"
)
@@ -65,7 +67,7 @@ func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product,
pb.addWatermark(pdf, chineseFontAvailable)
// 封面页布局 - 居中显示
pageWidth, pageHeight := pdf.GetPageSize()
pageWidth, _ := pdf.GetPageSize()
// 标题区域(页面中上部)
pdf.SetY(80)
@@ -128,14 +130,21 @@ func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product,
}
}
// 底部信息(价格等
// 价格信息(右下角,在产品详情之后
if !product.Price.IsZero() {
pdf.SetY(pageHeight - 60)
// 获取产品详情结束后的Y坐标稍微下移显示价格
contentEndY := pdf.GetY()
pdf.SetY(contentEndY + 5)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "", 12)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, fmt.Sprintf("价格:%s 元", product.Price.String()), "", 1, "C", false, 0, "")
pb.fontManager.SetFont(pdf, "", 14)
_, priceLineHt := pdf.GetFontSize()
priceText := fmt.Sprintf("价格:%s 元", product.Price.String())
textWidth := pdf.GetStringWidth(priceText)
// 右对齐从页面宽度减去文本宽度和右边距15mm
pdf.SetX(pageWidth - textWidth - 15)
pdf.CellFormat(textWidth, priceLineHt, priceText, "", 0, "R", false, 0, "")
}
}
// AddDocumentationPages 添加接口文档页面
@@ -266,8 +275,6 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
} else {
pb.logger.Info("返回字段说明表格渲染成功")
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
@@ -293,6 +300,328 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro
}
}
}
// 添加说明文字和二维码
pb.addAdditionalInfo(pdf, doc, chineseFontAvailable)
}
// AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明)
// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明
func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
// 创建自定义的AddPage函数确保每页都有水印
addPageWithWatermark := func() {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
}
addPageWithWatermark()
pdf.SetY(45)
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "")
// 请求URL
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, "请求URL", "", 1, "L", false, 0, "")
// URL使用黑体字体可能包含中文字符
// 先清理URL中的乱码
cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
pb.fontManager.SetFont(pdf, "", 10) // 使用黑体
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
// 请求方法
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
// 基本信息
if doc.BasicInfo != "" {
pdf.Ln(8)
pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
}
// 请求参数
if doc.RequestParams != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理请求参数
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.RequestParams)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
// 生成JSON示例
if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
pdf.Ln(5)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
// JSON中可能包含中文值使用黑体字体
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON支持中文
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
}
}
// 响应示例
if doc.ResponseExample != "" {
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
// 优先尝试提取和格式化JSON
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON支持中文
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseExample)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// 返回字段说明
if doc.ResponseFields != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
zap.Error(err),
zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseFields)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理错误代码
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ErrorCodes)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 注意:这里不添加二维码和说明,由调用方统一添加
}
// AddSubProductDocumentationPages 添加子产品的接口文档页面(用于组合包)
func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProduct *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool, isLastSubProduct bool) {
// 创建自定义的AddPage函数确保每页都有水印
addPageWithWatermark := func() {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印
}
addPageWithWatermark()
pdf.SetY(45)
pb.fontManager.SetFont(pdf, "B", 18)
_, lineHt := pdf.GetFontSize()
// 显示子产品标题
subProductTitle := fmt.Sprintf("子产品接口文档:%s", subProduct.Name)
if subProduct.Code != "" {
subProductTitle = fmt.Sprintf("子产品接口文档:%s (%s)", subProduct.Name, subProduct.Code)
}
pdf.CellFormat(0, lineHt, subProductTitle, "", 1, "L", false, 0, "")
// 请求URL
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, "请求URL", "", 1, "L", false, 0, "")
// URL使用黑体字体可能包含中文字符
// 先清理URL中的乱码
cleanURL := pb.textProcessor.CleanText(doc.RequestURL)
pb.fontManager.SetFont(pdf, "", 10) // 使用黑体
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false)
// 请求方法
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "B", 12)
pdf.CellFormat(0, lineHt, fmt.Sprintf("请求方法:%s", doc.RequestMethod), "", 1, "L", false, 0, "")
// 基本信息
if doc.BasicInfo != "" {
pdf.Ln(8)
pb.addSection(pdf, "基本信息", doc.BasicInfo, chineseFontAvailable)
}
// 请求参数
if doc.RequestParams != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "请求参数:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理请求参数
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "request_params"); err != nil {
pb.logger.Warn("渲染请求参数表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.RequestParams)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
// 生成JSON示例
if jsonExample := pb.jsonProcessor.GenerateJSONExample(doc.RequestParams, pb.tableParser); jsonExample != "" {
pdf.Ln(5)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "")
// JSON中可能包含中文值使用黑体字体
pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON支持中文
pdf.SetTextColor(0, 0, 0)
pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false)
}
}
// 响应示例
if doc.ResponseExample != "" {
pdf.Ln(8)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "")
// 优先尝试提取和格式化JSON
jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample)
if jsonContent != "" {
// 格式化JSON
formattedJSON, err := pb.jsonProcessor.FormatJSON(jsonContent)
if err == nil {
jsonContent = formattedJSON
}
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON支持中文
pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false)
} else {
// 如果没有JSON尝试使用表格方式处理
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil {
pb.logger.Warn("渲染响应示例表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseExample)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.SetTextColor(0, 0, 0) // 确保深黑色
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
}
// 返回字段说明
if doc.ResponseFields != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "返回字段说明:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理返回字段(支持多个表格,带标题)
if err := pb.tableParser.ParseAndRenderTablesWithTitles(context.Background(), pdf, doc, "response_fields"); err != nil {
pb.logger.Warn("渲染返回字段表格失败,回退到文本显示",
zap.Error(err),
zap.String("content_preview", pb.getContentPreview(doc.ResponseFields, 200)))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ResponseFields)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
} else {
pb.logger.Warn("返回字段内容为空或只有空白字符")
}
}
} else {
pb.logger.Debug("返回字段内容为空,跳过渲染")
}
// 错误代码
if doc.ErrorCodes != "" {
pdf.Ln(8)
// 显示标题
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 14)
_, lineHt = pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "错误代码:", "", 1, "L", false, 0, "")
// 使用新的数据库驱动方式处理错误代码
if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "error_codes"); err != nil {
pb.logger.Warn("渲染错误代码表格失败,回退到文本显示", zap.Error(err))
// 如果表格渲染失败,显示为文本
text := pb.textProcessor.CleanText(doc.ErrorCodes)
if strings.TrimSpace(text) != "" {
pb.fontManager.SetFont(pdf, "", 10)
pdf.MultiCell(0, lineHt*1.3, text, "", "L", false)
}
}
}
// 注意:这里不添加二维码和说明,由调用方统一添加
}
// addSection 添加章节
@@ -637,14 +966,10 @@ func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) {
// 绘制logo如果存在
if pb.logoPath != "" {
if _, err := os.Stat(pb.logoPath); err == nil {
// gofpdf的ImageOptions方法调整位置和大小左边距是15mm
pdf.ImageOptions(pb.logoPath, 15, 5, 15, 15, false, gofpdf.ImageOptions{}, 0, "")
pb.logger.Info("已添加logo", zap.String("path", pb.logoPath))
} else {
pb.logger.Warn("logo文件不存在", zap.String("path", pb.logoPath), zap.Error(err))
pb.logger.Warn("logo文件不存在", zap.String("path", pb.logoPath))
}
} else {
pb.logger.Warn("logo路径为空")
}
// 绘制"天远数据"文字(使用中文字体如果可用)
@@ -828,3 +1153,289 @@ func (pb *PageBuilder) safeSplitText(pdf *gofpdf.Fpdf, text string, width float6
return lines
}
// AddAdditionalInfo 添加说明文字和二维码(公开方法)
func (pb *PageBuilder) AddAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
pb.addAdditionalInfo(pdf, doc, chineseFontAvailable)
}
// addAdditionalInfo 添加说明文字和二维码(私有方法)
func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
// 检查是否需要换页
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
remainingHeight := pageHeight - currentY - bottomMargin
// 如果剩余空间不足,添加新页
if remainingHeight < 100 {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable)
pdf.SetY(45)
}
// 添加分隔线
pdf.Ln(10)
pdf.SetLineWidth(0.5)
pdf.SetDrawColor(200, 200, 200)
pdf.Line(15, pdf.GetY(), pageWidth-15, pdf.GetY())
pdf.SetDrawColor(0, 0, 0)
// 添加说明文字标题
pdf.Ln(15)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 16)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "接入流程说明", "", 1, "L", false, 0, "")
// 读取说明文本文件
explanationText := pb.readExplanationText()
if explanationText != "" {
pb.logger.Debug("开始渲染说明文本",
zap.Int("text_length", len(explanationText)),
zap.Int("line_count", len(strings.Split(explanationText, "\n"))),
)
pdf.Ln(5)
pb.fontManager.SetFont(pdf, "", 11)
_, lineHt = pdf.GetFontSize()
// 处理说明文本,按行分割并显示
lines := strings.Split(explanationText, "\n")
renderedLines := 0
for i, line := range lines {
// 保留原始行用于日志
originalLine := line
line = strings.TrimSpace(line)
// 处理空行
if line == "" {
pdf.Ln(3)
continue
}
// 清理文本(保留中文字符和标点)
cleanLine := pb.textProcessor.CleanText(line)
// 检查清理后的文本是否为空
if strings.TrimSpace(cleanLine) == "" {
pb.logger.Warn("文本行清理后为空,跳过渲染",
zap.Int("line_number", i+1),
zap.String("original_line", originalLine),
)
continue
}
// 渲染文本行
// 使用MultiCell自动换行支持长文本
pdf.MultiCell(0, lineHt*1.4, cleanLine, "", "L", false)
renderedLines++
}
pb.logger.Info("说明文本渲染完成",
zap.Int("total_lines", len(lines)),
zap.Int("rendered_lines", renderedLines),
)
} else {
pb.logger.Warn("说明文本为空,跳过渲染")
}
// 添加二维码生成方法和使用方法说明
pb.addQRCodeSection(pdf, doc, chineseFontAvailable)
}
// readExplanationText 读取说明文本文件
func (pb *PageBuilder) readExplanationText() string {
resourcesPDFDir := GetResourcesPDFDir()
if resourcesPDFDir == "" {
pb.logger.Error("无法获取resources/pdf目录路径")
return ""
}
textFilePath := filepath.Join(resourcesPDFDir, "后勤服务.txt")
// 记录尝试读取的文件路径
pb.logger.Debug("尝试读取说明文本文件", zap.String("path", textFilePath))
// 检查文件是否存在
fileInfo, err := os.Stat(textFilePath)
if err != nil {
if os.IsNotExist(err) {
pb.logger.Warn("说明文本文件不存在",
zap.String("path", textFilePath),
zap.String("resources_dir", resourcesPDFDir),
)
} else {
pb.logger.Error("检查说明文本文件时出错",
zap.String("path", textFilePath),
zap.Error(err),
)
}
return ""
}
// 检查文件大小
if fileInfo.Size() == 0 {
pb.logger.Warn("说明文本文件为空", zap.String("path", textFilePath))
return ""
}
// 尝试读取文件使用os.ReadFile替代已废弃的ioutil.ReadFile
content, err := os.ReadFile(textFilePath)
if err != nil {
pb.logger.Error("读取说明文本文件失败",
zap.String("path", textFilePath),
zap.Error(err),
)
return ""
}
// 转换为字符串
text := string(content)
// 检查内容是否为空(去除空白字符后)
trimmedText := strings.TrimSpace(text)
if trimmedText == "" {
pb.logger.Warn("说明文本文件内容为空(只有空白字符)",
zap.String("path", textFilePath),
zap.Int("file_size", len(content)),
)
return ""
}
// 记录读取成功的信息
pb.logger.Info("成功读取说明文本文件",
zap.String("path", textFilePath),
zap.Int64("file_size", fileInfo.Size()),
zap.Int("content_length", len(content)),
zap.Int("text_length", len(text)),
zap.Int("line_count", len(strings.Split(text, "\n"))),
)
// 返回文本内容
return text
}
// addQRCodeSection 添加二维码生成方法和使用方法说明
func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) {
_, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
// 检查是否需要换页(为二维码预留空间)
if pageHeight-currentY-bottomMargin < 120 {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable)
pdf.SetY(45)
}
// 添加二维码标题
pdf.Ln(15)
pdf.SetTextColor(0, 0, 0)
pb.fontManager.SetFont(pdf, "B", 16)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "天远api官网二维码", "", 1, "L", false, 0, "")
// 先生成并添加二维码图片(确保二维码能够正常显示)
pb.addQRCodeImage(pdf, "https://tianyuanapi.com/", chineseFontAvailable)
// 二维码说明文字(简化版,放在二维码之后)
pdf.Ln(10)
pb.fontManager.SetFont(pdf, "", 11)
_, lineHt = pdf.GetFontSize()
qrCodeExplanation := "使用手机扫描上方二维码可直接跳转到天远API官网https://tianyuanapi.com/),获取更多接口文档和资源。\n\n" +
"二维码使用方法:\n" +
"1. 使用手机相机或二维码扫描应用扫描二维码\n" +
"2. 扫描后会自动跳转到天远API官网首页\n" +
"3. 在官网可以查看完整的产品列表、接口文档和使用说明"
// 处理说明文本,按行分割并显示
lines := strings.Split(qrCodeExplanation, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
pdf.Ln(2)
continue
}
// 普通文本行
cleanLine := pb.textProcessor.CleanText(line)
if strings.TrimSpace(cleanLine) != "" {
pdf.MultiCell(0, lineHt*1.3, cleanLine, "", "L", false)
}
}
}
// addQRCodeImage 生成并添加二维码图片到PDF
func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseFontAvailable bool) {
// 检查是否需要换页
pageWidth, pageHeight := pdf.GetPageSize()
_, _, _, bottomMargin := pdf.GetMargins()
currentY := pdf.GetY()
// 二维码大小40mm
qrSize := 40.0
if pageHeight-currentY-bottomMargin < qrSize+20 {
pdf.AddPage()
pb.addHeader(pdf, chineseFontAvailable)
pb.addWatermark(pdf, chineseFontAvailable)
pdf.SetY(45)
}
// 生成二维码
qr, err := qrcode.New(content, qrcode.Medium)
if err != nil {
pb.logger.Warn("生成二维码失败", zap.Error(err))
return
}
// 将二维码转换为PNG字节
qrBytes, err := qr.PNG(256)
if err != nil {
pb.logger.Warn("转换二维码为PNG失败", zap.Error(err))
return
}
// 创建临时文件保存二维码使用os.CreateTemp替代已废弃的ioutil.TempFile
tmpFile, err := os.CreateTemp("", "qrcode_*.png")
if err != nil {
pb.logger.Warn("创建临时文件失败", zap.Error(err))
return
}
defer os.Remove(tmpFile.Name()) // 清理临时文件
// 写入二维码数据
if _, err := tmpFile.Write(qrBytes); err != nil {
pb.logger.Warn("写入二维码数据失败", zap.Error(err))
tmpFile.Close()
return
}
tmpFile.Close()
// 添加二维码说明
pdf.Ln(10)
pb.fontManager.SetFont(pdf, "", 10)
_, lineHt := pdf.GetFontSize()
pdf.CellFormat(0, lineHt, "官网二维码:", "", 1, "L", false, 0, "")
// 计算二维码位置(居中)
qrX := (pageWidth - qrSize) / 2
// 添加二维码图片
pdf.Ln(5)
pdf.ImageOptions(tmpFile.Name(), qrX, pdf.GetY(), qrSize, qrSize, false, gofpdf.ImageOptions{}, 0, "")
// 添加二维码下方的说明文字
pdf.SetY(pdf.GetY() + qrSize + 5)
pb.fontManager.SetFont(pdf, "", 9)
_, lineHt = pdf.GetFontSize()
qrNote := "使用手机扫描上方二维码可访问官网获取更多详情"
noteWidth := pdf.GetStringWidth(qrNote)
noteX := (pageWidth - noteWidth) / 2
pdf.SetX(noteX)
pdf.CellFormat(noteWidth, lineHt, qrNote, "", 1, "C", false, 0, "")
}

View File

@@ -0,0 +1,475 @@
package pdf
import (
"crypto/md5"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/zap"
)
// PDFCacheManager PDF缓存管理器
// 负责管理PDF文件的本地缓存提高下载性能
type PDFCacheManager struct {
logger *zap.Logger
cacheDir string
ttl time.Duration // 缓存过期时间
maxSize int64 // 最大缓存大小(字节)
mu sync.RWMutex // 保护并发访问
cleanupOnce sync.Once // 确保清理任务只启动一次
}
// CacheInfo 缓存信息
type CacheInfo struct {
FilePath string
Size int64
CreatedAt time.Time
ExpiresAt time.Time
ProductID string
Version string
}
// NewPDFCacheManager 创建PDF缓存管理器
func NewPDFCacheManager(logger *zap.Logger, cacheDir string, ttl time.Duration, maxSize int64) (*PDFCacheManager, error) {
// 如果缓存目录为空,使用默认目录
if cacheDir == "" {
cacheDir = filepath.Join(os.TempDir(), "tyapi_pdf_cache")
}
// 确保缓存目录存在
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return nil, fmt.Errorf("创建缓存目录失败: %w", err)
}
manager := &PDFCacheManager{
logger: logger,
cacheDir: cacheDir,
ttl: ttl,
maxSize: maxSize,
}
// 启动定期清理任务
manager.startCleanupTask()
logger.Info("PDF缓存管理器已初始化",
zap.String("cache_dir", cacheDir),
zap.Duration("ttl", ttl),
zap.Int64("max_size", maxSize),
)
return manager, nil
}
// GetCacheKey 生成缓存键
// 基于产品ID和文档版本号生成唯一的缓存键
func (m *PDFCacheManager) GetCacheKey(productID string, version string) string {
// 使用MD5哈希生成短键名
key := fmt.Sprintf("%s:%s", productID, version)
hash := md5.Sum([]byte(key))
return hex.EncodeToString(hash[:])
}
// GetCachePath 获取缓存文件路径
func (m *PDFCacheManager) GetCachePath(cacheKey string) string {
return filepath.Join(m.cacheDir, fmt.Sprintf("%s.pdf", cacheKey))
}
// Get 从缓存获取PDF文件
// 返回PDF字节流和是否命中缓存
func (m *PDFCacheManager) Get(productID string, version string) ([]byte, bool, error) {
cacheKey := m.GetCacheKey(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.mu.RLock()
defer m.mu.RUnlock()
// 检查文件是否存在
info, err := os.Stat(cachePath)
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil // 缓存未命中
}
return nil, false, fmt.Errorf("检查缓存文件失败: %w", err)
}
// 检查文件是否过期
// 使用文件的修改时间作为创建时间
createdAt := info.ModTime()
expiresAt := createdAt.Add(m.ttl)
if time.Now().After(expiresAt) {
// 缓存已过期,删除文件
m.logger.Debug("缓存已过期,删除文件",
zap.String("product_id", productID),
zap.String("cache_key", cacheKey),
zap.Time("expires_at", expiresAt),
)
_ = os.Remove(cachePath)
return nil, false, nil
}
// 读取缓存文件
pdfBytes, err := os.ReadFile(cachePath)
if err != nil {
return nil, false, fmt.Errorf("读取缓存文件失败: %w", err)
}
m.logger.Debug("缓存命中",
zap.String("product_id", productID),
zap.String("cache_key", cacheKey),
zap.Int64("file_size", int64(len(pdfBytes))),
zap.Time("expires_at", expiresAt),
)
return pdfBytes, true, nil
}
// Set 将PDF文件保存到缓存
func (m *PDFCacheManager) Set(productID string, version string, pdfBytes []byte) error {
cacheKey := m.GetCacheKey(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.mu.Lock()
defer m.mu.Unlock()
// 检查是否已存在缓存文件(用于判断是新建还是更新)
fileExists := false
var oldFileSize int64
if info, err := os.Stat(cachePath); err == nil {
fileExists = true
oldFileSize = info.Size()
m.logger.Info("检测到已存在的缓存文件,将更新缓存",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("old_file_size", oldFileSize),
zap.Int64("new_file_size", int64(len(pdfBytes))),
)
} else {
m.logger.Info("开始创建新的PDF缓存",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("file_size", int64(len(pdfBytes))),
)
}
// 检查缓存大小限制
if m.maxSize > 0 {
currentSize, err := m.getCacheDirSize()
if err != nil {
m.logger.Warn("获取缓存目录大小失败", zap.Error(err))
} else {
// 如果更新已存在的文件,需要减去旧文件的大小
sizeToAdd := int64(len(pdfBytes))
if fileExists {
sizeToAdd = int64(len(pdfBytes)) - oldFileSize
}
if currentSize+sizeToAdd > m.maxSize {
// 缓存空间不足,清理过期文件
m.logger.Warn("缓存空间不足,开始清理过期文件",
zap.Int64("current_size", currentSize),
zap.Int64("max_size", m.maxSize),
zap.Int64("required_size", sizeToAdd),
zap.Bool("is_update", fileExists),
)
if err := m.cleanupExpiredFiles(); err != nil {
m.logger.Warn("清理过期文件失败", zap.Error(err))
}
// 再次检查
currentSize, _ = m.getCacheDirSize()
if currentSize+sizeToAdd > m.maxSize {
m.logger.Error("缓存空间不足,无法保存文件",
zap.Int64("current_size", currentSize),
zap.Int64("max_size", m.maxSize),
zap.Int64("required_size", sizeToAdd),
)
return fmt.Errorf("缓存空间不足,无法保存文件")
}
}
}
}
// 写入缓存文件
if err := os.WriteFile(cachePath, pdfBytes, 0644); err != nil {
m.logger.Error("写入缓存文件失败",
zap.String("product_id", productID),
zap.String("cache_path", cachePath),
zap.Error(err),
)
return fmt.Errorf("写入缓存文件失败: %w", err)
}
// 计算过期时间
expiresAt := time.Now().Add(m.ttl)
// 根据是新建还是更新,记录不同的日志
if fileExists {
m.logger.Info("PDF缓存已更新",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("old_file_size", oldFileSize),
zap.Int64("new_file_size", int64(len(pdfBytes))),
zap.Int64("size_change", int64(len(pdfBytes))-oldFileSize),
zap.Duration("ttl", m.ttl),
zap.Time("expires_at", expiresAt),
)
} else {
m.logger.Info("PDF缓存已创建",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("file_size", int64(len(pdfBytes))),
zap.Duration("ttl", m.ttl),
zap.Time("expires_at", expiresAt),
)
}
return nil
}
// Invalidate 使缓存失效
func (m *PDFCacheManager) Invalidate(productID string, version string) error {
cacheKey := m.GetCacheKey(productID, version)
cachePath := m.GetCachePath(cacheKey)
m.logger.Info("开始使缓存失效",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
)
m.mu.Lock()
defer m.mu.Unlock()
// 检查文件是否存在,记录文件信息
fileInfo, err := os.Stat(cachePath)
if err != nil {
if os.IsNotExist(err) {
m.logger.Info("缓存文件不存在,视为已失效",
zap.String("product_id", productID),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
)
return nil // 文件不存在,视为已失效
}
m.logger.Error("检查缓存文件失败",
zap.String("product_id", productID),
zap.String("cache_path", cachePath),
zap.Error(err),
)
return fmt.Errorf("检查缓存文件失败: %w", err)
}
// 记录文件信息
fileSize := fileInfo.Size()
fileModTime := fileInfo.ModTime()
// 删除缓存文件
if err := os.Remove(cachePath); err != nil {
m.logger.Error("删除缓存文件失败",
zap.String("product_id", productID),
zap.String("cache_path", cachePath),
zap.Error(err),
)
return fmt.Errorf("删除缓存文件失败: %w", err)
}
m.logger.Info("缓存已成功失效",
zap.String("product_id", productID),
zap.String("version", version),
zap.String("cache_key", cacheKey),
zap.String("cache_path", cachePath),
zap.Int64("deleted_file_size", fileSize),
zap.Time("file_created_at", fileModTime),
)
return nil
}
// InvalidateByProductID 使指定产品的所有缓存失效
func (m *PDFCacheManager) InvalidateByProductID(productID string) error {
m.mu.Lock()
defer m.mu.Unlock()
// 遍历缓存目录,查找匹配的文件
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
count := 0
for _, file := range files {
if file.IsDir() {
continue
}
// 读取文件内容检查是否匹配产品ID
// 由于我们使用MD5哈希无法直接匹配需要读取文件元数据
// 这里简化处理删除所有PDF文件实际应该存储元数据
// 更好的方案是使用数据库或JSON文件存储元数据
if filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
if err := os.Remove(filePath); err == nil {
count++
}
}
}
m.logger.Info("已清理产品缓存",
zap.String("product_id", productID),
zap.Int("deleted_count", count),
)
return nil
}
// Clear 清空所有缓存
func (m *PDFCacheManager) Clear() error {
m.mu.Lock()
defer m.mu.Unlock()
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
count := 0
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
if err := os.Remove(filePath); err == nil {
count++
}
}
}
m.logger.Info("已清空所有缓存", zap.Int("deleted_count", count))
return nil
}
// GetCacheStats 获取缓存统计信息
func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) {
m.mu.RLock()
defer m.mu.RUnlock()
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return nil, fmt.Errorf("读取缓存目录失败: %w", err)
}
var totalSize int64
var fileCount int
var expiredCount int
now := time.Now()
for _, file := range files {
if file.IsDir() {
continue
}
if filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
totalSize += info.Size()
fileCount++
// 检查是否过期
expiresAt := info.ModTime().Add(m.ttl)
if now.After(expiresAt) {
expiredCount++
}
}
}
return map[string]interface{}{
"total_size": totalSize,
"file_count": fileCount,
"expired_count": expiredCount,
"cache_dir": m.cacheDir,
"ttl": m.ttl.String(),
"max_size": m.maxSize,
}, nil
}
// cleanupExpiredFiles 清理过期的缓存文件
func (m *PDFCacheManager) cleanupExpiredFiles() error {
files, err := os.ReadDir(m.cacheDir)
if err != nil {
return fmt.Errorf("读取缓存目录失败: %w", err)
}
now := time.Now()
count := 0
var totalFreed int64
for _, file := range files {
if file.IsDir() {
continue
}
if filepath.Ext(file.Name()) == ".pdf" {
filePath := filepath.Join(m.cacheDir, file.Name())
info, err := os.Stat(filePath)
if err != nil {
continue
}
expiresAt := info.ModTime().Add(m.ttl)
if now.After(expiresAt) {
if err := os.Remove(filePath); err == nil {
count++
totalFreed += info.Size()
}
}
}
}
if count > 0 {
m.logger.Info("已清理过期缓存文件",
zap.Int("count", count),
zap.Int64("freed_size", totalFreed),
)
}
return nil
}
// getCacheDirSize 获取缓存目录总大小
func (m *PDFCacheManager) getCacheDirSize() (int64, error) {
var totalSize int64
err := filepath.Walk(m.cacheDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
totalSize += info.Size()
}
return nil
})
return totalSize, err
}
// startCleanupTask 启动定期清理任务
func (m *PDFCacheManager) startCleanupTask() {
m.cleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(1 * time.Hour) // 每小时清理一次
defer ticker.Stop()
for range ticker.C {
if err := m.cleanupExpiredFiles(); err != nil {
m.logger.Warn("定期清理缓存失败", zap.Error(err))
}
}
}()
})
}

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