Compare commits
27 Commits
7e0d58b295
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 451d869361 | |||
| 08ea153cac | |||
| 6147878dfe | |||
| be47a0f045 | |||
| 810696e0f0 | |||
| 17dbaf1ccb | |||
| 18f3d10518 | |||
| 0d4953c6d3 | |||
| 3f64600f02 | |||
| 2c89b8cb26 | |||
| 09d7a4f076 | |||
| 83d0fd6587 | |||
| 0fd28054f1 | |||
| ce858983ee | |||
| 9b2bffae15 | |||
| c68ece5bea | |||
| 398d2cee74 | |||
| b6c8d93af5 | |||
| b423aa6be8 | |||
| a47c306c87 | |||
| af88bcc8eb | |||
| 89367fb2ee | |||
| 05b6623e75 | |||
| bfedec249f | |||
| 9f669a9c94 | |||
| 0f5c4f4303 | |||
| d9c2d9f103 |
@@ -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
|
||||
}
|
||||
|
||||
22
config.yaml
22
config.yaml
@@ -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"
|
||||
|
||||
# ===========================================
|
||||
# 🔍 天眼查配置
|
||||
# ===========================================
|
||||
|
||||
@@ -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 # 开发环境禁用代理检查
|
||||
|
||||
210
docs/PDF缓存优化说明.md
Normal file
210
docs/PDF缓存优化说明.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# PDF接口文档下载缓存优化说明
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本次优化为PDF接口文档下载功能添加了本地文件缓存机制,显著提升了下载性能,减少了重复生成PDF的开销。
|
||||
|
||||
## 🔍 问题分析
|
||||
|
||||
### 原有问题
|
||||
|
||||
1. **性能问题**:
|
||||
- 每次请求都重新生成PDF,没有缓存机制
|
||||
- PDF生成涉及复杂的字体加载、页面构建、表格渲染等操作,耗时较长
|
||||
- 同一产品的PDF被多次下载时,会重复执行相同的生成过程
|
||||
|
||||
2. **资源浪费**:
|
||||
- CPU资源浪费在重复的PDF生成上
|
||||
- 数据库查询重复执行
|
||||
- 没有版本控制,即使产品文档没有变化,也会重新生成
|
||||
|
||||
## ✅ 解决方案
|
||||
|
||||
### 1. PDF缓存管理器 (`PDFCacheManager`)
|
||||
|
||||
创建了专门的PDF缓存管理器,提供以下功能:
|
||||
|
||||
- **本地文件缓存**:将生成的PDF文件保存到本地文件系统
|
||||
- **版本控制**:基于产品ID和文档版本号生成缓存键,确保版本更新时自动失效
|
||||
- **自动过期**:支持TTL(Time 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
2
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
@@ -245,6 +245,8 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
|
||||
&articleEntities.Category{},
|
||||
&articleEntities.Tag{},
|
||||
&articleEntities.ScheduledTask{},
|
||||
// 公告
|
||||
&articleEntities.Announcement{},
|
||||
|
||||
// 统计域
|
||||
&statisticsEntities.StatisticsMetric{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"` // 是否可以重试
|
||||
}
|
||||
@@ -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参数)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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 更新产品
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 // 返回nil,handler中会检查
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 打印注册的路由信息
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "查询原因ID:1-授信审批;2-贷中管理;3-贷后管理;4-异议处理;5-担保查询;6-租赁资质审查;7-融资租赁审批;8-借贷撮合查询;9-保险审批;10-资质审核;11-风控审核;12-企业背调",
|
||||
}
|
||||
|
||||
if desc, exists := descMap[jsonTag]; exists {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ¶msDto); 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
|
||||
}
|
||||
@@ -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, ¶msDto); 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
|
||||
}
|
||||
@@ -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, ¶msDto); 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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, ¶msDto); err != nil {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¶msDto); 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, ¶msDto); 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
|
||||
}
|
||||
137
internal/domains/article/entities/announcement.go
Normal file
137
internal/domains/article/entities/announcement.go
Normal 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
|
||||
}
|
||||
24
internal/domains/article/repositories/announcement.go
Normal file
24
internal/domains/article/repositories/announcement.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
133
internal/domains/article/services/announcement_service.go
Normal file
133
internal/domains/article/services/announcement_service.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
136
internal/domains/finance/entities/pay_order.go
Normal file
136
internal/domains/finance/entities/pay_order.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
33
internal/domains/finance/entities/wechat_order.go
Normal file
33
internal/domains/finance/entities/wechat_order.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"tyapi-server/internal/domains/api/entities"
|
||||
"tyapi-server/internal/domains/api/repositories"
|
||||
@@ -229,6 +230,11 @@ func (r *GormApiCallRepository) CountByUserId(ctx context.Context, userId string
|
||||
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ?", userId)
|
||||
}
|
||||
|
||||
// CountByUserIdAndProductId 按用户ID和产品ID统计API调用次数
|
||||
func (r *GormApiCallRepository) CountByUserIdAndProductId(ctx context.Context, userId string, productId string) (int64, error) {
|
||||
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ? AND product_id = ?", userId, productId)
|
||||
}
|
||||
|
||||
// CountByUserIdAndDateRange 按用户ID和日期范围统计API调用次数
|
||||
func (r *GormApiCallRepository) CountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (int64, error) {
|
||||
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ? AND created_at >= ? AND created_at < ?", userId, startDate, endDate)
|
||||
@@ -304,8 +310,29 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
|
||||
|
||||
// 应用筛选条件
|
||||
if filters != nil {
|
||||
// 用户ID筛选
|
||||
if userId, ok := filters["user_id"].(string); ok && userId != "" {
|
||||
// 用户ID筛选(支持单个user_id和多个user_ids)
|
||||
// 如果同时存在,优先使用user_ids(批量查询)
|
||||
if userIds, ok := filters["user_ids"].(string); ok && userIds != "" {
|
||||
// 解析逗号分隔的用户ID列表
|
||||
userIdsList := strings.Split(userIds, ",")
|
||||
// 去除空白字符
|
||||
var cleanUserIds []string
|
||||
for _, id := range userIdsList {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
cleanUserIds = append(cleanUserIds, id)
|
||||
}
|
||||
}
|
||||
if len(cleanUserIds) > 0 {
|
||||
placeholders := strings.Repeat("?,", len(cleanUserIds))
|
||||
placeholders = placeholders[:len(placeholders)-1] // 移除最后一个逗号
|
||||
whereCondition += " AND ac.user_id IN (" + placeholders + ")"
|
||||
for _, id := range cleanUserIds {
|
||||
whereArgs = append(whereArgs, id)
|
||||
}
|
||||
}
|
||||
} else if userId, ok := filters["user_id"].(string); ok && userId != "" {
|
||||
// 单个用户ID筛选
|
||||
whereCondition += " AND ac.user_id = ?"
|
||||
whereArgs = append(whereArgs, userId)
|
||||
}
|
||||
@@ -404,10 +431,11 @@ func (r *GormApiCallRepository) GetSystemTotalCalls(ctx context.Context) (int64,
|
||||
}
|
||||
|
||||
// GetSystemCallsByDateRange 获取系统指定时间范围内的API调用次数
|
||||
// endDate 应该是结束日期当天的次日00:00:00(日统计)或下个月1号00:00:00(月统计),使用 < 而不是 <=
|
||||
func (r *GormApiCallRepository) GetSystemCallsByDateRange(ctx context.Context, startDate, endDate time.Time) (int64, error) {
|
||||
var count int64
|
||||
err := r.GetDB(ctx).Model(&entities.ApiCall{}).
|
||||
Where("created_at >= ? AND created_at <= ?", startDate, endDate).
|
||||
Where("created_at >= ? AND created_at < ?", startDate, endDate).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -445,7 +473,7 @@ func (r *GormApiCallRepository) GetSystemMonthlyStats(ctx context.Context, start
|
||||
COUNT(*) as calls
|
||||
FROM api_calls
|
||||
WHERE created_at >= $1
|
||||
AND created_at <= $2
|
||||
AND created_at < $2
|
||||
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
|
||||
ORDER BY month ASC
|
||||
`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -382,12 +382,26 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
|
||||
|
||||
// 应用筛选条件
|
||||
if filters != nil {
|
||||
// 用户ID筛选
|
||||
if userId, ok := filters["user_id"].(string); ok && userId != "" {
|
||||
// 用户ID筛选(支持单个和多个)
|
||||
if userIds, ok := filters["user_ids"].(string); ok && userIds != "" {
|
||||
// 多个用户ID,逗号分隔
|
||||
userIdsList := strings.Split(userIds, ",")
|
||||
whereCondition += " AND wt.user_id IN ?"
|
||||
whereArgs = append(whereArgs, userIdsList)
|
||||
} else if userId, ok := filters["user_id"].(string); ok && userId != "" {
|
||||
// 单个用户ID
|
||||
whereCondition += " AND wt.user_id = ?"
|
||||
whereArgs = append(whereArgs, userId)
|
||||
}
|
||||
|
||||
// 产品ID筛选(支持多个)
|
||||
if productIds, ok := filters["product_ids"].(string); ok && productIds != "" {
|
||||
// 多个产品ID,逗号分隔
|
||||
productIdsList := strings.Split(productIds, ",")
|
||||
whereCondition += " AND wt.product_id IN ?"
|
||||
whereArgs = append(whereArgs, productIdsList)
|
||||
}
|
||||
|
||||
// 时间范围筛选
|
||||
if startTime, ok := filters["start_time"].(time.Time); ok {
|
||||
whereCondition += " AND wt.created_at >= ?"
|
||||
@@ -572,10 +586,11 @@ func (r *GormWalletTransactionRepository) GetSystemTotalAmount(ctx context.Conte
|
||||
}
|
||||
|
||||
// GetSystemAmountByDateRange 获取系统指定时间范围内的消费金额
|
||||
// endDate 应该是结束日期当天的次日00:00:00(日统计)或下个月1号00:00:00(月统计),使用 < 而不是 <=
|
||||
func (r *GormWalletTransactionRepository) GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error) {
|
||||
var total float64
|
||||
err := r.GetDB(ctx).Model(&entities.WalletTransaction{}).
|
||||
Where("created_at >= ? AND created_at <= ?", startDate, endDate).
|
||||
Where("created_at >= ? AND created_at < ?", startDate, endDate).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&total).Error
|
||||
return total, err
|
||||
@@ -614,7 +629,7 @@ func (r *GormWalletTransactionRepository) GetSystemMonthlyStats(ctx context.Cont
|
||||
COALESCE(SUM(amount), 0) as amount
|
||||
FROM wallet_transactions
|
||||
WHERE created_at >= ?
|
||||
AND created_at <= ?
|
||||
AND created_at < ?
|
||||
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
|
||||
ORDER BY month ASC
|
||||
`
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
411
internal/infrastructure/http/handlers/announcement_handler.go
Normal file
411
internal/infrastructure/http/handlers/announcement_handler.go
Normal 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, "获取统计信息成功")
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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, "事件系统调试信息")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"tyapi-server/internal/application/api"
|
||||
"tyapi-server/internal/application/finance"
|
||||
@@ -10,9 +13,6 @@ import (
|
||||
"tyapi-server/internal/application/product/dto/queries"
|
||||
"tyapi-server/internal/application/product/dto/responses"
|
||||
"tyapi-server/internal/shared/interfaces"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProductAdminHandler 产品管理员HTTP处理器
|
||||
@@ -72,13 +72,14 @@ func (h *ProductAdminHandler) CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.productAppService.CreateProduct(c.Request.Context(), &cmd); err != nil {
|
||||
result, err := h.productAppService.CreateProduct(c.Request.Context(), &cmd)
|
||||
if err != nil {
|
||||
h.logger.Error("创建产品失败", zap.Error(err))
|
||||
h.responseBuilder.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.responseBuilder.Created(c, nil, "产品创建成功")
|
||||
h.responseBuilder.Created(c, result, "产品创建成功")
|
||||
}
|
||||
|
||||
// UpdateProduct 更新产品
|
||||
@@ -1237,13 +1238,27 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
|
||||
|
||||
// 时间范围筛选
|
||||
if startTime := c.Query("start_time"); startTime != "" {
|
||||
// 处理URL编码的+号,转换为空格
|
||||
startTime = strings.ReplaceAll(startTime, "+", " ")
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", startTime); err == nil {
|
||||
filters["start_time"] = t
|
||||
} else {
|
||||
// 尝试其他格式
|
||||
if t, err := time.Parse("2006-01-02T15:04:05", startTime); err == nil {
|
||||
filters["start_time"] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
if endTime := c.Query("end_time"); endTime != "" {
|
||||
// 处理URL编码的+号,转换为空格
|
||||
endTime = strings.ReplaceAll(endTime, "+", " ")
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", endTime); err == nil {
|
||||
filters["end_time"] = t
|
||||
} else {
|
||||
// 尝试其他格式
|
||||
if t, err := time.Parse("2006-01-02T15:04:05", endTime); err == nil {
|
||||
filters["end_time"] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1262,6 +1277,11 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
|
||||
filters["product_ids"] = productIds
|
||||
}
|
||||
|
||||
// 企业名称筛选
|
||||
if companyName := c.Query("company_name"); companyName != "" {
|
||||
filters["company_name"] = companyName
|
||||
}
|
||||
|
||||
// 金额范围筛选
|
||||
if minAmount := c.Query("min_amount"); minAmount != "" {
|
||||
filters["min_amount"] = minAmount
|
||||
@@ -1281,7 +1301,16 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
|
||||
fileData, err := h.financeAppService.ExportAdminWalletTransactions(c.Request.Context(), filters, format)
|
||||
if err != nil {
|
||||
h.logger.Error("导出消费记录失败", zap.Error(err))
|
||||
h.responseBuilder.BadRequest(c, "导出消费记录失败")
|
||||
|
||||
// 根据错误信息返回具体的提示
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "没有找到符合条件的数据") || strings.Contains(errMsg, "没有数据") {
|
||||
h.responseBuilder.NotFound(c, "没有找到符合筛选条件的数据,请调整筛选条件后重试")
|
||||
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
|
||||
h.responseBuilder.BadRequest(c, errMsg)
|
||||
} else {
|
||||
h.responseBuilder.BadRequest(c, "导出消费记录失败:"+errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1308,7 +1337,7 @@ func (h *ProductAdminHandler) ExportAdminWalletTransactions(c *gin.Context) {
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页数量" default(10)
|
||||
// @Param user_id query string false "用户ID"
|
||||
// @Param recharge_type query string false "充值类型" Enums(alipay, transfer, gift)
|
||||
// @Param recharge_type query string false "充值类型" Enums(alipay, wechat, transfer, gift)
|
||||
// @Param status query string false "状态" Enums(pending, success, failed)
|
||||
// @Param min_amount query string false "最小金额"
|
||||
// @Param max_amount query string false "最大金额"
|
||||
@@ -1364,6 +1393,11 @@ func (h *ProductAdminHandler) GetAdminRechargeRecords(c *gin.Context) {
|
||||
filters["max_amount"] = maxAmount
|
||||
}
|
||||
|
||||
// 企业名称筛选
|
||||
if companyName := c.Query("company_name"); companyName != "" {
|
||||
filters["company_name"] = companyName
|
||||
}
|
||||
|
||||
// 构建分页选项
|
||||
options := interfaces.ListOptions{
|
||||
Page: page,
|
||||
@@ -1390,7 +1424,7 @@ func (h *ProductAdminHandler) GetAdminRechargeRecords(c *gin.Context) {
|
||||
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv
|
||||
// @Security Bearer
|
||||
// @Param user_ids query string false "用户ID列表,逗号分隔"
|
||||
// @Param recharge_type query string false "充值类型" Enums(alipay, transfer, gift)
|
||||
// @Param recharge_type query string false "充值类型" Enums(alipay, wechat, transfer, gift)
|
||||
// @Param status query string false "状态" Enums(pending, success, failed)
|
||||
// @Param start_time query string false "开始时间" format(date-time)
|
||||
// @Param end_time query string false "结束时间" format(date-time)
|
||||
@@ -1442,7 +1476,16 @@ func (h *ProductAdminHandler) ExportAdminRechargeRecords(c *gin.Context) {
|
||||
fileData, err := h.financeAppService.ExportAdminRechargeRecords(c.Request.Context(), filters, format)
|
||||
if err != nil {
|
||||
h.logger.Error("导出充值记录失败", zap.Error(err))
|
||||
h.responseBuilder.BadRequest(c, "导出充值记录失败")
|
||||
|
||||
// 根据错误信息返回具体的提示
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "没有找到符合条件的数据") || strings.Contains(errMsg, "没有数据") {
|
||||
h.responseBuilder.NotFound(c, "没有找到符合筛选条件的充值记录,请调整筛选条件后重试")
|
||||
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
|
||||
h.responseBuilder.BadRequest(c, errMsg)
|
||||
} else {
|
||||
h.responseBuilder.BadRequest(c, "导出充值记录失败:"+errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1468,7 +1511,10 @@ func (h *ProductAdminHandler) GetAdminApiCalls(c *gin.Context) {
|
||||
// 构建筛选条件
|
||||
filters := make(map[string]interface{})
|
||||
|
||||
// 用户ID筛选
|
||||
// 用户ID筛选(支持单个user_id和多个user_ids,根据需求使用)
|
||||
if userId := c.Query("user_id"); userId != "" {
|
||||
filters["user_id"] = userId
|
||||
}
|
||||
if userIds := c.Query("user_ids"); userIds != "" {
|
||||
filters["user_ids"] = userIds
|
||||
}
|
||||
@@ -1498,15 +1544,37 @@ func (h *ProductAdminHandler) GetAdminApiCalls(c *gin.Context) {
|
||||
filters["status"] = status
|
||||
}
|
||||
|
||||
// 时间范围筛选
|
||||
// 时间范围筛选 - 增强错误处理和日志
|
||||
if startTime := c.Query("start_time"); startTime != "" {
|
||||
// 处理URL编码的+号,转换为空格
|
||||
startTime = strings.ReplaceAll(startTime, "+", " ")
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", startTime); err == nil {
|
||||
filters["start_time"] = t
|
||||
h.logger.Debug("解析start_time成功", zap.String("原始值", c.Query("start_time")), zap.Time("解析后", t))
|
||||
} else {
|
||||
// 尝试其他格式(ISO格式)
|
||||
if t, err := time.Parse("2006-01-02T15:04:05", startTime); err == nil {
|
||||
filters["start_time"] = t
|
||||
h.logger.Debug("解析start_time成功(ISO格式)", zap.String("原始值", c.Query("start_time")), zap.Time("解析后", t))
|
||||
} else {
|
||||
h.logger.Warn("解析start_time失败", zap.String("原始值", c.Query("start_time")), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if endTime := c.Query("end_time"); endTime != "" {
|
||||
// 处理URL编码的+号,转换为空格
|
||||
endTime = strings.ReplaceAll(endTime, "+", " ")
|
||||
if t, err := time.Parse("2006-01-02 15:04:05", endTime); err == nil {
|
||||
filters["end_time"] = t
|
||||
h.logger.Debug("解析end_time成功", zap.String("原始值", c.Query("end_time")), zap.Time("解析后", t))
|
||||
} else {
|
||||
// 尝试其他格式(ISO格式)
|
||||
if t, err := time.Parse("2006-01-02T15:04:05", endTime); err == nil {
|
||||
filters["end_time"] = t
|
||||
h.logger.Debug("解析end_time成功(ISO格式)", zap.String("原始值", c.Query("end_time")), zap.Time("解析后", t))
|
||||
} else {
|
||||
h.logger.Warn("解析end_time失败", zap.String("原始值", c.Query("end_time")), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1566,7 +1634,16 @@ func (h *ProductAdminHandler) ExportAdminApiCalls(c *gin.Context) {
|
||||
fileData, err := h.apiAppService.ExportAdminApiCalls(c.Request.Context(), filters, format)
|
||||
if err != nil {
|
||||
h.logger.Error("导出API调用记录失败", zap.Error(err))
|
||||
h.responseBuilder.BadRequest(c, "导出API调用记录失败")
|
||||
|
||||
// 根据错误信息返回具体的提示
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "没有找到符合条件的数据") || strings.Contains(errMsg, "没有数据") {
|
||||
h.responseBuilder.NotFound(c, "没有找到符合筛选条件的API调用记录,请调整筛选条件后重试")
|
||||
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
|
||||
h.responseBuilder.BadRequest(c, errMsg)
|
||||
} else {
|
||||
h.responseBuilder.BadRequest(c, "导出API调用记录失败:"+errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -640,7 +644,7 @@ func (h *ProductHandler) CancelMySubscription(c *gin.Context) {
|
||||
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, "订阅不存在")
|
||||
@@ -742,6 +746,7 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
|
||||
|
||||
// 将响应类型转换为entity类型
|
||||
var docEntity *entities.ProductDocumentation
|
||||
var docVersion string
|
||||
if doc != nil {
|
||||
docEntity = &entities.ProductDocumentation{
|
||||
ID: doc.ID,
|
||||
@@ -755,12 +760,114 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
|
||||
ErrorCodes: doc.ErrorCodes,
|
||||
Version: doc.Version,
|
||||
}
|
||||
docVersion = doc.Version
|
||||
} else {
|
||||
// 如果没有文档,使用默认版本号
|
||||
docVersion = "1.0"
|
||||
}
|
||||
|
||||
// 使用数据库数据生成PDF
|
||||
h.logger.Info("准备调用PDF生成器",
|
||||
// 如果是组合包,获取子产品的文档信息
|
||||
var subProductDocs []*entities.ProductDocumentation
|
||||
if product.IsPackage && len(product.PackageItems) > 0 {
|
||||
h.logger.Info("检测到组合包,开始获取子产品文档",
|
||||
zap.String("product_id", productID),
|
||||
zap.Int("sub_product_count", len(product.PackageItems)),
|
||||
)
|
||||
|
||||
// 收集所有子产品的ID
|
||||
subProductIDs := make([]string, 0, len(product.PackageItems))
|
||||
for _, item := range product.PackageItems {
|
||||
subProductIDs = append(subProductIDs, item.ProductID)
|
||||
}
|
||||
|
||||
// 批量获取子产品的文档
|
||||
subDocs, err := h.documentationAppService.GetDocumentationsByProductIDs(c.Request.Context(), subProductIDs)
|
||||
if err != nil {
|
||||
h.logger.Warn("获取组合包子产品文档失败",
|
||||
zap.String("product_id", productID),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
// 转换为entity类型,并按PackageItems的顺序排序
|
||||
docMap := make(map[string]*entities.ProductDocumentation)
|
||||
for i := range subDocs {
|
||||
docMap[subDocs[i].ProductID] = &entities.ProductDocumentation{
|
||||
ID: subDocs[i].ID,
|
||||
ProductID: subDocs[i].ProductID,
|
||||
RequestURL: subDocs[i].RequestURL,
|
||||
RequestMethod: subDocs[i].RequestMethod,
|
||||
BasicInfo: subDocs[i].BasicInfo,
|
||||
RequestParams: subDocs[i].RequestParams,
|
||||
ResponseFields: subDocs[i].ResponseFields,
|
||||
ResponseExample: subDocs[i].ResponseExample,
|
||||
ErrorCodes: subDocs[i].ErrorCodes,
|
||||
Version: subDocs[i].Version,
|
||||
}
|
||||
}
|
||||
|
||||
// 按PackageItems的顺序构建子产品文档列表
|
||||
for _, item := range product.PackageItems {
|
||||
if subDoc, exists := docMap[item.ProductID]; exists {
|
||||
subProductDocs = append(subProductDocs, subDoc)
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("成功获取组合包子产品文档",
|
||||
zap.String("product_id", productID),
|
||||
zap.Int("total_sub_products", len(product.PackageItems)),
|
||||
zap.Int("docs_found", len(subProductDocs)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从缓存获取PDF
|
||||
var pdfBytes []byte
|
||||
var cacheHit bool
|
||||
|
||||
if h.pdfCacheManager != nil {
|
||||
var cacheErr error
|
||||
pdfBytes, cacheHit, cacheErr = h.pdfCacheManager.Get(productID, docVersion)
|
||||
if cacheErr != nil {
|
||||
h.logger.Warn("从缓存获取PDF失败,将重新生成",
|
||||
zap.String("product_id", productID),
|
||||
zap.Error(cacheErr),
|
||||
)
|
||||
} else if cacheHit {
|
||||
h.logger.Info("PDF缓存命中",
|
||||
zap.String("product_id", productID),
|
||||
zap.String("version", docVersion),
|
||||
zap.Int("pdf_size", len(pdfBytes)),
|
||||
)
|
||||
// 直接返回缓存的PDF
|
||||
fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
|
||||
if product.Name == "" {
|
||||
fileName = fmt.Sprintf("%s_接口文档.pdf", product.Code)
|
||||
}
|
||||
// 清理文件名中的非法字符
|
||||
fileName = strings.ReplaceAll(fileName, "/", "_")
|
||||
fileName = strings.ReplaceAll(fileName, "\\", "_")
|
||||
fileName = strings.ReplaceAll(fileName, ":", "_")
|
||||
fileName = strings.ReplaceAll(fileName, "*", "_")
|
||||
fileName = strings.ReplaceAll(fileName, "?", "_")
|
||||
fileName = strings.ReplaceAll(fileName, "\"", "_")
|
||||
fileName = strings.ReplaceAll(fileName, "<", "_")
|
||||
fileName = strings.ReplaceAll(fileName, ">", "_")
|
||||
fileName = strings.ReplaceAll(fileName, "|", "_")
|
||||
|
||||
c.Header("Content-Type", "application/pdf")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
|
||||
c.Header("X-Cache", "HIT") // 添加缓存命中标识
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存未命中,需要生成PDF
|
||||
h.logger.Info("PDF缓存未命中,开始生成PDF",
|
||||
zap.String("product_id", productID),
|
||||
zap.String("product_name", product.Name),
|
||||
zap.String("version", docVersion),
|
||||
zap.Bool("has_doc", docEntity != nil),
|
||||
)
|
||||
|
||||
@@ -777,18 +884,61 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
// 构建Product实体(用于PDF生成)
|
||||
productEntity := &entities.Product{
|
||||
ID: product.ID,
|
||||
Name: product.Name,
|
||||
Code: product.Code,
|
||||
Description: product.Description,
|
||||
Content: product.Content,
|
||||
IsPackage: product.IsPackage,
|
||||
Price: decimal.NewFromFloat(product.Price),
|
||||
}
|
||||
|
||||
// 如果是组合包,添加子产品信息
|
||||
if product.IsPackage && len(product.PackageItems) > 0 {
|
||||
productEntity.PackageItems = make([]*entities.ProductPackageItem, len(product.PackageItems))
|
||||
for i, item := range product.PackageItems {
|
||||
productEntity.PackageItems[i] = &entities.ProductPackageItem{
|
||||
ID: item.ID,
|
||||
PackageID: product.ID,
|
||||
ProductID: item.ProductID,
|
||||
SortOrder: item.SortOrder,
|
||||
Product: &entities.Product{
|
||||
ID: item.ProductID,
|
||||
Code: item.ProductCode,
|
||||
Name: item.ProductName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接调用PDF生成器(简化版本,不使用goroutine)
|
||||
h.logger.Info("开始调用PDF生成器")
|
||||
pdfBytes, genErr := h.pdfGenerator.GenerateProductPDF(
|
||||
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),
|
||||
@@ -819,6 +969,19 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到缓存(异步,不阻塞响应)
|
||||
if h.pdfCacheManager != nil {
|
||||
go func() {
|
||||
if err := h.pdfCacheManager.Set(productID, docVersion, pdfBytes); err != nil {
|
||||
h.logger.Warn("保存PDF到缓存失败",
|
||||
zap.String("product_id", productID),
|
||||
zap.String("version", docVersion),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 生成文件名(清理文件名中的非法字符)
|
||||
fileName := fmt.Sprintf("%s_接口文档.pdf", product.Name)
|
||||
if product.Name == "" {
|
||||
@@ -840,11 +1003,13 @@ func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
|
||||
zap.String("product_code", product.Code),
|
||||
zap.String("file_name", fileName),
|
||||
zap.Int("file_size", len(pdfBytes)),
|
||||
zap.Bool("cached", false),
|
||||
)
|
||||
|
||||
// 设置响应头并返回PDF文件
|
||||
c.Header("Content-Type", "application/pdf")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
|
||||
c.Header("X-Cache", "MISS") // 添加缓存未命中标识
|
||||
c.Data(http.StatusOK, "application/pdf", pdfBytes)
|
||||
}
|
||||
|
||||
73
internal/infrastructure/http/routes/announcement_routes.go
Normal file
73
internal/infrastructure/http/routes/announcement_routes.go
Normal 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("公告路由注册完成")
|
||||
}
|
||||
@@ -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) // 下载发票文件
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
25
internal/shared/payment/context.go
Normal file
25
internal/shared/payment/context.go
Normal 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
|
||||
}
|
||||
48
internal/shared/payment/user_auth_model.go
Normal file
48
internal/shared/payment/user_auth_model.go
Normal 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未实现,请注入真实的实现")
|
||||
}
|
||||
7
internal/shared/payment/utils.go
Normal file
7
internal/shared/payment/utils.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package payment
|
||||
|
||||
// ToWechatAmount 将金额转换为微信支付金额(单位:分)
|
||||
// 微信支付金额以分为单位,需要将元转换为分
|
||||
func ToWechatAmount(amount float64) int64 {
|
||||
return int64(amount * 100)
|
||||
}
|
||||
353
internal/shared/payment/wechatpay.go
Normal file
353
internal/shared/payment/wechatpay.go
Normal 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
|
||||
}
|
||||
@@ -126,11 +126,11 @@ func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath, fontName string) b
|
||||
absFontPath = newAbsPath
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 使用filepath.ToSlash统一路径分隔符(Linux下使用/)
|
||||
// 注意:ToSlash不会改变路径的绝对/相对性质,只统一分隔符
|
||||
normalizedPath := filepath.ToSlash(absFontPath)
|
||||
|
||||
|
||||
// 在Linux下,绝对路径必须以/开头
|
||||
// 如果normalizedPath不是以/开头,说明转换有问题
|
||||
if len(normalizedPath) == 0 || normalizedPath[0] != '/' {
|
||||
@@ -166,14 +166,14 @@ func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath, fontName string) b
|
||||
)
|
||||
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) // 粗体样式
|
||||
|
||||
@@ -223,7 +223,6 @@ func (fm *FontManager) getWatermarkFontPaths() []string {
|
||||
return fm.buildFontPaths(fontNames)
|
||||
}
|
||||
|
||||
|
||||
// buildFontPaths 构建字体文件路径列表(仅从resources/pdf/fonts加载)
|
||||
// 返回所有存在的字体文件的绝对路径
|
||||
func (fm *FontManager) buildFontPaths(fontNames []string) []string {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -298,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 添加章节
|
||||
@@ -829,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, "")
|
||||
}
|
||||
|
||||
475
internal/shared/pdf/pdf_cache_manager.go
Normal file
475
internal/shared/pdf/pdf_cache_manager.go
Normal 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))
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
@@ -90,16 +90,21 @@ func (g *PDFGeneratorRefactored) GenerateProductPDF(ctx context.Context, product
|
||||
product.Price = decimal.NewFromFloat(price)
|
||||
}
|
||||
|
||||
return g.generatePDF(product, doc)
|
||||
return g.generatePDF(product, doc, nil)
|
||||
}
|
||||
|
||||
// GenerateProductPDFFromEntity 从entity类型生成PDF(推荐使用)
|
||||
func (g *PDFGeneratorRefactored) GenerateProductPDFFromEntity(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation) ([]byte, error) {
|
||||
return g.generatePDF(product, doc)
|
||||
return g.generatePDF(product, doc, nil)
|
||||
}
|
||||
|
||||
// GenerateProductPDFWithSubProducts 从entity类型生成PDF,支持组合包子产品文档
|
||||
func (g *PDFGeneratorRefactored) GenerateProductPDFWithSubProducts(ctx context.Context, product *entities.Product, doc *entities.ProductDocumentation, subProductDocs []*entities.ProductDocumentation) ([]byte, error) {
|
||||
return g.generatePDF(product, doc, subProductDocs)
|
||||
}
|
||||
|
||||
// generatePDF 内部PDF生成方法
|
||||
func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *entities.ProductDocumentation) (result []byte, err error) {
|
||||
func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *entities.ProductDocumentation, subProductDocs []*entities.ProductDocumentation) (result []byte, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// 将panic转换为error,而不是重新抛出
|
||||
@@ -114,7 +119,7 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
|
||||
// 保存当前工作目录(用于后续恢复)
|
||||
originalWorkDir, _ := os.Getwd()
|
||||
|
||||
|
||||
// 关键修复:gofpdf在AddUTF8Font和Output时都会处理字体路径
|
||||
// 如果路径是绝对路径 /app/resources/pdf/fonts/simhei.ttf,gofpdf会去掉开头的/
|
||||
// 变成相对路径 app/resources/pdf/fonts/simhei.ttf
|
||||
@@ -154,7 +159,14 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
chineseFontAvailable := g.fontManager.LoadChineseFont(pdf)
|
||||
|
||||
// 加载水印字体(使用宋体或其他非黑体字体)
|
||||
g.fontManager.LoadWatermarkFont(pdf)
|
||||
watermarkFontAvailable := g.fontManager.LoadWatermarkFont(pdf)
|
||||
|
||||
// 记录字体加载状态,便于诊断问题
|
||||
g.logger.Info("PDF字体加载状态",
|
||||
zap.Bool("chinese_font_loaded", chineseFontAvailable),
|
||||
zap.Bool("watermark_font_loaded", watermarkFontAvailable),
|
||||
zap.String("watermark_text", g.watermarkText),
|
||||
)
|
||||
|
||||
// 设置文档信息
|
||||
pdf.SetTitle("Product Documentation", true)
|
||||
@@ -167,9 +179,55 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
// 添加第一页(产品信息)
|
||||
pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable)
|
||||
|
||||
// 如果有关联的文档,添加接口文档页面
|
||||
if doc != nil {
|
||||
pageBuilder.AddDocumentationPages(pdf, doc, chineseFontAvailable)
|
||||
// 如果是组合包,需要特殊处理:先渲染所有文档,最后统一添加二维码
|
||||
if product.IsPackage {
|
||||
// 如果有关联的文档,添加接口文档页面(但不包含二维码和说明,后面统一添加)
|
||||
if doc != nil {
|
||||
pageBuilder.AddDocumentationPagesWithoutAdditionalInfo(pdf, doc, chineseFontAvailable)
|
||||
}
|
||||
|
||||
// 如果有子产品文档,为每个子产品添加接口文档页面
|
||||
if len(subProductDocs) > 0 {
|
||||
for i, subDoc := range subProductDocs {
|
||||
// 获取子产品信息(从文档中获取ProductID,然后查找对应的产品信息)
|
||||
// 注意:这里我们需要从product.PackageItems中查找对应的子产品信息
|
||||
var subProduct *entities.Product
|
||||
if product.PackageItems != nil && i < len(product.PackageItems) {
|
||||
if product.PackageItems[i].Product != nil {
|
||||
subProduct = product.PackageItems[i].Product
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到子产品信息,创建一个基本的子产品实体
|
||||
if subProduct == nil {
|
||||
subProduct = &entities.Product{
|
||||
ID: subDoc.ProductID,
|
||||
Code: subDoc.ProductID, // 使用ProductID作为临时Code
|
||||
Name: fmt.Sprintf("子产品 %d", i+1),
|
||||
}
|
||||
}
|
||||
|
||||
pageBuilder.AddSubProductDocumentationPages(pdf, subProduct, subDoc, chineseFontAvailable, false)
|
||||
}
|
||||
}
|
||||
|
||||
// 在所有接口文档渲染完成后,统一添加二维码和后勤服务说明
|
||||
// 使用主产品文档(如果存在),否则使用第一个子产品文档
|
||||
var finalDoc *entities.ProductDocumentation
|
||||
if doc != nil {
|
||||
finalDoc = doc
|
||||
} else if len(subProductDocs) > 0 {
|
||||
finalDoc = subProductDocs[0]
|
||||
}
|
||||
|
||||
if finalDoc != nil {
|
||||
pageBuilder.AddAdditionalInfo(pdf, finalDoc, chineseFontAvailable)
|
||||
}
|
||||
} else {
|
||||
// 普通产品:使用原来的方法(包含二维码和说明)
|
||||
if doc != nil {
|
||||
pageBuilder.AddDocumentationPages(pdf, doc, chineseFontAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成PDF字节流
|
||||
@@ -177,7 +235,7 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
// 这样gofpdf在Output时使用相对路径 app/resources/pdf/fonts 就能正确解析
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
|
||||
// 在Output前验证字体文件路径(此时工作目录应该是根目录/)
|
||||
if workDir, err := os.Getwd(); err == nil {
|
||||
// 验证绝对路径
|
||||
@@ -188,7 +246,7 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
zap.String("work_dir", workDir),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// 验证相对路径(gofpdf可能使用的路径)
|
||||
fontRelPath := "app/resources/pdf/fonts/simhei.ttf"
|
||||
if relAbsPath, err := filepath.Abs(fontRelPath); err == nil {
|
||||
@@ -207,14 +265,14 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
g.logger.Debug("准备生成PDF",
|
||||
zap.String("work_dir", workDir),
|
||||
zap.String("resources_pdf_dir", resourcesDir),
|
||||
zap.Bool("work_dir_changed", workDirChanged),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
err = pdf.Output(&buf)
|
||||
if err != nil {
|
||||
// 记录详细的错误信息
|
||||
@@ -222,7 +280,7 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent
|
||||
if wd, e := os.Getwd(); e == nil {
|
||||
currentWorkDir = wd
|
||||
}
|
||||
|
||||
|
||||
// 尝试分析错误:如果是路径问题,记录更多信息
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "stat ") && strings.Contains(errStr, ": no such file") {
|
||||
|
||||
BIN
resources/etc/wxetc_cert/apiclient_cert.p12
Normal file
BIN
resources/etc/wxetc_cert/apiclient_cert.p12
Normal file
Binary file not shown.
25
resources/etc/wxetc_cert/apiclient_cert.pem
Normal file
25
resources/etc/wxetc_cert/apiclient_cert.pem
Normal file
@@ -0,0 +1,25 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEJDCCAwygAwIBAgIUH06LPDnGADXUzBVPJ20D2cwsYD0wDQYJKoZIhvcNAQEL
|
||||
BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT
|
||||
FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg
|
||||
Q0EwHhcNMjUxMjExMDYxMjQ4WhcNMzAxMjEwMDYxMjQ4WjB+MRMwEQYDVQQDDAox
|
||||
NjgzNTg5MTc2MRswGQYDVQQKDBLlvq7kv6HllYbmiLfns7vnu58xKjAoBgNVBAsM
|
||||
Iea1t+WNl+a1t+Wuh+Wkp+aVsOaNruaciemZkOWFrOWPuDELMAkGA1UEBhMCQ04x
|
||||
ETAPBgNVBAcMCFNoZW5aaGVuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
|
||||
AQEAn7zhFOO7aFq0Zd5L0xf+rnhJl3ELFhhSDgHTo2wk9f1K7U0PWsdu6RWjtQiu
|
||||
MS6u4gOPtYXgVAAue37KzyTs9nWfdOFpm9Q/CI/lLfyFs9/JV61sDO18+t4apr0D
|
||||
ML0enRxrzE6dqlgBdjm7FGcfWLOnVcnBSbxskp2vSji230HFcBDOwVTlELApoDzJ
|
||||
6zkfaoKfKJkhk1b+ZHB70ikyRg0f8z+qeNyFkmJecPzRXGn6QlrXldX0Or10ZMss
|
||||
HBMuDDqCihl0mom20phRbUgLVj7/dlRSslrhQfh0MD9Mn55g8dok4YV68s+hZpIC
|
||||
l0EfzCGCvppDvGnkVFcYLwoDdwIDAQABo4G5MIG2MAkGA1UdEwQCMAAwCwYDVR0P
|
||||
BAQDAgP4MIGbBgNVHR8EgZMwgZAwgY2ggYqggYeGgYRodHRwOi8vZXZjYS5pdHJ1
|
||||
cy5jb20uY24vcHVibGljL2l0cnVzY3JsP0NBPTFCRDQyMjBFNTBEQkMwNEIwNkFE
|
||||
Mzk3NTQ5ODQ2QzAxQzNFOEVCRDImc2c9SEFDQzQ3MUI2NTQyMkUxMkIyN0E5RDMz
|
||||
QTg3QUQxQ0RGNTkyNkUxNDAzNzEwDQYJKoZIhvcNAQELBQADggEBAKzb7i8F/jJ3
|
||||
yDUphme5IpOl14HXYWwuIqWMnD2Sk8YemMcjAEvxFMvXR5WmwWymnfcYhrQWYBn6
|
||||
iWMzfT2hovOo+DBUjn01XTzzWGAS0WwOJ5ewwFIvyW5BYODvqBcWd1dF9pCXhpH6
|
||||
fk0dUKi6t9PbErLEtqf3CDSsM9muh8Lb81ks80VfHz/IV24Su2ZKShJJIMbqK+cW
|
||||
UqrBMnwpd9CqrzkKb4RPll3wRyG7CZ/DMfWXx7uz3UDULSlaRIfNFw2v/w4WSX3H
|
||||
1Sy1MzDERvfq3CjWXGwtuI7OQE1AWxdH+FEik8dKm81U8yR/bX+rPjjFM4CJg3MD
|
||||
M8N+ymic4rs=
|
||||
-----END CERTIFICATE-----
|
||||
28
resources/etc/wxetc_cert/apiclient_key.pem
Normal file
28
resources/etc/wxetc_cert/apiclient_key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfvOEU47toWrRl
|
||||
3kvTF/6ueEmXcQsWGFIOAdOjbCT1/UrtTQ9ax27pFaO1CK4xLq7iA4+1heBUAC57
|
||||
fsrPJOz2dZ904Wmb1D8Ij+Ut/IWz38lXrWwM7Xz63hqmvQMwvR6dHGvMTp2qWAF2
|
||||
ObsUZx9Ys6dVycFJvGySna9KOLbfQcVwEM7BVOUQsCmgPMnrOR9qgp8omSGTVv5k
|
||||
cHvSKTJGDR/zP6p43IWSYl5w/NFcafpCWteV1fQ6vXRkyywcEy4MOoKKGXSaibbS
|
||||
mFFtSAtWPv92VFKyWuFB+HQwP0yfnmDx2iThhXryz6FmkgKXQR/MIYK+mkO8aeRU
|
||||
VxgvCgN3AgMBAAECggEAP6qfp5zREFm+ty9v11Yj+1QUONkkiwzsf4q42NT8slLf
|
||||
b0+chBkjGqG2Wyx3iUDLEWhL+hS/AZwE6tHxcbiM/fqJsKM7XZGuAfKgbMDOZZAX
|
||||
huunOkvZ2X927eg+AkoOjp5KVOcsrj1fb8i4yPwFIWyRkH7WnFYOjC1vNUz/jmHe
|
||||
ZHos/T+ZGOrP/Q9fpzyCKKtDwC0oMpx1l6hsQjU14MNbWIgc/eiWmnyAbUe5PmS3
|
||||
M5Aj2xFBoFCiRS95P8lG2d/0rdq2XmNh1L1MqqEJ0uc5iAAma2FTjpVbbey3N1hM
|
||||
csfq/s2olPExO8v13W4UJDFBPwTvCcAC1JPyb6WoGQKBgQDLwARt3N3rdo61GZSo
|
||||
HF9vUHRJ3+7OkF5mTYV0+y4LyKYTxa8GiyOrCD9XQbRnfcGG74hK02HNzyPDdbD/
|
||||
XDBmr3DxHx3hG7wmrajkLr0+Pum7ajjaqiC990bneBhof5odz28PPo/Vkk66QKJD
|
||||
RWucTloHdZosQBPLAMENtmLNUwKBgQDIs4CbvZSKNDw9sXZFC3cSKg5eREGIftVt
|
||||
gUiBT5yBcu7pVA6aAp73JYsDPzyWxlLbQ+6dT4gMVeE6uLs5DnYiLDzEm6X8XrVp
|
||||
kXIS5M+xzBWCTtUgUmZtWHbTH6nxTmNFTzQEd/9TPhYTRTVJF4V3jTYRDevBSwJ8
|
||||
HDcX1VsIzQKBgQC2GXab7hOVV4+yAhvfqAQPi7tzLyXTDiqgilZlt/xuYbU05LBK
|
||||
S97kBGoABWREPpvRipGoNoYqGCChl7VKdU146QIrLqFYyJ3/f6P71F4knLvvWb9Y
|
||||
h0beIXwIckh2VN0cGYHsAQEyYyHjytJ7BzdnKovCMPRK6jYGcDUamVByqwKBgE1V
|
||||
xZe9XFBIwnGvQPhn65DHPdQbDvlujgTtDSguqgrDY8XqytmTavemssMkic87SlAN
|
||||
BBP/wleme+wppJLevKx8SUolA7eUMukjz0Xyfwlur1cP01XqCmfV76t4hv5hiyT4
|
||||
2P1j07GaudzhDSBF/PrNIek+aPqJUcLLCHuZjcN9AoGAfpWmZ1PivWZ3K99nWj3H
|
||||
u0P7mgENWAuuOXCoVMJ+42Ce8siBsCovkZJynbVhd1TYqto6F15KvwdOjLKKucDx
|
||||
3K5yACAL9fxbBqO+gel2t6Lkd145kwLly3ChJxF9Y+GfxkrQC5XedHENmb+20Ryb
|
||||
qc7u6TBrGPF1ceeEK3HBvzw=
|
||||
-----END PRIVATE KEY-----
|
||||
9
resources/etc/wxetc_cert/pub_key.pem
Normal file
9
resources/etc/wxetc_cert/pub_key.pem
Normal file
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArw9V+Nc7LZ/2Sul64PWT
|
||||
rIpnWKAILD5Mt+lStWBm48sWxGsDDXcZVlp8Pk58Otrxl/d1yuGOWDa3WAp6W1cs
|
||||
xWnx4jfG5V9sh/xWWEMnGTnOYC+KwtOADFLqIXPbkNeieDjaIxoVyDQEQFxIjN6W
|
||||
lNdHbA0iWH8rqzFPtLwlP1U4X/xXpZvN/vwfEbuC/+tDhMROYbi1uGCEoYVpT8i4
|
||||
cd6UfO46CG40VuT2V+ZWGC0Ulu5dxjG/MSmIwhFhSoaF8Ec9wxR+yumTUhRG4Ahv
|
||||
ZRBylfZrJFk95LYWVEXf7dbJvbc5wYpWTOH4k3A4Nvo5ILzN4KQoA5WoULLCHUeu
|
||||
vQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
18
resources/etc/wxetc_cert/璇佷功浣跨敤璇存槑.txt
Normal file
18
resources/etc/wxetc_cert/璇佷功浣跨敤璇存槑.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
欢迎使用微信支付!
|
||||
附件中的三份文件(证书pkcs12格式、证书pem格式、证书密钥pem格式),为接口中强制要求时需携带的证书文件。
|
||||
证书属于敏感信息,请妥善保管不要泄露和被他人复制。
|
||||
不同开发语言下的证书格式不同,以下为说明指引:
|
||||
证书pkcs12格式(apiclient_cert.p12)
|
||||
包含了私钥信息的证书文件,为p12(pfx)格式,由微信支付签发给您用来标识和界定您的身份
|
||||
部分安全性要求较高的API需要使用该证书来确认您的调用身份
|
||||
windows上可以直接双击导入系统,导入过程中会提示输入证书密码,证书密码默认为您的商户号(如:1900006031)
|
||||
证书pem格式(apiclient_cert.pem)
|
||||
从apiclient_cert.p12中导出证书部分的文件,为pem格式,请妥善保管不要泄漏和被他人复制
|
||||
部分开发语言和环境,不能直接使用p12文件,而需要使用pem,所以为了方便您使用,已为您直接提供
|
||||
您也可以使用openssl命令来自己导出:openssl pkcs12 -clcerts -nokeys -in apiclient_cert.p12 -out apiclient_cert.pem
|
||||
证书密钥pem格式(apiclient_key.pem)
|
||||
从apiclient_cert.p12中导出密钥部分的文件,为pem格式
|
||||
部分开发语言和环境,不能直接使用p12文件,而需要使用pem,所以为了方便您使用,已为您直接提供
|
||||
您也可以使用openssl命令来自己导出:openssl pkcs12 -nocerts -in apiclient_cert.p12 -out apiclient_key.pem
|
||||
备注说明:
|
||||
由于绝大部分操作系统已内置了微信支付服务器证书的根CA证书, 2018年3月6日后, 不再提供CA证书文件(rootca.pem)下载
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 1.3 MiB |
27
resources/pdf/后勤服务.txt
Normal file
27
resources/pdf/后勤服务.txt
Normal file
@@ -0,0 +1,27 @@
|
||||
天远数据安全测试接入流程说明
|
||||
若您希望接入天远数据的安全测试服务,可按照以下详细流程进行操作:
|
||||
|
||||
1. 联系商务了解接入流程
|
||||
请您首先与天远数据的商务团队取得联系,深入了解安全测试接入的具体流程、要求以及相关注意事项。您可以通过以下方式联系我们的商务人员:
|
||||
|
||||
商务邮箱:jiaowuzhe@aitoolpath.com
|
||||
|
||||
商务联系电话:13876051080 微信同号
|
||||
|
||||
获得更多详情请访问 [https://www.tianyuanapi.com/]
|
||||
|
||||
2. 提供正式生产环境公网 IP
|
||||
在与商务团队沟通并了解清楚接入流程后,请您将正式生产环境的公网 IP 提供给天远数据。我们将依据您提供的公网 IP 进行 IP 访问设置,以确保后续接口调用的顺利进行。
|
||||
|
||||
3. 构造并加密请求报文
|
||||
您需要构造 JSON 明文请求报文,然后使用 AES-128 算法(基于账户获得的16进制字符串密钥/Access Key)对该明文请求报文进行加密处理。加密时采用AES-CBC模式(密钥长度128位/16字节,填充方式PKCS7),每次加密随机生成16字节(128位)的IV(初始化向量),将IV与加密后的密文拼接在一起,最后通过Base64编码形成可传输的字符串,并将该Base64字符串放入请求体的data字段传参。此步骤中涉及的代码部分,您可参考我们提供的demo包,里面有详细的示例和说明,能帮助您顺利完成报文的构造、加密及Base64编码操作。
|
||||
|
||||
4. 调用接口获取返回结果
|
||||
完成请求报文的构造、加密及Base64编码后,您可以使用处理好的报文(即包含Base64编码数据的数据体)调用天远数据的接口。调用接口后,您将获得相应的返回结果(该返回结果为经过Base64编码且拼接了IV的密文数据)。
|
||||
|
||||
5. 解密获得明文结果
|
||||
当您获得接口返回的结果后,需要先对Base64解码后的数据提取前16字节作为IV,再使用该IV通过AES-CBC模式解密剩余密文,最后去除PKCS7填充得到原始明文。同样,关于Base64解码及AES解密(含IV提取、填充去除)的代码实现,您可参考test包中的相关内容,以顺利完成返回结果的解密操作。
|
||||
|
||||
|
||||
若您在接入过程中有任何疑问或需要进一步的帮助,请随时与我们联系。您可以通过上述的商务邮箱和商务联系电话与我们的团队沟通,我们将竭诚为您服务。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user