Compare commits

..

52 Commits

Author SHA1 Message Date
451d869361 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-16 14:32:42 +08:00
08ea153cac uplogo 2025-12-16 14:32:41 +08:00
6147878dfe Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-14 21:38:49 +08:00
be47a0f045 fix 2025-12-14 21:38:01 +08:00
810696e0f0 fix 2025-12-13 15:09:55 +08:00
17dbaf1ccb fix 2025-12-12 15:47:20 +08:00
18f3d10518 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-12 15:27:17 +08:00
0d4953c6d3 微信支付 2025-12-12 15:27:15 +08:00
3f64600f02 change 2025-12-11 19:32:46 +08:00
2c89b8cb26 fix组合包文档 2025-12-11 11:14:31 +08:00
09d7a4f076 fix_step null false 2025-12-10 17:51:28 +08:00
83d0fd6587 fix身份证最后一个字母提醒 2025-12-10 14:17:33 +08:00
0fd28054f1 fix 2025-12-10 10:32:33 +08:00
ce858983ee 503接口问题 2025-12-09 18:32:29 +08:00
9b2bffae15 移动端弹幕优化 2025-12-09 16:42:37 +08:00
c68ece5bea 企业五要素 2025-12-09 15:12:03 +08:00
398d2cee74 企业五要素 2025-12-09 14:42:32 +08:00
b6c8d93af5 1 2025-12-09 10:48:13 +08:00
b423aa6be8 2 2025-12-09 10:25:26 +08:00
a47c306c87 18278715334@163.com 2025-12-08 14:03:14 +08:00
af88bcc8eb 18278715334@163.com 2025-12-06 15:46:46 +08:00
89367fb2ee 18278715334@163.com 2025-12-06 13:56:00 +08:00
05b6623e75 18278715334@163.com 2025-12-05 14:59:23 +08:00
bfedec249f 18278715334@163.com 2025-12-04 18:10:14 +08:00
9f669a9c94 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-04 16:19:20 +08:00
0f5c4f4303 18278715334@163.com 2025-12-04 16:17:27 +08:00
d9c2d9f103 18278715334@163.com 2025-12-04 16:17:04 +08:00
7e0d58b295 change esign template 2025-12-04 15:11:25 +08:00
a17ff2140e fix 2025-12-04 14:26:56 +08:00
6a2241bc66 fix 2025-12-04 14:21:58 +08:00
e57bef6609 fix 2025-12-04 14:00:22 +08:00
81639a81e6 fix 2025-12-04 13:42:32 +08:00
aaf17321ff fix 2025-12-04 13:28:03 +08:00
a8a4ff2d37 fix 2025-12-04 13:20:03 +08:00
619deeb456 fix pdf path 2025-12-04 13:09:59 +08:00
f12c3fb8ad fix pdf 2025-12-04 12:56:39 +08:00
4ce8fe4023 18278715334@163.com 2025-12-04 12:30:33 +08:00
7b45b43a0e 2 2025-12-04 10:47:58 +08:00
752b90b048 1 2025-12-04 10:41:07 +08:00
68def7e08b 18287815334@163.com 2025-12-04 10:35:11 +08:00
b0e8974d6c 将字体文件添加到 .gitignore,不再进行版本控制 2025-12-04 10:27:31 +08:00
b41d41ddf3 18278781533@163.com 2025-12-03 18:25:04 +08:00
b08a63fc99 18278715334@163.com 2025-12-03 18:02:49 +08:00
1f06f21faf 18278715334@163.com 2025-12-03 16:53:31 +08:00
3f5a126bfa 18278715334@163.com 2025-12-03 16:53:19 +08:00
17ff48a642 18278715334@163.com 2025-12-03 15:53:31 +08:00
af629e96c2 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-03 12:03:47 +08:00
63252fa30f 18278715334@163.cmo 2025-12-03 12:03:42 +08:00
1cf64e831c 18278715334@163.com 2025-12-03 12:02:47 +08:00
577c2bc581 Merge branch 'main' of http://1.117.67.95:3000/team/tyapi-server 2025-12-02 12:24:22 +08:00
6d73dad88e fix document version 2025-12-02 12:24:09 +08:00
937c812ea5 abc 2025-12-01 18:35:17 +08:00
134 changed files with 15221 additions and 620 deletions

6
.gitignore vendored
View File

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

View File

@@ -50,9 +50,11 @@ WORKDIR /app
COPY --from=builder /app/tyapi-server .
# 复制配置文件
COPY --chown=tyapi:tyapi config.yaml .
COPY --chown=tyapi:tyapi configs/ ./configs/
COPY config.yaml .
COPY configs/ ./configs/
# 复制资源文件(直接从构建上下文复制,与配置文件一致)
COPY resources ./resources
# 暴露端口
EXPOSE 8080

View File

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

View File

@@ -243,7 +243,7 @@ esign:
app_id: "7439073138"
app_secret: "d76e27fdd169b391e09262a0959dac5c"
server_url: "https://smlopenapi.esign.cn"
template_id: "1fd7ed9c6d134d1db7b5af9582633d76"
template_id: "9f7a3f63cc5a48b085b127ba027d234d"
contract:
name: "天远数据API合作协议"
expire_days: 7
@@ -362,6 +362,28 @@ alipay:
notify_url: "https://console.tianyuanapi.com/api/v1/finance/alipay/callback"
return_url: "https://console.tianyuanapi.com/api/v1/finance/alipay/return"
# ===========================================
# 💰 微信支付配置
# ===========================================
Wxpay:
app_id: "wxa581992dc74d860e"
mch_id: "1683589176"
mch_certificate_serial_number: "1F4E8B3C39C60035D4CC154F276D03D9CC2C603D"
mch_apiv3_key: "TY8X9nP2qR5tY7uW3zA6bC4dE1flgGJ0"
mch_private_key_path: "resources/etc/wxetc_cert/apiclient_key.pem"
mch_public_key_id: "PUB_KEY_ID_0116835891762025062600211574000800"
mch_public_key_path: "resources/etc/wxetc_cert/pub_key.pem"
notify_url: "https://console.tianyuanapi.com/api/v1/pay/wechat/callback"
refund_notify_url: "https://console.tianyuanapi.com/api/v1/wechat/refund_callback"
# 微信小程序配置
WechatMini:
app_id: "wxa581992dc74d860e"
# 微信H5配置
WechatH5:
app_id: "wxa581992dc74d860e"
# ===========================================
# 🔍 天眼查配置
# ===========================================

View File

@@ -43,7 +43,7 @@ esign:
app_id: "7439073713"
app_secret: "c7d8cb0d701f7890601d221e9b6edfef"
server_url: "https://smlopenapi.esign.cn"
template_id: "1fd7ed9c6d134d1db7b5af9582633d76"
template_id: "9f7a3f63cc5a48b085b127ba027d234d"
contract:
name: "天远数据API合作协议"
expire_days: 7
@@ -81,6 +81,27 @@ alipay:
return_url: "http://127.0.0.1:8080/api/v1/finance/alipay/return"
# ===========================================
# 💰 微信支付配置
# ===========================================
Wxpay:
app_id: "wxa581992dc74d860e"
mch_id: "1683589176"
mch_certificate_serial_number: "1F4E8B3C39C60035D4CC154F276D03D9CC2C603D"
mch_apiv3_key: "TY8X9nP2qR5tY7uW3zA6bC4dE1flgGJ0"
mch_private_key_path: "resources/etc/wxetc_cert/apiclient_key.pem"
mch_public_key_id: "PUB_KEY_ID_0116835891762025062600211574000800"
mch_public_key_path: "resources/etc/wxetc_cert/pub_key.pem"
notify_url: "https://bx89915628g.vicp.fun/api/v1/pay/wechat/callback"
refund_notify_url: "https://bx89915628g.vicp.fun/api/v1/wechat/refund_callback"
# 微信小程序配置
WechatMini:
app_id: "wxa581992dc74d860e"
# 微信H5配置
WechatH5:
app_id: "wxa581992dc74d860e"
# ===========================================
# 💰 钱包配置
# ===========================================
wallet:
@@ -114,34 +135,42 @@ development:
cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
# ===========================================
# 🚦 开发环境全局限流(放宽或近似关闭)
# ===========================================
ratelimit:
requests: 1000000 # 每窗口允许的请求数,足够大,相当于关闭
window: 1s # 时间窗口
burst: 1000000 # 令牌桶突发容量
# ===========================================
# 🚀 开发环境频率限制配置(放宽限制)
# ===========================================
daily_ratelimit:
max_requests_per_day: 1000000 # 开发环境每日最大请求次数
max_requests_per_ip: 10000000 # 开发环境每个IP每日最大请求次数
max_concurrent: 50 # 开发环境最大并发请求数
max_requests_per_day: 1000000 # 开发环境每日最大请求次数
max_requests_per_ip: 10000000 # 开发环境每个IP每日最大请求次数
max_concurrent: 50 # 开发环境最大并发请求数
# 排除频率限制的路径
exclude_paths:
- "/health" # 健康检查接口
- "/metrics" # 监控指标接口
- "/health" # 健康检查接口
- "/metrics" # 监控指标接口
# 排除频率限制的域名
exclude_domains:
- "api.*" # API二级域名不受频率限制
- "*.api.*" # 支持多级API域名
- "api.*" # API二级域名不受频率限制
- "*.api.*" # 支持多级API域名
# 开发环境安全配置(放宽限制)
enable_ip_whitelist: true # 启用IP白名单
ip_whitelist: # 开发环境IP白名单
- "127.0.0.1" # 本地回环
- "localhost" # 本地主机
- "192.168.*" # 内网IP段
- "10.*" # 内网IP段
- "172.16.*" # 内网IP段
enable_ip_blacklist: false # 开发环境禁用IP黑名单
enable_user_agent: false # 开发环境禁用User-Agent检查
enable_referer: false # 开发环境禁用Referer检查
enable_proxy_check: false # 开发环境禁用代理检查
enable_ip_whitelist: true # 启用IP白名单
ip_whitelist: # 开发环境IP白名单
- "127.0.0.1" # 本地回环
- "localhost" # 本地主机
- "192.168.*" # 内网IP段
- "10.*" # 内网IP段
- "172.16.*" # 内网IP段
enable_ip_blacklist: false # 开发环境禁用IP黑名单
enable_user_agent: false # 开发环境禁用User-Agent检查
enable_referer: false # 开发环境禁用Referer检查
enable_proxy_check: false # 开发环境禁用代理检查

View File

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

View File

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

View File

@@ -0,0 +1,242 @@
# Ubuntu服务器PDF字体配置指南
## 概述
本文档说明如何在Ubuntu 24.04 LTS服务器上配置PDF生成功能所需的中文字体。
## 字体文件位置
确保字体文件存在于以下任一位置:
### 推荐路径(按优先级)
1. **工作目录相对路径**(最常用)
```
{工作目录}/internal/shared/pdf/fonts/
```
例如:如果工作目录是 `/www/tyapi-server`,则字体应在:
```
/www/tyapi-server/internal/shared/pdf/fonts/
```
2. **可执行文件相对路径**
```
{可执行文件所在目录}/internal/shared/pdf/fonts/
```
3. **环境变量指定路径**
```bash
export PDF_FONT_DIR=/path/to/fonts
```
4. **硬编码路径**(后备方案)
- `/www/tyapi-server/internal/shared/pdf/fonts` ✅(已配置)
- `/app/internal/shared/pdf/fonts`Docker
- `/usr/local/tyapi-server/internal/shared/pdf/fonts`
- `/opt/tyapi-server/internal/shared/pdf/fonts`
- `/home/ubuntu/tyapi-server/internal/shared/pdf/fonts`
- `/root/tyapi-server/internal/shared/pdf/fonts`
- `/var/www/tyapi-server/internal/shared/pdf/fonts`
## 部署步骤
### 方法1直接复制字体文件推荐
```bash
# 1. 创建字体目录
sudo mkdir -p /www/tyapi-server/internal/shared/pdf/fonts
# 2. 复制字体文件从本地或Git仓库
# 需要以下字体文件:
# - simhei.ttf (黑体,必需)
# - simkai.ttf (楷体,可选)
# - simfang.ttf (仿宋,可选)
# - YunFengFeiYunTi-2.ttf (水印字体,可选)
# 3. 设置权限
sudo chmod -R 644 /www/tyapi-server/internal/shared/pdf/fonts/*.ttf
sudo chmod -R 644 /www/tyapi-server/internal/shared/pdf/fonts/*.ttc
# 4. 确保运行用户有读取权限
sudo chown -R $(whoami):$(whoami) /www/tyapi-server/internal/shared/pdf/fonts
```
### 方法2使用环境变量
```bash
# 设置字体目录环境变量
export PDF_FONT_DIR=/www/tyapi-server/internal/shared/pdf/fonts
# 或在 systemd 服务文件中添加
# Environment="PDF_FONT_DIR=/www/tyapi-server/internal/shared/pdf/fonts"
```
### 方法3使用符号链接
如果字体文件在其他位置,可以创建符号链接:
```bash
sudo mkdir -p /www/tyapi-server/internal/shared/pdf/fonts
sudo ln -s /path/to/actual/fonts/*.ttf /www/tyapi-server/internal/shared/pdf/fonts/
```
## 验证字体文件
### 1. 检查文件是否存在
```bash
ls -lh /www/tyapi-server/internal/shared/pdf/fonts/
```
应该看到:
```
-rw-r--r-- 1 user user 9.5M Dec 3 18:00 simhei.ttf
-rw-r--r-- 1 user user 8.2M Dec 3 18:00 simkai.ttf
-rw-r--r-- 1 user user 7.8M Dec 3 18:00 simfang.ttf
```
### 2. 检查文件权限
```bash
stat /www/tyapi-server/internal/shared/pdf/fonts/simhei.ttf
```
确保有读取权限(至少 `-r--r--r--`)。
### 3. 检查文件类型
```bash
file /www/tyapi-server/internal/shared/pdf/fonts/simhei.ttf
```
应该显示:`TrueType font data`
## 验证PDF生成功能
### 1. 查看日志
启动服务后,查看日志中是否有以下信息:
```json
{"level":"INFO","msg":"找到字体文件","count":3,"paths":["/www/tyapi-server/internal/shared/pdf/fonts/simhei.ttf",...]}
{"level":"INFO","msg":"成功加载中文字体","font_path":"/www/tyapi-server/internal/shared/pdf/fonts/simhei.ttf"}
```
### 2. 测试PDF生成
调用PDF下载接口检查
- PDF文件能正常生成
- 中文文字正常显示(不是乱码或空白)
- 没有字体相关的错误日志
### 3. 调试信息
如果字体未找到,查看日志中的调试信息:
```json
{"level":"DEBUG","msg":"查找字体文件","total_paths":20,"paths":[...]}
{"level":"DEBUG","msg":"字体文件不存在","font_path":"...","error":"..."}
```
## 常见问题
### 问题1字体文件找不到
**症状**:日志显示 `"未找到中文字体文件"`
**解决方案**
1. 确认字体文件路径是否正确
2. 检查文件权限:`chmod 644 *.ttf`
3. 检查文件所有者:`chown user:user *.ttf`
4. 查看日志中的 `"查找字体文件"` 调试信息,确认尝试的路径
### 问题2字体文件无权限读取
**症状**:日志显示 `"字体文件无读取权限"`
**解决方案**
```bash
sudo chmod 644 /www/tyapi-server/internal/shared/pdf/fonts/*.ttf
sudo chown -R $(whoami):$(whoami) /www/tyapi-server/internal/shared/pdf/fonts
```
### 问题3中文显示为乱码
**症状**PDF中中文显示为乱码或空白
**解决方案**
1. 确认字体文件已成功加载(查看日志)
2. 确认字体文件是有效的TTF格式
3. 检查字体文件是否损坏:`file *.ttf`
### 问题4Docker容器中找不到字体
**症状**在Docker容器中运行时找不到字体
**解决方案**
1. 确保Dockerfile中已复制字体文件
```dockerfile
COPY --from=builder /app/internal/shared/pdf/fonts/ ./internal/shared/pdf/fonts/
```
2. 或使用volume挂载
```yaml
volumes:
- /www/tyapi-server/internal/shared/pdf/fonts:/app/internal/shared/pdf/fonts:ro
```
## Systemd服务配置示例
如果使用systemd管理服务可以在服务文件中设置环境变量
```ini
[Unit]
Description=TYAPI Server
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/www/tyapi-server
ExecStart=/www/tyapi-server/tyapi-server -env=production
Environment="PDF_FONT_DIR=/www/tyapi-server/internal/shared/pdf/fonts"
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
## 字体文件获取
如果本地没有字体文件,可以从以下来源获取:
1. **Windows系统字体**如果服务器是Windows迁移过来的
- `C:\Windows\Fonts\simhei.ttf` → 复制到服务器
2. **Linux系统字体包**
```bash
# Ubuntu/Debian
sudo apt-get install fonts-wqy-zenhei fonts-wqy-microhei
# 然后从系统字体目录复制或创建符号链接
```
3. **从项目仓库**
- 确保字体文件已提交到Git仓库
- 使用 `git pull` 拉取最新代码
## 注意事项
1. **字体文件大小**每个TTF文件约8-10MB确保有足够磁盘空间
2. **文件权限**:确保运行服务的用户有读取权限
3. **路径一致性**:确保字体路径与代码中的查找路径一致
4. **日志级别**生产环境建议将字体查找日志设为DEBUG级别避免日志过多
## 技术支持
如果遇到问题,请提供以下信息:
1. 服务器操作系统版本:`lsb_release -a`
2. 字体文件位置和权限:`ls -lh /www/tyapi-server/internal/shared/pdf/fonts/`
3. 工作目录:`pwd`(服务运行时)
4. 可执行文件位置:`which tyapi-server` 或 `readlink -f $(which tyapi-server)`
5. 相关日志:包含 `"查找字体文件"` 和 `"字体文件"` 的日志条目

3
go.mod
View File

@@ -12,11 +12,13 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/hibiken/asynq v0.25.1
github.com/jung-kurt/gofpdf/v2 v2.17.3
github.com/prometheus/client_golang v1.22.0
github.com/qiniu/go-sdk/v7 v7.25.4
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
@@ -24,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

8
go.sum
View File

@@ -9,6 +9,8 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 h1:7dONQ3WNZ1zy960TmkxJPuwoolZwL7xKtpcM04MBnt4=
github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82/go.mod h1:nLnM0KdK1CmygvjpDUO6m1TjSsiQtL61juhNsvV/JVI=
@@ -133,6 +135,8 @@ github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFF
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -206,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=
@@ -260,6 +266,8 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ package product
import (
"context"
"fmt"
"strings"
"tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/responses"
@@ -28,6 +30,9 @@ type DocumentationApplicationServiceInterface interface {
// GetDocumentationsByProductIDs 批量获取文档
GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]responses.DocumentationResponse, error)
// GenerateFullDocumentation 生成完整的接口文档Markdown格式
GenerateFullDocumentation(ctx context.Context, productID string) (string, error)
}
// DocumentationApplicationService 文档应用服务
@@ -53,6 +58,7 @@ func (s *DocumentationApplicationService) CreateDocumentation(ctx context.Contex
ResponseFields: cmd.ResponseFields,
ResponseExample: cmd.ResponseExample,
ErrorCodes: cmd.ErrorCodes,
PDFFilePath: cmd.PDFFilePath,
}
// 调用领域服务创建文档
@@ -88,6 +94,20 @@ func (s *DocumentationApplicationService) UpdateDocumentation(ctx context.Contex
return nil, err
}
// 更新PDF文件路径如果提供
if cmd.PDFFilePath != "" {
doc.PDFFilePath = cmd.PDFFilePath
err = s.docService.UpdateDocumentationEntity(ctx, doc)
if err != nil {
return nil, fmt.Errorf("更新PDF文件路径失败: %w", err)
}
// 重新获取更新后的文档以确保获取最新数据
doc, err = s.docService.GetDocumentation(ctx, id)
if err != nil {
return nil, err
}
}
// 返回响应
resp := responses.NewDocumentationResponse(doc)
return &resp, nil
@@ -136,3 +156,93 @@ func (s *DocumentationApplicationService) GetDocumentationsByProductIDs(ctx cont
return docResponses, nil
}
// GenerateFullDocumentation 生成完整的接口文档Markdown格式
func (s *DocumentationApplicationService) GenerateFullDocumentation(ctx context.Context, productID string) (string, error) {
// 通过产品ID获取文档
doc, err := s.docService.GetDocumentationByProductID(ctx, productID)
if err != nil {
return "", fmt.Errorf("获取文档失败: %w", err)
}
// 获取文档时已经包含了产品信息通过GetDocumentationWithProduct
// 如果没有产品信息通过文档ID获取
if doc.Product == nil && doc.ID != "" {
docWithProduct, err := s.docService.GetDocumentationWithProduct(ctx, doc.ID)
if err == nil && docWithProduct != nil {
doc = docWithProduct
}
}
var markdown strings.Builder
// 添加文档标题
productName := "产品"
if doc.Product != nil {
productName = doc.Product.Name
}
markdown.WriteString(fmt.Sprintf("# %s 接口文档\n\n", productName))
// 添加产品基本信息
if doc.Product != nil {
markdown.WriteString("## 产品信息\n\n")
markdown.WriteString(fmt.Sprintf("- **产品名称**: %s\n", doc.Product.Name))
markdown.WriteString(fmt.Sprintf("- **产品编号**: %s\n", doc.Product.Code))
if doc.Product.Description != "" {
markdown.WriteString(fmt.Sprintf("- **产品描述**: %s\n", doc.Product.Description))
}
markdown.WriteString("\n")
}
// 添加请求方式
markdown.WriteString("## 请求方式\n\n")
if doc.RequestURL != "" {
markdown.WriteString(fmt.Sprintf("- **请求方法**: %s\n", doc.RequestMethod))
markdown.WriteString(fmt.Sprintf("- **请求地址**: %s\n", doc.RequestURL))
markdown.WriteString("\n")
}
// 添加请求方式详细说明
if doc.BasicInfo != "" {
markdown.WriteString("### 请求方式说明\n\n")
markdown.WriteString(doc.BasicInfo)
markdown.WriteString("\n\n")
}
// 添加请求参数
if doc.RequestParams != "" {
markdown.WriteString("## 请求参数\n\n")
markdown.WriteString(doc.RequestParams)
markdown.WriteString("\n\n")
}
// 添加返回字段说明
if doc.ResponseFields != "" {
markdown.WriteString("## 返回字段说明\n\n")
markdown.WriteString(doc.ResponseFields)
markdown.WriteString("\n\n")
}
// 添加响应示例
if doc.ResponseExample != "" {
markdown.WriteString("## 响应示例\n\n")
markdown.WriteString(doc.ResponseExample)
markdown.WriteString("\n\n")
}
// 添加错误代码
if doc.ErrorCodes != "" {
markdown.WriteString("## 错误代码\n\n")
markdown.WriteString(doc.ErrorCodes)
markdown.WriteString("\n\n")
}
// 添加文档版本信息
markdown.WriteString("---\n\n")
markdown.WriteString(fmt.Sprintf("**文档版本**: %s\n\n", doc.Version))
if doc.UpdatedAt.Year() > 1900 {
markdown.WriteString(fmt.Sprintf("**更新时间**: %s\n", doc.UpdatedAt.Format("2006-01-02 15:04:05")))
}
return markdown.String(), nil
}

View File

@@ -10,6 +10,7 @@ type CreateDocumentationCommand struct {
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
PDFFilePath string `json:"pdf_file_path,omitempty"`
}
// UpdateDocumentationCommand 更新文档命令
@@ -21,4 +22,5 @@ type UpdateDocumentationCommand struct {
ResponseFields string `json:"response_fields"`
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
PDFFilePath string `json:"pdf_file_path,omitempty"`
}

View File

@@ -18,6 +18,7 @@ type DocumentationResponse struct {
ResponseExample string `json:"response_example"`
ErrorCodes string `json:"error_codes"`
Version string `json:"version"`
PDFFilePath string `json:"pdf_file_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -35,6 +36,7 @@ func NewDocumentationResponse(doc *entities.ProductDocumentation) DocumentationR
ResponseExample: doc.ResponseExample,
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
PDFFilePath: doc.PDFFilePath,
CreatedAt: doc.CreatedAt,
UpdatedAt: doc.UpdatedAt,
}

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import (
"tyapi-server/internal/application/product/dto/commands"
appQueries "tyapi-server/internal/application/product/dto/queries"
"tyapi-server/internal/application/product/dto/responses"
domain_api_repo "tyapi-server/internal/domains/api/repositories"
"tyapi-server/internal/domains/product/entities"
repoQueries "tyapi-server/internal/domains/product/repositories/queries"
product_service "tyapi-server/internal/domains/product/services"
@@ -21,6 +22,7 @@ import (
type SubscriptionApplicationServiceImpl struct {
productSubscriptionService *product_service.ProductSubscriptionService
userRepo user_repositories.UserRepository
apiCallRepository domain_api_repo.ApiCallRepository
logger *zap.Logger
}
@@ -28,11 +30,13 @@ type SubscriptionApplicationServiceImpl struct {
func NewSubscriptionApplicationService(
productSubscriptionService *product_service.ProductSubscriptionService,
userRepo user_repositories.UserRepository,
apiCallRepository domain_api_repo.ApiCallRepository,
logger *zap.Logger,
) SubscriptionApplicationService {
return &SubscriptionApplicationServiceImpl{
productSubscriptionService: productSubscriptionService,
userRepo: userRepo,
apiCallRepository: apiCallRepository,
logger: logger,
}
}
@@ -262,17 +266,30 @@ func (s *SubscriptionApplicationServiceImpl) GetProductSubscriptions(ctx context
}
// GetSubscriptionUsage 获取订阅使用情况
// 业务流程1. 获取订阅使用情况 2. 构建响应数据
// 业务流程1. 获取订阅信息 2. 根据产品ID和用户ID统计API调用次数 3. 构建响应数据
func (s *SubscriptionApplicationServiceImpl) GetSubscriptionUsage(ctx context.Context, subscriptionID string) (*responses.SubscriptionUsageResponse, error) {
// 获取订阅信息
subscription, err := s.productSubscriptionService.GetSubscriptionByID(ctx, subscriptionID)
if err != nil {
return nil, err
}
// 根据用户ID和产品ID统计API调用次数
apiCallCount, err := s.apiCallRepository.CountByUserIdAndProductId(ctx, subscription.UserID, subscription.ProductID)
if err != nil {
s.logger.Warn("统计API调用次数失败使用订阅记录中的值",
zap.String("subscription_id", subscriptionID),
zap.String("user_id", subscription.UserID),
zap.String("product_id", subscription.ProductID),
zap.Error(err))
// 如果统计失败使用订阅实体中的APIUsed字段作为备选
apiCallCount = subscription.APIUsed
}
return &responses.SubscriptionUsageResponse{
ID: subscription.ID,
ProductID: subscription.ProductID,
APIUsed: subscription.APIUsed,
APIUsed: apiCallCount,
}, nil
}
@@ -304,6 +321,38 @@ func (s *SubscriptionApplicationServiceImpl) GetMySubscriptionStats(ctx context.
}, nil
}
// CancelMySubscription 取消我的订阅
// 业务流程1. 验证订阅是否属于当前用户 2. 取消订阅
func (s *SubscriptionApplicationServiceImpl) CancelMySubscription(ctx context.Context, userID string, subscriptionID string) error {
// 1. 获取订阅信息
subscription, err := s.productSubscriptionService.GetSubscriptionByID(ctx, subscriptionID)
if err != nil {
s.logger.Error("获取订阅信息失败", zap.String("subscription_id", subscriptionID), zap.Error(err))
return fmt.Errorf("订阅不存在")
}
// 2. 验证订阅是否属于当前用户
if subscription.UserID != userID {
s.logger.Warn("用户尝试取消不属于自己的订阅",
zap.String("user_id", userID),
zap.String("subscription_id", subscriptionID),
zap.String("subscription_user_id", subscription.UserID))
return fmt.Errorf("无权取消此订阅")
}
// 3. 取消订阅(软删除)
if err := s.productSubscriptionService.CancelSubscription(ctx, subscriptionID); err != nil {
s.logger.Error("取消订阅失败", zap.String("subscription_id", subscriptionID), zap.Error(err))
return fmt.Errorf("取消订阅失败: %w", err)
}
s.logger.Info("用户取消订阅成功",
zap.String("user_id", userID),
zap.String("subscription_id", subscriptionID))
return nil
}
// convertToSubscriptionInfoResponse 转换为订阅信息响应
func (s *SubscriptionApplicationServiceImpl) convertToSubscriptionInfoResponse(subscription *entities.Subscription) *responses.SubscriptionInfoResponse {
// 查询用户信息

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ package container
import (
"context"
"fmt"
"os"
"strconv"
"time"
"go.uber.org/fx"
@@ -66,6 +68,7 @@ import (
"tyapi-server/internal/shared/middleware"
sharedOCR "tyapi-server/internal/shared/ocr"
"tyapi-server/internal/shared/payment"
"tyapi-server/internal/shared/pdf"
"tyapi-server/internal/shared/resilience"
"tyapi-server/internal/shared/saga"
"tyapi-server/internal/shared/tracing"
@@ -304,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)
@@ -509,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,
@@ -571,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域仓储层
@@ -675,6 +698,8 @@ func NewContainer() *Container {
certification_service.NewEnterpriseInfoSubmitRecordService,
// 文章领域服务
article_service.NewArticleService,
// 公告领域服务
article_service.NewAnnouncementService,
// 统计领域服务
statistics_service.NewStatisticsAggregateService,
statistics_service.NewStatisticsCalculationService,
@@ -775,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,
@@ -785,6 +811,7 @@ func NewContainer() *Container {
redisAddr,
logger,
articleApplicationService,
announcementApplicationService,
apiApplicationService,
walletService,
subscriptionService,
@@ -843,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,
@@ -855,10 +885,13 @@ func NewContainer() *Container {
) finance.FinanceApplicationService {
return finance.NewFinanceApplicationService(
aliPayClient,
wechatPayService,
walletService,
rechargeRecordService,
walletTransactionRepo,
alipayOrderRepo,
wechatOrderRepo,
rechargeRecordRepo,
userRepo,
txManager,
logger,
@@ -941,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(
@@ -980,6 +1030,62 @@ func NewContainer() *Container {
),
),
// PDF查找服务
fx.Provide(
func(logger *zap.Logger) (*pdf.PDFFinder, error) {
docDir, err := pdf.GetDocumentationDir()
if err != nil {
logger.Warn("未找到接口文档文件夹PDF自动查找功能将不可用", zap.Error(err))
return nil, nil // 返回nilhandler中会检查
}
logger.Info("PDF查找服务已初始化", zap.String("documentation_dir", docDir))
return pdf.NewPDFFinder(docDir, logger), nil
},
),
// PDF生成器
fx.Provide(
func(logger *zap.Logger) *pdf.PDFGenerator {
return pdf.NewPDFGenerator(logger)
},
),
// PDF缓存管理器
fx.Provide(
func(logger *zap.Logger) (*pdf.PDFCacheManager, error) {
// 使用默认配置缓存目录在临时目录TTL为24小时最大缓存大小为500MB
cacheDir := "" // 使用默认目录临时目录下的tyapi_pdf_cache
ttl := 24 * time.Hour
maxSize := int64(500 * 1024 * 1024) // 500MB
// 可以通过环境变量覆盖
if envCacheDir := os.Getenv("PDF_CACHE_DIR"); envCacheDir != "" {
cacheDir = envCacheDir
}
if envTTL := os.Getenv("PDF_CACHE_TTL"); envTTL != "" {
if parsedTTL, err := time.ParseDuration(envTTL); err == nil {
ttl = parsedTTL
}
}
if envMaxSize := os.Getenv("PDF_CACHE_MAX_SIZE"); envMaxSize != "" {
if parsedMaxSize, err := strconv.ParseInt(envMaxSize, 10, 64); err == nil {
maxSize = parsedMaxSize
}
}
cacheManager, err := pdf.NewPDFCacheManager(logger, cacheDir, ttl, maxSize)
if err != nil {
logger.Warn("PDF缓存管理器初始化失败将禁用缓存功能", zap.Error(err))
return nil, nil // 返回nilhandler中会检查
}
logger.Info("PDF缓存管理器已初始化",
zap.String("cache_dir", cacheDir),
zap.Duration("ttl", ttl),
zap.Int64("max_size", maxSize),
)
return cacheManager, nil
},
),
// HTTP处理器
fx.Provide(
// 用户HTTP处理器
@@ -1005,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)
},
),
// 路由注册
@@ -1021,6 +1136,8 @@ func NewContainer() *Container {
routes.NewProductAdminRoutes,
// 文章路由
routes.NewArticleRoutes,
// 公告路由
routes.NewAnnouncementRoutes,
// API路由
routes.NewApiRoutes,
// 统计路由
@@ -1132,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,
@@ -1149,6 +1267,7 @@ func RegisterRoutes(
productRoutes.Register(router)
productAdminRoutes.Register(router)
articleRoutes.Register(router)
announcementRoutes.Register(router)
statisticsRoutes.Register(router)
// 打印注册的路由信息

View File

@@ -133,6 +133,13 @@ type QYGL23T7Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
}
type QYGL5CMPReq struct {
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
EntCode string `json:"ent_code" validate:"required,validUSCI"`
LegalPerson string `json:"legal_person" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
type YYSY4B37Req struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
@@ -195,6 +202,18 @@ type IVYZGZ08Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
}
type IVYZ2B2TReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
QueryReasonId int64 `json:"query_reason_id" validate:"required"`
}
type IVYZ5A9OReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
AuthAuthorizeFileCode string `json:"auth_authorize_file_code" validate:"required"`
}
type FLXG8A3FReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
@@ -452,6 +471,20 @@ type JRZQ3C9RReq struct {
Authorized string `json:"authorized" validate:"required,oneof=0 1"`
}
type JRZQ3P01Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
Authorized string `json:"authorized" validate:"required,oneof=0 1"`
}
// JRZQ3AG6Req JRZQ3AG6 轻松查公积API处理方法
type JRZQ3AG6Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
ReturnURL string `json:"return_url" validate:"required,validReturnURL"`
AuthorizationURL string `json:"authorization_url" validate:"required,authorization_url"`
}
type JRZQ8A2DReq struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
@@ -663,6 +696,11 @@ type IVYZ8I9JReq struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
type IVYZ6M8PReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type YYSY9E4AReq struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}

View File

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

View File

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

View File

@@ -130,6 +130,9 @@ func registerAllProcessors(combService *comb.CombService) {
"JRZQ0B6Y": jrzq.ProcessJRZQ0B6YRequest,
"JRZQ9A1W": jrzq.ProcessJRZQ9A1WRequest,
"JRZQ8F7C": jrzq.ProcessJRZQ8F7CRequest,
"JRZQ1W4X": jrzq.ProcessJRZQ1W4XRequest,
"JRZQ3P01": jrzq.ProcessJRZQ3P01Request,
"JRZQ3AG6": jrzq.ProcessJRZQ3AG6Request,
// QYGL系列处理器
"QYGL8261": qygl.ProcessQYGL8261Request,
@@ -153,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,
@@ -199,7 +203,9 @@ func registerAllProcessors(combService *comb.CombService) {
"IVYZ9K2L": ivyz.ProcessIVYZ9K2LRequest,
"IVYZ2C1P": ivyz.ProcessIVYZ2C1PRequest,
"IVYZP2Q6": ivyz.ProcessIVYZP2Q6Request,
"JRZQ1W4X": jrzq.ProcessJRZQ1W4XRequest,
"IVYZ2B2T": ivyz.ProcessIVYZ2B2TRequest, //能力资质核验(学历)
"IVYZ5A9O": ivyz.ProcessIVYZ5A9ORequest, //全国⾃然⼈⻛险评估评分模型
"IVYZ6M8P": ivyz.ProcessIVYZ6M8PRequest, //职业资格证书
// COMB系列处理器 - 只注册有自定义逻辑的组合包
"COMB86PM": comb.ProcessCOMB86PMRequest, // 有自定义逻辑重命名ApiCode

View File

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

View File

@@ -193,7 +193,12 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"QYGL2S0W": &dto.QYGL2S0WReq{}, //失信被执行企业个人查询
"QYGL9T1Q": &dto.QYGL9T1QReq{}, //全国企业借贷意向验证查询_V1
"QYGL5A9T": &dto.QYGL5A9TReq{}, //全国企业各类工商风险统计数量查询
"JRZQ3P01": &dto.JRZQ3P01Req{}, //天远风控决策
"JRZQ3AG6": &dto.JRZQ3AG6Req{}, //轻松查公积
"IVYZ2B2T": &dto.IVYZ2B2TReq{}, //能力资质核验(学历)
"IVYZ5A9O": &dto.IVYZ5A9OReq{}, //全国⾃然⼈⻛险评估评分模型
"IVYZ6M8P": &dto.IVYZ6M8PReq{}, //职业资格证书
"QYGL5CMP": &dto.QYGL5CMPReq{}, //企业五要素验证
}
// 优先返回已配置的DTO
@@ -317,6 +322,7 @@ func (s *FormConfigServiceImpl) parseValidationRules(validateTag string) string
values := strings.TrimPrefix(rule, "oneof=")
frontendRules = append(frontendRules, "可选值: "+values)
}
}
return strings.Join(frontendRules, "、")
@@ -392,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 {
@@ -436,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 {
@@ -489,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 {
@@ -514,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": "请输入女方真实姓名",
@@ -543,7 +552,8 @@ func (s *FormConfigServiceImpl) generateDescription(jsonTag string, validation s
"return_type": "返回类型1-专业和学校名称数据返回编码形式默认2-专业和学校名称数据返回中文名称",
"photo_data": "人脸图片必填base64编码的图片数据仅支持JPG、BMP、PNG三种格式",
"owner_type": "企业主类型编码1-法定代表人2-主要人员3-自然人股东4-法定代表人及自然人股东5-其他",
"type": "查询类型per-人员ent-企业 1",
"type": "查询类型per-人员ent-企业 ",
"query_reason_id": "查询原因ID1-授信审批2-贷中管理3-贷后管理4-异议处理5-担保查询6-租赁资质审查7-融资租赁审批8-借贷撮合查询9-保险审批10-资质审核11-风控审核12-企业背调",
}
if desc, exists := descMap[jsonTag]; exists {

View File

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

View File

@@ -10,7 +10,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex"
)
// ProcessFLXG5876Request FLXG5876 API处理方法
// ProcessFLXG5876Request FLXG5876 易诉人识别API处理方法
func ProcessFLXG5876Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.FLXG5876Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
@@ -42,4 +42,4 @@ func ProcessFLXG5876Request(ctx context.Context, params []byte, deps *processors
}
return respBytes, nil
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/zhicha"
)
// ProcessJRZQ3AG6Request JRZQ3AG6 轻松查公积API处理方法
func ProcessJRZQ3AG6Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQ3AG6Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedMobileNo, err := deps.ZhichaService.Encrypt(paramsDto.MobileNo)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
reqData := map[string]interface{}{
"name": encryptedName,
"idCard": encryptedIDCard,
"phone": encryptedMobileNo,
"return_url": paramsDto.ReturnURL,
"authorization_url": paramsDto.AuthorizationURL,
}
respData, err := deps.ZhichaService.CallAPI(ctx, "ZCI108", reqData)
if err != nil {
if errors.Is(err, zhicha.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else {
return nil, errors.Join(processors.ErrSystem, err)
}
}
// 将响应数据转换为JSON字节
respBytes, err := json.Marshal(respData)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,56 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/zhicha"
)
// ProcessJRZQ3P01Request JRZQ3P01 天远风控决策API处理方法
func ProcessJRZQ3P01Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQ3P01Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
reqData := map[string]interface{}{
"name": encryptedName,
"idCard": encryptedIDCard,
"authorized": paramsDto.Authorized,
}
respData, err := deps.ZhichaService.CallAPI(ctx, "ZCI109", reqData)
if err != nil {
if errors.Is(err, zhicha.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
} else {
return nil, errors.Join(processors.ErrSystem, err)
}
}
// 将响应数据转换为JSON字节
respBytes, err := json.Marshal(respData)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,8 +42,8 @@ func ProcessQYGL8271Request(ctx context.Context, params []byte, deps *processors
}
reqData := map[string]interface{}{
"data": map[string]interface{}{
"org_name": encryptedEntName,
"uscc": encryptedEntCode,
"org_name": encryptedEntName,
"uscc": encryptedEntCode,
"auth_authorizeFileCode": encryptedAuthAuthorizeFileCode,
"inquired_auth": fmt.Sprintf("authed:%s", paramsDto.AuthDate),
},

View File

@@ -34,7 +34,7 @@ func ProcessQYGLB4C0Request(ctx context.Context, params []byte, deps *processors
// 数据源错误
if errors.Is(err, westdex.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}else if errors.Is(err, westdex.ErrNotFound) {
} else if errors.Is(err, westdex.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
// 其他系统错误

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,8 @@ type Product struct {
Price decimal.Decimal `gorm:"type:decimal(10,2);not null;default:0" comment:"产品价格"`
CostPrice decimal.Decimal `gorm:"type:decimal(10,2);default:0" comment:"成本价"`
Remark string `gorm:"type:text" comment:"备注"`
IsEnabled bool `gorm:"default:true" comment:"是否启用"`
IsVisible bool `gorm:"default:true" comment:"是否展示"`
IsEnabled bool `gorm:"default:false" comment:"是否启用"`
IsVisible bool `gorm:"default:false" comment:"是否展示"`
IsPackage bool `gorm:"default:false" comment:"是否组合包"`
// 组合包相关关联
PackageItems []*ProductPackageItem `gorm:"foreignKey:PackageID" comment:"组合包项目列表"`
@@ -62,7 +62,6 @@ func (p *Product) CanBeSubscribed() bool {
return p.IsValid()
}
// UpdateSEO 更新SEO信息
func (p *Product) UpdateSEO(title, description, keywords string) {
p.SEOTitle = title
@@ -115,4 +114,4 @@ func (p *Product) GetOldID() string {
// HasOldID 检查是否有旧ID
func (p *Product) HasOldID() bool {
return p.OldID != nil && *p.OldID != ""
}
}

View File

@@ -22,6 +22,7 @@ type ProductDocumentation struct {
ResponseExample string `gorm:"type:text" comment:"响应示例"`
ErrorCodes string `gorm:"type:text" comment:"错误代码"`
Version string `gorm:"type:varchar(20);default:'1.0'" comment:"文档版本"`
PDFFilePath string `gorm:"type:varchar(500)" comment:"PDF文档文件路径或URL"`
// 关联关系
Product *Product `gorm:"foreignKey:ProductID" comment:"产品"`
@@ -44,7 +45,6 @@ func (pd *ProductDocumentation) IsValid() bool {
return pd.DeletedAt.Time.IsZero()
}
// UpdateContent 更新文档内容
func (pd *ProductDocumentation) UpdateContent(requestURL, requestMethod, basicInfo, requestParams, responseFields, responseExample, errorCodes string) {
pd.RequestURL = requestURL
@@ -58,13 +58,42 @@ func (pd *ProductDocumentation) UpdateContent(requestURL, requestMethod, basicIn
// IncrementVersion 增加版本号
func (pd *ProductDocumentation) IncrementVersion() {
// 简单的版本号递增逻辑,实际项目中可能需要更复杂的版本管理
if pd.Version == "" {
pd.Version = "1.0"
} else {
// 这里可以实现更复杂的版本号递增逻辑
pd.Version = pd.Version + ".1"
return
}
// 解析版本号 major.minor
parts := strings.Split(pd.Version, ".")
if len(parts) < 2 {
// 如果格式不正确,重置为 1.0
pd.Version = "1.0"
return
}
// 解析 major 和 minor
var major, minor int
_, err := fmt.Sscanf(parts[0], "%d", &major)
if err != nil {
pd.Version = "1.0"
return
}
_, err = fmt.Sscanf(parts[1], "%d", &minor)
if err != nil {
pd.Version = "1.0"
return
}
// 递增 minor
minor++
// 如果 minor 达到 10则 major +1minor 重置为 0
if minor >= 10 {
major++
minor = 0
}
// 更新版本号
pd.Version = fmt.Sprintf("%d.%d", major, minor)
}
// Validate 验证文档完整性
@@ -137,7 +166,6 @@ func (pd *ProductDocumentation) UpdateDocumentation(requestURL, requestMethod, b
return nil
}
// GetDocumentationSummary 获取文档摘要
func (pd *ProductDocumentation) GetDocumentationSummary() map[string]interface{} {
return map[string]interface{}{

View File

@@ -114,3 +114,15 @@ func (s *ProductDocumentationService) GetDocumentationWithProduct(ctx context.Co
func (s *ProductDocumentationService) GetDocumentationsByProductIDs(ctx context.Context, productIDs []string) ([]*entities.ProductDocumentation, error) {
return s.docRepo.FindByProductIDs(ctx, productIDs)
}
// UpdateDocumentationEntity 更新文档实体用于更新PDFFilePath等字段
func (s *ProductDocumentationService) UpdateDocumentationEntity(ctx context.Context, doc *entities.ProductDocumentation) error {
// 验证文档是否存在
_, err := s.docRepo.FindByID(ctx, doc.ID)
if err != nil {
return fmt.Errorf("文档不存在: %w", err)
}
// 保存更新
return s.docRepo.Update(ctx, doc)
}

View File

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

View File

@@ -20,7 +20,6 @@ type UserStats struct {
// UserRepository 用户仓储接口
type UserRepository interface {
interfaces.Repository[entities.User]
// 基础查询 - 直接使用实体
GetByPhone(ctx context.Context, phone string) (*entities.User, error)
GetByUsername(ctx context.Context, username string) (*entities.User, error)
@@ -48,13 +47,15 @@ type UserRepository interface {
// 统计信息
GetStats(ctx context.Context) (*UserStats, error)
GetStatsByDateRange(ctx context.Context, startDate, endDate string) (*UserStats, error)
// 系统级别统计方法
GetSystemUserStats(ctx context.Context) (*UserStats, error)
GetSystemUserStatsByDateRange(ctx context.Context, startDate, endDate time.Time) (*UserStats, error)
GetSystemDailyUserStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemMonthlyUserStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemDailyCertificationStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
GetSystemMonthlyCertificationStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error)
// 排行榜查询方法
GetUserCallRankingByCalls(ctx context.Context, period string, limit int) ([]map[string]interface{}, error)
GetUserCallRankingByConsumption(ctx context.Context, period string, limit int) ([]map[string]interface{}, error)
@@ -117,4 +118,4 @@ type EnterpriseInfoRepository interface {
Count(ctx context.Context, options interfaces.CountOptions) (int64, error)
List(ctx context.Context, options interfaces.ListOptions) ([]entities.EnterpriseInfo, error)
Exists(ctx context.Context, id string) (bool, error)
}
}

View File

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

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
"strings"
"time"
"tyapi-server/internal/domains/api/entities"
"tyapi-server/internal/domains/api/repositories"
@@ -14,7 +15,7 @@ import (
)
const (
ApiCallsTable = "api_calls"
ApiCallsTable = "api_calls"
ApiCallCacheTTL = 10 * time.Minute
)
@@ -212,7 +213,7 @@ func (r *GormApiCallRepository) ListByUserIdWithFiltersAndProductName(ctx contex
// 转换为entities.ApiCall并构建产品名称映射
var calls []*entities.ApiCall
productNameMap := make(map[string]string)
for _, c := range callsWithProduct {
call := c.ApiCall
calls = append(calls, &call)
@@ -229,6 +230,11 @@ func (r *GormApiCallRepository) CountByUserId(ctx context.Context, userId string
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ?", userId)
}
// CountByUserIdAndProductId 按用户ID和产品ID统计API调用次数
func (r *GormApiCallRepository) CountByUserIdAndProductId(ctx context.Context, userId string, productId string) (int64, error) {
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ? AND product_id = ?", userId, productId)
}
// CountByUserIdAndDateRange 按用户ID和日期范围统计API调用次数
func (r *GormApiCallRepository) CountByUserIdAndDateRange(ctx context.Context, userId string, startDate, endDate time.Time) (int64, error) {
return r.CountWhere(ctx, &entities.ApiCall{}, "user_id = ? AND created_at >= ? AND created_at < ?", userId, startDate, endDate)
@@ -237,7 +243,7 @@ func (r *GormApiCallRepository) CountByUserIdAndDateRange(ctx context.Context, u
// GetDailyStatsByUserId 获取用户每日API调用统计
func (r *GormApiCallRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -250,19 +256,19 @@ func (r *GormApiCallRepository) GetDailyStatsByUserId(ctx context.Context, userI
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetMonthlyStatsByUserId 获取用户每月API调用统计
func (r *GormApiCallRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -275,12 +281,12 @@ func (r *GormApiCallRepository) GetMonthlyStatsByUserId(ctx context.Context, use
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -304,8 +310,29 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
// 应用筛选条件
if filters != nil {
// 用户ID筛选
if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 用户ID筛选支持单个user_id和多个user_ids
// 如果同时存在优先使用user_ids批量查询
if userIds, ok := filters["user_ids"].(string); ok && userIds != "" {
// 解析逗号分隔的用户ID列表
userIdsList := strings.Split(userIds, ",")
// 去除空白字符
var cleanUserIds []string
for _, id := range userIdsList {
id = strings.TrimSpace(id)
if id != "" {
cleanUserIds = append(cleanUserIds, id)
}
}
if len(cleanUserIds) > 0 {
placeholders := strings.Repeat("?,", len(cleanUserIds))
placeholders = placeholders[:len(placeholders)-1] // 移除最后一个逗号
whereCondition += " AND ac.user_id IN (" + placeholders + ")"
for _, id := range cleanUserIds {
whereArgs = append(whereArgs, id)
}
}
} else if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 单个用户ID筛选
whereCondition += " AND ac.user_id = ?"
whereArgs = append(whereArgs, userId)
}
@@ -332,6 +359,12 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
whereArgs = append(whereArgs, "%"+productName+"%")
}
// 企业名称筛选
if companyName, ok := filters["company_name"].(string); ok && companyName != "" {
whereCondition += " AND ei.company_name LIKE ?"
whereArgs = append(whereArgs, "%"+companyName+"%")
}
// 状态筛选
if status, ok := filters["status"].(string); ok && status != "" {
whereCondition += " AND ac.status = ?"
@@ -340,9 +373,12 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
}
// 构建JOIN查询
// 需要JOIN product表获取产品名称JOIN users和enterprise_infos表获取企业名称
query := r.GetDB(ctx).Table("api_calls ac").
Select("ac.*, p.name as product_name").
Joins("LEFT JOIN product p ON ac.product_id = p.id").
Joins("LEFT JOIN users u ON ac.user_id = u.id").
Joins("LEFT JOIN enterprise_infos ei ON u.id = ei.user_id").
Where(whereCondition, whereArgs...)
// 获取总数
@@ -374,7 +410,7 @@ func (r *GormApiCallRepository) ListWithFiltersAndProductName(ctx context.Contex
// 转换为entities.ApiCall并构建产品名称映射
var calls []*entities.ApiCall
productNameMap := make(map[string]string)
for _, c := range callsWithProduct {
call := c.ApiCall
calls = append(calls, &call)
@@ -395,10 +431,11 @@ func (r *GormApiCallRepository) GetSystemTotalCalls(ctx context.Context) (int64,
}
// GetSystemCallsByDateRange 获取系统指定时间范围内的API调用次数
// endDate 应该是结束日期当天的次日00:00:00日统计或下个月1号00:00:00月统计使用 < 而不是 <=
func (r *GormApiCallRepository) GetSystemCallsByDateRange(ctx context.Context, startDate, endDate time.Time) (int64, error) {
var count int64
err := r.GetDB(ctx).Model(&entities.ApiCall{}).
Where("created_at >= ? AND created_at <= ?", startDate, endDate).
Where("created_at >= ? AND created_at < ?", startDate, endDate).
Count(&count).Error
return count, err
}
@@ -406,7 +443,7 @@ func (r *GormApiCallRepository) GetSystemCallsByDateRange(ctx context.Context, s
// GetSystemDailyStats 获取系统每日API调用统计
func (r *GormApiCallRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
DATE(created_at) as date,
@@ -417,35 +454,35 @@ func (r *GormApiCallRepository) GetSystemDailyStats(ctx context.Context, startDa
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetSystemMonthlyStats 获取系统每月API调用统计
func (r *GormApiCallRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
COUNT(*) as calls
FROM api_calls
WHERE created_at >= $1
AND created_at <= $2
AND created_at < $2
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -453,7 +490,7 @@ func (r *GormApiCallRepository) GetSystemMonthlyStats(ctx context.Context, start
func (r *GormApiCallRepository) GetApiPopularityRanking(ctx context.Context, period string, limit int) ([]map[string]interface{}, error) {
var sql string
var args []interface{}
switch period {
case "today":
sql = `
@@ -508,12 +545,12 @@ func (r *GormApiCallRepository) GetApiPopularityRanking(ctx context.Context, per
default:
return nil, fmt.Errorf("不支持的时间周期: %s", period)
}
var results []map[string]interface{}
err := r.GetDB(ctx).Raw(sql, args...).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package repositories
import (
"context"
"errors"
"fmt"
"strings"
"time"
"tyapi-server/internal/domains/finance/entities"
@@ -10,6 +11,7 @@ import (
"tyapi-server/internal/shared/database"
"tyapi-server/internal/shared/interfaces"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -87,15 +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 {
query = query.Where(key+" = ?", value)
// 特殊处理时间范围过滤器
if key == "start_time" {
if startTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at >= ?", startTime)
} else {
query = query.Where("created_at >= ?", startTime)
}
}
} else if key == "end_time" {
if endTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at <= ?", endTime)
} else {
query = query.Where("created_at <= ?", endTime)
}
}
} else if key == "company_name" {
// 处理企业名称筛选
if companyName, ok := value.(string); ok && companyName != "" {
query = query.Where("ei.company_name LIKE ?", "%"+companyName+"%")
}
} else if key == "min_amount" {
// 处理最小金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount >= ?", amount)
} else {
query = query.Where("amount >= ?", amount)
}
}
} else if key == "max_amount" {
// 处理最大金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount <= ?", amount)
} else {
query = query.Where("amount <= ?", amount)
}
}
} else {
// 其他过滤器使用等值查询
if hasCompanyNameFilter {
query = query.Where("rr."+key+" = ?", value)
} else {
query = query.Where(key+" = ?", value)
}
}
}
}
if options.Search != "" {
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
if hasCompanyNameFilter {
query = query.Where("rr.user_id LIKE ? OR rr.transfer_order_id LIKE ? OR rr.alipay_order_id LIKE ? OR rr.wechat_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
} else {
query = query.Where("user_id LIKE ? OR transfer_order_id LIKE ? OR alipay_order_id LIKE ? OR wechat_order_id LIKE ?",
"%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%", "%"+options.Search+"%")
}
}
return count, query.Count(&count).Error
}
@@ -108,41 +181,123 @@ 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 {
if hasCompanyNameFilter {
query = query.Where("rr.created_at >= ?", startTime)
} else {
query = query.Where("created_at >= ?", startTime)
}
}
} else if key == "end_time" {
// 处理结束时间范围
if endTime, ok := value.(time.Time); ok {
if hasCompanyNameFilter {
query = query.Where("rr.created_at <= ?", endTime)
} else {
query = query.Where("created_at <= ?", endTime)
}
}
} else if key == "min_amount" {
// 处理最小金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount >= ?", amount)
} else {
query = query.Where("amount >= ?", amount)
}
}
} else if key == "max_amount" {
// 处理最大金额支持string、int、int64类型
if amount, err := r.parseAmount(value); err == nil {
if hasCompanyNameFilter {
query = query.Where("rr.amount <= ?", amount)
} else {
query = query.Where("amount <= ?", amount)
}
}
} else {
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 != "" {
order := "ASC"
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 {
offset := (options.Page - 1) * options.PageSize
query = query.Offset(offset).Limit(options.PageSize)
}
err := query.Find(&records).Error
return records, err
}
@@ -209,7 +364,7 @@ func (r *GormRechargeRecordRepository) GetTotalAmountByUserIdAndDateRange(ctx co
// GetDailyStatsByUserId 获取用户每日充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -224,19 +379,19 @@ func (r *GormRechargeRecordRepository) GetDailyStatsByUserId(ctx context.Context
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetMonthlyStatsByUserId 获取用户每月充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -251,12 +406,12 @@ func (r *GormRechargeRecordRepository) GetMonthlyStatsByUserId(ctx context.Conte
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, userId, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -271,19 +426,21 @@ 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{}
sql := `
SELECT
DATE(created_at) as date,
@@ -291,24 +448,24 @@ 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
}
return results, nil
}
// GetSystemMonthlyStats 获取系统每月充值统计(排除赠送)
func (r *GormRechargeRecordRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
@@ -317,15 +474,36 @@ 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
`
err := r.GetDB(ctx).Raw(sql, entities.RechargeStatusSuccess, entities.RechargeTypeGift, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
}
// parseAmount 解析金额值支持string、int、int64类型转换为decimal.Decimal
func (r *GormRechargeRecordRepository) parseAmount(value interface{}) (decimal.Decimal, error) {
switch v := value.(type) {
case string:
if v == "" {
return decimal.Zero, fmt.Errorf("empty string")
}
return decimal.NewFromString(v)
case int:
return decimal.NewFromInt(int64(v)), nil
case int64:
return decimal.NewFromInt(v), nil
case float64:
return decimal.NewFromFloat(v), nil
case decimal.Decimal:
return v, nil
default:
return decimal.Zero, fmt.Errorf("unsupported type: %T", value)
}
}

View File

@@ -179,7 +179,7 @@ func (r *GormWalletTransactionRepository) GetTotalAmountByUserIdAndDateRange(ctx
// GetDailyStatsByUserId 获取用户每日消费统计
func (r *GormWalletTransactionRepository) GetDailyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -192,19 +192,19 @@ func (r *GormWalletTransactionRepository) GetDailyStatsByUserId(ctx context.Cont
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetMonthlyStatsByUserId 获取用户每月消费统计
func (r *GormWalletTransactionRepository) GetMonthlyStatsByUserId(ctx context.Context, userId string, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
// 构建SQL查询 - 使用PostgreSQL语法使用具体的日期范围
sql := `
SELECT
@@ -217,12 +217,12 @@ func (r *GormWalletTransactionRepository) GetMonthlyStatsByUserId(ctx context.Co
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, userId, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
@@ -358,7 +358,7 @@ func (r *GormWalletTransactionRepository) ListByUserIdWithFiltersAndProductName(
// 转换为entities.WalletTransaction并构建产品名称映射
var transactions []*entities.WalletTransaction
productNameMap := make(map[string]string)
for _, t := range transactionsWithProduct {
transaction := t.WalletTransaction
transactions = append(transactions, &transaction)
@@ -382,12 +382,26 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
// 应用筛选条件
if filters != nil {
// 用户ID筛选
if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 用户ID筛选(支持单个和多个)
if userIds, ok := filters["user_ids"].(string); ok && userIds != "" {
// 多个用户ID逗号分隔
userIdsList := strings.Split(userIds, ",")
whereCondition += " AND wt.user_id IN ?"
whereArgs = append(whereArgs, userIdsList)
} else if userId, ok := filters["user_id"].(string); ok && userId != "" {
// 单个用户ID
whereCondition += " AND wt.user_id = ?"
whereArgs = append(whereArgs, userId)
}
// 产品ID筛选支持多个
if productIds, ok := filters["product_ids"].(string); ok && productIds != "" {
// 多个产品ID逗号分隔
productIdsList := strings.Split(productIds, ",")
whereCondition += " AND wt.product_id IN ?"
whereArgs = append(whereArgs, productIdsList)
}
// 时间范围筛选
if startTime, ok := filters["start_time"].(time.Time); ok {
whereCondition += " AND wt.created_at >= ?"
@@ -410,6 +424,12 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
whereArgs = append(whereArgs, "%"+productName+"%")
}
// 企业名称筛选
if companyName, ok := filters["company_name"].(string); ok && companyName != "" {
whereCondition += " AND ei.company_name LIKE ?"
whereArgs = append(whereArgs, "%"+companyName+"%")
}
// 金额范围筛选
if minAmount, ok := filters["min_amount"].(string); ok && minAmount != "" {
whereCondition += " AND wt.amount >= ?"
@@ -422,9 +442,12 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
}
// 构建JOIN查询
// 需要JOIN product表获取产品名称JOIN users和enterprise_infos表获取企业名称
query := r.GetDB(ctx).Table("wallet_transactions wt").
Select("wt.*, p.name as product_name").
Joins("LEFT JOIN product p ON wt.product_id = p.id").
Joins("LEFT JOIN users u ON wt.user_id = u.id").
Joins("LEFT JOIN enterprise_infos ei ON u.id = ei.user_id").
Where(whereCondition, whereArgs...)
// 获取总数
@@ -456,7 +479,7 @@ func (r *GormWalletTransactionRepository) ListWithFiltersAndProductName(ctx cont
// 转换为entities.WalletTransaction并构建产品名称映射
var transactions []*entities.WalletTransaction
productNameMap := make(map[string]string)
for _, t := range transactionsWithProduct {
transaction := t.WalletTransaction
transactions = append(transactions, &transaction)
@@ -563,10 +586,11 @@ func (r *GormWalletTransactionRepository) GetSystemTotalAmount(ctx context.Conte
}
// GetSystemAmountByDateRange 获取系统指定时间范围内的消费金额
// endDate 应该是结束日期当天的次日00:00:00日统计或下个月1号00:00:00月统计使用 < 而不是 <=
func (r *GormWalletTransactionRepository) GetSystemAmountByDateRange(ctx context.Context, startDate, endDate time.Time) (float64, error) {
var total float64
err := r.GetDB(ctx).Model(&entities.WalletTransaction{}).
Where("created_at >= ? AND created_at <= ?", startDate, endDate).
Where("created_at >= ? AND created_at < ?", startDate, endDate).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
return total, err
@@ -575,7 +599,7 @@ func (r *GormWalletTransactionRepository) GetSystemAmountByDateRange(ctx context
// GetSystemDailyStats 获取系统每日消费统计
func (r *GormWalletTransactionRepository) GetSystemDailyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
DATE(created_at) as date,
@@ -586,34 +610,34 @@ func (r *GormWalletTransactionRepository) GetSystemDailyStats(ctx context.Contex
GROUP BY DATE(created_at)
ORDER BY date ASC
`
err := r.GetDB(ctx).Raw(sql, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetSystemMonthlyStats 获取系统每月消费统计
func (r *GormWalletTransactionRepository) GetSystemMonthlyStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) {
var results []map[string]interface{}
sql := `
SELECT
TO_CHAR(created_at, 'YYYY-MM') as month,
COALESCE(SUM(amount), 0) as amount
FROM wallet_transactions
WHERE created_at >= ?
AND created_at <= ?
AND created_at < ?
GROUP BY TO_CHAR(created_at, 'YYYY-MM')
ORDER BY month ASC
`
err := r.GetDB(ctx).Raw(sql, startDate, endDate).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
}

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ type ZhichaResp struct {
type ZhichaConfig struct {
URL string
AppID string
AppSecret string
AppSecret string
EncryptKey string
}
@@ -133,14 +133,13 @@ func (z *ZhichaService) CallAPI(ctx context.Context, proID string, params map[st
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
// 检查是否是网络超时错误
isTimeout = true
} else if errStr := err.Error();
errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
isTimeout = true
}
if isTimeout {
// 超时错误应该返回数据源异常,而不是系统异常
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
package handlers
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"time"
@@ -19,12 +21,12 @@ import (
// FinanceHandler 财务HTTP处理器
type FinanceHandler struct {
appService finance.FinanceApplicationService
invoiceAppService finance.InvoiceApplicationService
adminInvoiceAppService finance.AdminInvoiceApplicationService
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
appService finance.FinanceApplicationService
invoiceAppService finance.InvoiceApplicationService
adminInvoiceAppService finance.AdminInvoiceApplicationService
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
}
// NewFinanceHandler 创建财务HTTP处理器
@@ -201,6 +203,123 @@ func (h *FinanceHandler) HandleAlipayCallback(c *gin.Context) {
c.String(200, "success")
}
// HandleWechatPayCallback 处理微信支付回调
// @Summary 微信支付回调
// @Description 处理微信支付异步通知
// @Tags 支付管理
// @Accept application/json
// @Produce text/plain
// @Success 200 {string} string "success"
// @Failure 400 {string} string "fail"
// @Router /api/v1/pay/wechat/callback [post]
func (h *FinanceHandler) HandleWechatPayCallback(c *gin.Context) {
// 记录回调请求信息
h.logger.Info("收到微信支付回调请求",
zap.String("method", c.Request.Method),
zap.String("url", c.Request.URL.String()),
zap.String("remote_addr", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
zap.String("content_type", c.GetHeader("Content-Type")),
)
// 读取请求体内容用于调试(注意:读取后需要重新设置,否则后续解析会失败)
bodyBytes, err := c.GetRawData()
if err == nil && len(bodyBytes) > 0 {
h.logger.Info("微信支付回调请求体",
zap.String("body", string(bodyBytes)),
zap.Int("body_size", len(bodyBytes)),
)
// 重新设置请求体,供后续解析使用
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// 通过应用服务处理微信支付回调
err = h.appService.HandleWechatPayCallback(c.Request.Context(), c.Request)
if err != nil {
h.logger.Error("微信支付回调处理失败",
zap.Error(err),
zap.String("remote_addr", c.ClientIP()),
)
c.String(400, "fail")
return
}
h.logger.Info("微信支付回调处理成功", zap.String("remote_addr", c.ClientIP()))
// 返回成功响应微信要求返回success
c.String(200, "success")
}
// HandleWechatRefundCallback 处理微信退款回调
// @Summary 微信退款回调
// @Description 处理微信退款异步通知
// @Tags 支付管理
// @Accept application/json
// @Produce text/plain
// @Success 200 {string} string "success"
// @Failure 400 {string} string "fail"
// @Router /api/v1/wechat/refund_callback [post]
func (h *FinanceHandler) HandleWechatRefundCallback(c *gin.Context) {
// 记录回调请求信息
h.logger.Info("收到微信退款回调请求",
zap.String("method", c.Request.Method),
zap.String("url", c.Request.URL.String()),
zap.String("remote_addr", c.ClientIP()),
zap.String("user_agent", c.GetHeader("User-Agent")),
)
// 通过应用服务处理微信退款回调
err := h.appService.HandleWechatRefundCallback(c.Request.Context(), c.Request)
if err != nil {
h.logger.Error("微信退款回调处理失败", zap.Error(err))
c.String(400, "fail")
return
}
// 返回成功响应微信要求返回success
c.String(200, "success")
}
// GetWechatOrderStatus 获取微信订单状态
// @Summary 获取微信订单状态
// @Description 根据商户订单号查询微信订单状态
// @Tags 钱包管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param out_trade_no query string true "商户订单号"
// @Success 200 {object} responses.WechatOrderStatusResponse "获取订单状态成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 404 {object} map[string]interface{} "订单不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/finance/wallet/wechat-order-status [get]
func (h *FinanceHandler) GetWechatOrderStatus(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
outTradeNo := c.Query("out_trade_no")
if outTradeNo == "" {
h.responseBuilder.BadRequest(c, "缺少商户订单号")
return
}
result, err := h.appService.GetWechatOrderStatus(c.Request.Context(), outTradeNo)
if err != nil {
h.logger.Error("获取微信订单状态失败",
zap.String("user_id", userID),
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
h.responseBuilder.BadRequest(c, "获取订单状态失败: "+err.Error())
return
}
h.responseBuilder.Success(c, result, "获取订单状态成功")
}
// HandleAlipayReturn 处理支付宝同步回调
// @Summary 支付宝同步回调
// @Description 处理支付宝同步支付通知,跳转到前端成功页面
@@ -240,7 +359,7 @@ func (h *FinanceHandler) HandleAlipayReturn(c *gin.Context) {
// 通过应用服务处理同步回调,查询订单状态
orderStatus, err := h.appService.HandleAlipayReturn(c.Request.Context(), outTradeNo)
if err != nil {
h.logger.Error("支付宝同步回调处理失败",
h.logger.Error("支付宝同步回调处理失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err))
h.redirectToFailPage(c, outTradeNo, "订单处理失败")
@@ -257,7 +376,7 @@ func (h *FinanceHandler) HandleAlipayReturn(c *gin.Context) {
switch orderStatus {
case "TRADE_SUCCESS":
// 支付成功,跳转到前端成功页面
successURL := fmt.Sprintf("%s/finance/wallet/success?out_trade_no=%s&trade_no=%s&amount=%s",
successURL := fmt.Sprintf("%s/finance/wallet/success?out_trade_no=%s&trade_no=%s&amount=%s",
frontendDomain, outTradeNo, tradeNo, totalAmount)
c.Redirect(http.StatusFound, successURL)
case "WAIT_BUYER_PAY":
@@ -275,8 +394,8 @@ func (h *FinanceHandler) redirectToFailPage(c *gin.Context, outTradeNo, reason s
if gin.Mode() == gin.DebugMode {
frontendDomain = "http://localhost:5173"
}
failURL := fmt.Sprintf("%s/finance/wallet/fail?out_trade_no=%s&reason=%s",
failURL := fmt.Sprintf("%s/finance/wallet/fail?out_trade_no=%s&reason=%s",
frontendDomain, outTradeNo, reason)
c.Redirect(http.StatusFound, failURL)
}
@@ -287,8 +406,8 @@ func (h *FinanceHandler) redirectToProcessingPage(c *gin.Context, outTradeNo, am
if gin.Mode() == gin.DebugMode {
frontendDomain = "http://localhost:5173"
}
processingURL := fmt.Sprintf("%s/finance/wallet/processing?out_trade_no=%s&amount=%s",
processingURL := fmt.Sprintf("%s/finance/wallet/processing?out_trade_no=%s&amount=%s",
frontendDomain, outTradeNo, amount)
c.Redirect(http.StatusFound, processingURL)
}
@@ -319,7 +438,6 @@ func (h *FinanceHandler) CreateAlipayRecharge(c *gin.Context) {
return
}
// 调用应用服务进行完整的业务流程编排
result, err := h.appService.CreateAlipayRechargeOrder(c.Request.Context(), &cmd)
if err != nil {
@@ -343,6 +461,53 @@ func (h *FinanceHandler) CreateAlipayRecharge(c *gin.Context) {
h.responseBuilder.Success(c, result, "支付宝充值订单创建成功")
}
// CreateWechatRecharge 创建微信充值订单
// @Summary 创建微信充值订单
// @Description 创建微信充值订单并返回预支付数据
// @Tags 钱包管理
// @Accept json
// @Produce json
// @Security Bearer
// @Param request body commands.CreateWechatRechargeCommand true "微信充值请求"
// @Success 200 {object} responses.WechatRechargeOrderResponse "创建充值订单成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/finance/wallet/wechat-recharge [post]
func (h *FinanceHandler) CreateWechatRecharge(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
var cmd commands.CreateWechatRechargeCommand
cmd.UserID = userID
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
return
}
result, err := h.appService.CreateWechatRechargeOrder(c.Request.Context(), &cmd)
if err != nil {
h.logger.Error("创建微信充值订单失败",
zap.String("user_id", userID),
zap.String("amount", cmd.Amount),
zap.Error(err),
)
h.responseBuilder.BadRequest(c, "创建微信充值订单失败: "+err.Error())
return
}
h.logger.Info("微信充值订单创建成功",
zap.String("user_id", userID),
zap.String("out_trade_no", result.OutTradeNo),
zap.String("amount", cmd.Amount),
zap.String("platform", cmd.Platform),
)
h.responseBuilder.Success(c, result, "微信充值订单创建成功")
}
// TransferRecharge 管理员对公转账充值
func (h *FinanceHandler) TransferRecharge(c *gin.Context) {
var cmd commands.TransferRechargeCommand
@@ -849,8 +1014,6 @@ func (h *FinanceHandler) ApproveInvoiceApplication(c *gin.Context) {
return
}
h.responseBuilder.Success(c, nil, "通过发票申请成功")
}
@@ -932,14 +1095,14 @@ func (h *FinanceHandler) AdminDownloadInvoiceFile(c *gin.Context) {
// @Router /api/v1/debug/event-system [post]
func (h *FinanceHandler) DebugEventSystem(c *gin.Context) {
h.logger.Info("🔍 请求事件系统调试信息")
// 这里可以添加事件系统的状态信息
// 暂时返回基本信息
debugInfo := map[string]interface{}{
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"message": "事件系统调试端点已启用",
"handler": "FinanceHandler",
"message": "事件系统调试端点已启用",
"handler": "FinanceHandler",
}
h.responseBuilder.Success(c, debugInfo, "事件系统调试信息")
}

View File

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

View File

@@ -2,27 +2,35 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"strings"
"tyapi-server/internal/application/product"
"tyapi-server/internal/application/product/dto/commands"
"tyapi-server/internal/application/product/dto/queries"
_ "tyapi-server/internal/application/product/dto/responses"
"tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/shared/interfaces"
"tyapi-server/internal/shared/pdf"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"go.uber.org/zap"
)
// ProductHandler 产品相关HTTP处理器
type ProductHandler struct {
appService product.ProductApplicationService
apiConfigService product.ProductApiConfigApplicationService
categoryService product.CategoryApplicationService
subAppService product.SubscriptionApplicationService
appService product.ProductApplicationService
apiConfigService product.ProductApiConfigApplicationService
categoryService product.CategoryApplicationService
subAppService product.SubscriptionApplicationService
documentationAppService product.DocumentationApplicationServiceInterface
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
logger *zap.Logger
responseBuilder interfaces.ResponseBuilder
validator interfaces.RequestValidator
pdfGenerator *pdf.PDFGenerator
pdfCacheManager *pdf.PDFCacheManager
logger *zap.Logger
}
// NewProductHandler 创建产品HTTP处理器
@@ -34,17 +42,21 @@ func NewProductHandler(
documentationAppService product.DocumentationApplicationServiceInterface,
responseBuilder interfaces.ResponseBuilder,
validator interfaces.RequestValidator,
pdfGenerator *pdf.PDFGenerator,
pdfCacheManager *pdf.PDFCacheManager,
logger *zap.Logger,
) *ProductHandler {
return &ProductHandler{
appService: appService,
apiConfigService: apiConfigService,
categoryService: categoryService,
subAppService: subAppService,
appService: appService,
apiConfigService: apiConfigService,
categoryService: categoryService,
subAppService: subAppService,
documentationAppService: documentationAppService,
responseBuilder: responseBuilder,
validator: validator,
logger: logger,
responseBuilder: responseBuilder,
validator: validator,
pdfGenerator: pdfGenerator,
pdfCacheManager: pdfCacheManager,
logger: logger,
}
}
@@ -574,32 +586,77 @@ func (h *ProductHandler) GetMySubscriptionUsage(c *gin.Context) {
return
}
// 先获取订阅信息以验证权限
result, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID)
if err != nil {
h.logger.Error("获取我的订阅使用情况失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
h.responseBuilder.NotFound(c, "订阅不存在")
return
}
// 验证订阅是否属于当前用户(通过获取订阅详情来验证)
var query queries.GetSubscriptionQuery
query.ID = subscriptionID
subscription, err := h.subAppService.GetSubscriptionByID(c.Request.Context(), &query)
if err != nil {
h.logger.Error("获取订阅信息失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
h.logger.Error("获取订阅详情失败", zap.Error(err), zap.String("subscription_id", subscriptionID))
h.responseBuilder.NotFound(c, "订阅不存在")
return
}
// 验证订阅是否属于当前用户
if subscription.UserID != userID {
h.logger.Error("用户尝试访问不属于自己的订阅使用情况", zap.String("user_id", userID), zap.String("subscription_user_id", subscription.UserID), zap.String("subscription_id", subscriptionID))
h.logger.Error("用户尝试访问不属于自己的订阅", zap.String("user_id", userID), zap.String("subscription_user_id", subscription.UserID), zap.String("subscription_id", subscriptionID))
h.responseBuilder.Forbidden(c, "无权访问此订阅")
return
}
usage, err := h.subAppService.GetSubscriptionUsage(c.Request.Context(), subscriptionID)
if err != nil {
h.logger.Error("获取订阅使用情况失败", zap.Error(err))
h.responseBuilder.BadRequest(c, err.Error())
h.responseBuilder.Success(c, result, "获取我的订阅使用情况成功")
}
// CancelMySubscription 取消我的订阅
// @Summary 取消我的订阅
// @Description 取消指定的订阅(软删除)
// @Tags 我的订阅
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "订阅ID"
// @Success 200 {object} map[string]interface{} "取消订阅成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 403 {object} map[string]interface{} "无权操作"
// @Failure 404 {object} map[string]interface{} "订阅不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/my/subscriptions/{id}/cancel [post]
func (h *ProductHandler) CancelMySubscription(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
h.responseBuilder.Unauthorized(c, "用户未登录")
return
}
h.responseBuilder.Success(c, usage, "获取使用情况成功")
subscriptionID := c.Param("id")
if subscriptionID == "" {
h.responseBuilder.BadRequest(c, "订阅ID不能为空")
return
}
err := h.subAppService.CancelMySubscription(c.Request.Context(), userID, subscriptionID)
if err != nil {
h.logger.Error("取消订阅失败", zap.Error(err), zap.String("user_id", userID), zap.String("subscription_id", subscriptionID))
// 根据错误类型返回不同的响应
if err.Error() == "订阅不存在" {
h.responseBuilder.NotFound(c, "订阅不存在")
} else if err.Error() == "无权取消此订阅" {
h.responseBuilder.Forbidden(c, "无权取消此订阅")
} else {
h.responseBuilder.BadRequest(c, err.Error())
}
return
}
h.responseBuilder.Success(c, nil, "取消订阅成功")
}
// GetProductDocumentation 获取产品文档
@@ -630,3 +687,329 @@ func (h *ProductHandler) GetProductDocumentation(c *gin.Context) {
h.responseBuilder.Success(c, doc, "获取文档成功")
}
// DownloadProductDocumentation 下载产品接口文档PDF文件
// @Summary 下载产品接口文档
// @Description 根据产品ID从数据库获取产品信息和文档信息动态生成PDF文档并下载。
// @Tags 数据大厅
// @Accept json
// @Produce application/pdf
// @Param id path string true "产品ID"
// @Success 200 {file} file "PDF文档文件"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 404 {object} map[string]interface{} "产品不存在"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/products/{id}/documentation/download [get]
func (h *ProductHandler) DownloadProductDocumentation(c *gin.Context) {
productID := c.Param("id")
if productID == "" {
h.responseBuilder.BadRequest(c, "产品ID不能为空")
return
}
// 检查PDF生成器是否可用
if h.pdfGenerator == nil {
h.logger.Error("PDF生成器未初始化")
h.responseBuilder.InternalError(c, "PDF生成器未初始化")
return
}
// 获取产品信息
product, err := h.appService.GetProductByID(c.Request.Context(), &queries.GetProductQuery{ID: productID})
if err != nil {
h.logger.Error("获取产品信息失败", zap.Error(err))
h.responseBuilder.NotFound(c, "产品不存在")
return
}
// 检查产品编码是否存在
if product.Code == "" {
h.logger.Warn("产品编码为空", zap.String("product_id", productID))
h.responseBuilder.BadRequest(c, "产品编码不存在")
return
}
h.logger.Info("开始生成PDF文档",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
zap.String("product_name", product.Name),
)
// 获取产品文档信息
doc, docErr := h.documentationAppService.GetDocumentationByProductID(c.Request.Context(), productID)
if docErr != nil {
h.logger.Warn("获取产品文档失败,将只生成产品基本信息",
zap.String("product_id", productID),
zap.Error(docErr),
)
}
// 将响应类型转换为entity类型
var docEntity *entities.ProductDocumentation
var docVersion string
if doc != nil {
docEntity = &entities.ProductDocumentation{
ID: doc.ID,
ProductID: doc.ProductID,
RequestURL: doc.RequestURL,
RequestMethod: doc.RequestMethod,
BasicInfo: doc.BasicInfo,
RequestParams: doc.RequestParams,
ResponseFields: doc.ResponseFields,
ResponseExample: doc.ResponseExample,
ErrorCodes: doc.ErrorCodes,
Version: doc.Version,
}
docVersion = doc.Version
} else {
// 如果没有文档,使用默认版本号
docVersion = "1.0"
}
// 如果是组合包,获取子产品的文档信息
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),
)
defer func() {
if r := recover(); r != nil {
h.logger.Error("PDF生成过程中发生panic",
zap.String("product_id", productID),
zap.Any("panic_value", r),
)
// 确保在panic时也能返回响应
if !c.Writer.Written() {
h.responseBuilder.InternalError(c, fmt.Sprintf("生成PDF文档时发生错误: %v", r))
}
}
}()
// 构建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生成器",
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),
zap.Int("pdf_size", len(pdfBytes)),
)
if genErr != nil {
h.logger.Error("生成PDF文档失败",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
zap.Error(genErr),
)
h.responseBuilder.InternalError(c, fmt.Sprintf("生成PDF文档失败: %s", genErr.Error()))
return
}
h.logger.Info("PDF生成器调用完成",
zap.String("product_id", productID),
zap.Int("pdf_size", len(pdfBytes)),
)
if len(pdfBytes) == 0 {
h.logger.Error("生成的PDF文档为空",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
)
h.responseBuilder.InternalError(c, "生成的PDF文档为空")
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 == "" {
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, "|", "_")
h.logger.Info("成功生成PDF文档",
zap.String("product_id", productID),
zap.String("product_code", product.Code),
zap.String("file_name", fileName),
zap.Int("file_size", len(pdfBytes)),
zap.Bool("cached", false),
)
// 设置响应头并返回PDF文件
c.Header("Content-Type", "application/pdf")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Header("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))
c.Header("X-Cache", "MISS") // 添加缓存未命中标识
c.Data(http.StatusOK, "application/pdf", pdfBytes)
}

View File

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

View File

@@ -42,6 +42,18 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
alipayGroup.GET("/return", r.financeHandler.HandleAlipayReturn) // 支付宝同步回调
}
// 微信支付回调路由(不需要认证)
wechatPayGroup := engine.Group("/api/v1/pay/wechat")
{
wechatPayGroup.POST("/callback", r.financeHandler.HandleWechatPayCallback) // 微信支付异步回调
}
// 微信退款回调路由(不需要认证)
wechatRefundGroup := engine.Group("/api/v1/wechat")
{
wechatRefundGroup.POST("/refund_callback", r.financeHandler.HandleWechatRefundCallback) // 微信退款异步回调
}
// 财务路由组,需要用户认证
financeGroup := engine.Group("/api/v1/finance")
financeGroup.Use(r.authMiddleware.Handle())
@@ -49,12 +61,14 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
// 钱包相关路由
walletGroup := financeGroup.Group("/wallet")
{
walletGroup.GET("", r.financeHandler.GetWallet) // 获取钱包信息
walletGroup.GET("/transactions", r.financeHandler.GetUserWalletTransactions) // 获取钱包交易记录
walletGroup.GET("/recharge-config", r.financeHandler.GetRechargeConfig) // 获取充值配置
walletGroup.POST("/alipay-recharge", r.financeHandler.CreateAlipayRecharge) // 创建支付宝充值订单
walletGroup.GET("/recharge-records", r.financeHandler.GetUserRechargeRecords) // 用户充值记录分页
walletGroup.GET("", r.financeHandler.GetWallet) // 获取钱包信息
walletGroup.GET("/transactions", r.financeHandler.GetUserWalletTransactions) // 获取钱包交易记录
walletGroup.GET("/recharge-config", r.financeHandler.GetRechargeConfig) // 获取充值配置
walletGroup.POST("/alipay-recharge", r.financeHandler.CreateAlipayRecharge) // 创建支付宝充值订单
walletGroup.POST("/wechat-recharge", r.financeHandler.CreateWechatRecharge) // 创建微信充值订单
walletGroup.GET("/recharge-records", r.financeHandler.GetUserRechargeRecords) // 用户充值记录分页
walletGroup.GET("/alipay-order-status", r.financeHandler.GetAlipayOrderStatus) // 获取支付宝订单状态
walletGroup.GET("/wechat-order-status", r.financeHandler.GetWechatOrderStatus) // 获取微信订单状态
}
}
@@ -62,11 +76,11 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
invoiceGroup := engine.Group("/api/v1/invoices")
invoiceGroup.Use(r.authMiddleware.Handle())
{
invoiceGroup.POST("/apply", r.financeHandler.ApplyInvoice) // 申请开票
invoiceGroup.GET("/info", r.financeHandler.GetUserInvoiceInfo) // 获取用户发票信息
invoiceGroup.PUT("/info", r.financeHandler.UpdateUserInvoiceInfo) // 更新用户发票信息
invoiceGroup.GET("/records", r.financeHandler.GetUserInvoiceRecords) // 获取用户开票记录
invoiceGroup.GET("/available-amount", r.financeHandler.GetAvailableAmount) // 获取可开票金额
invoiceGroup.POST("/apply", r.financeHandler.ApplyInvoice) // 申请开票
invoiceGroup.GET("/info", r.financeHandler.GetUserInvoiceInfo) // 获取用户发票信息
invoiceGroup.PUT("/info", r.financeHandler.UpdateUserInvoiceInfo) // 更新用户发票信息
invoiceGroup.GET("/records", r.financeHandler.GetUserInvoiceRecords) // 获取用户开票记录
invoiceGroup.GET("/available-amount", r.financeHandler.GetAvailableAmount) // 获取可开票金额
invoiceGroup.GET("/:application_id/download", r.financeHandler.DownloadInvoiceFile) // 下载发票文件
}
@@ -74,8 +88,8 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
adminFinanceGroup := engine.Group("/api/v1/admin/finance")
adminFinanceGroup.Use(r.adminAuthMiddleware.Handle())
{
adminFinanceGroup.POST("/transfer-recharge", r.financeHandler.TransferRecharge) // 对公转账充值
adminFinanceGroup.POST("/gift-recharge", r.financeHandler.GiftRecharge) // 赠送充值
adminFinanceGroup.POST("/transfer-recharge", r.financeHandler.TransferRecharge) // 对公转账充值
adminFinanceGroup.POST("/gift-recharge", r.financeHandler.GiftRecharge) // 赠送充值
adminFinanceGroup.GET("/recharge-records", r.financeHandler.GetAdminRechargeRecords) // 管理员充值记录分页
}
@@ -83,7 +97,7 @@ func (r *FinanceRoutes) Register(router *sharedhttp.GinRouter) {
adminInvoiceGroup := engine.Group("/api/v1/admin/invoices")
adminInvoiceGroup.Use(r.adminAuthMiddleware.Handle())
{
adminInvoiceGroup.GET("/pending", r.financeHandler.GetPendingApplications) // 获取待处理申请列表
adminInvoiceGroup.GET("/pending", r.financeHandler.GetPendingApplications) // 获取待处理申请列表
adminInvoiceGroup.POST("/:application_id/approve", r.financeHandler.ApproveInvoiceApplication) // 通过发票申请
adminInvoiceGroup.POST("/:application_id/reject", r.financeHandler.RejectInvoiceApplication) // 拒绝发票申请
adminInvoiceGroup.GET("/:application_id/download", r.financeHandler.AdminDownloadInvoiceFile) // 下载发票文件

View File

@@ -51,6 +51,7 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
products.GET("/:id", r.productHandler.GetProductDetail)
products.GET("/:id/api-config", r.productHandler.GetProductApiConfig)
products.GET("/:id/documentation", r.productHandler.GetProductDocumentation)
products.GET("/:id/documentation/download", r.productHandler.DownloadProductDocumentation)
// 订阅产品(需要认证)
products.POST("/:id/subscribe", r.auth.Handle(), r.productHandler.SubscribeProduct)
@@ -82,6 +83,9 @@ func (r *ProductRoutes) Register(router *sharedhttp.GinRouter) {
// 获取订阅使用情况
subscriptions.GET("/:id/usage", r.productHandler.GetMySubscriptionUsage)
// 取消订阅
subscriptions.POST("/:id/cancel", r.productHandler.CancelMySubscription)
}
}

View File

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

View File

@@ -17,17 +17,24 @@ import (
// ArticleTaskHandler 文章任务处理器
type ArticleTaskHandler struct {
logger *zap.Logger
articleApplicationService article.ArticleApplicationService
asyncTaskRepo repositories.AsyncTaskRepository
logger *zap.Logger
articleApplicationService article.ArticleApplicationService
announcementApplicationService article.AnnouncementApplicationService
asyncTaskRepo repositories.AsyncTaskRepository
}
// NewArticleTaskHandler 创建文章任务处理器
func NewArticleTaskHandler(logger *zap.Logger, articleApplicationService article.ArticleApplicationService, asyncTaskRepo repositories.AsyncTaskRepository) *ArticleTaskHandler {
func NewArticleTaskHandler(
logger *zap.Logger,
articleApplicationService article.ArticleApplicationService,
announcementApplicationService article.AnnouncementApplicationService,
asyncTaskRepo repositories.AsyncTaskRepository,
) *ArticleTaskHandler {
return &ArticleTaskHandler{
logger: logger,
articleApplicationService: articleApplicationService,
asyncTaskRepo: asyncTaskRepo,
logger: logger,
articleApplicationService: articleApplicationService,
announcementApplicationService: announcementApplicationService,
asyncTaskRepo: asyncTaskRepo,
}
}
@@ -112,6 +119,47 @@ func (h *ArticleTaskHandler) HandleArticleModify(ctx context.Context, t *asynq.T
return nil
}
// HandleAnnouncementPublish 处理公告发布任务
func (h *ArticleTaskHandler) HandleAnnouncementPublish(ctx context.Context, t *asynq.Task) error {
h.logger.Info("开始处理公告发布任务")
var payload AnnouncementPublishPayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析公告发布任务载荷失败", zap.Error(err))
h.updateTaskStatus(ctx, t, "failed", "解析任务载荷失败")
return err
}
h.logger.Info("处理公告发布任务",
zap.String("announcement_id", payload.AnnouncementID),
zap.Time("publish_at", payload.PublishAt))
// 检查任务是否已被取消
if err := h.checkTaskStatus(ctx, t); err != nil {
h.logger.Info("任务已被取消,跳过执行", zap.String("announcement_id", payload.AnnouncementID))
return nil // 静默返回,不报错
}
// 调用公告应用服务发布公告
if h.announcementApplicationService != nil {
err := h.announcementApplicationService.PublishAnnouncementByID(ctx, payload.AnnouncementID)
if err != nil {
h.logger.Error("公告发布失败", zap.String("announcement_id", payload.AnnouncementID), zap.Error(err))
h.updateTaskStatus(ctx, t, "failed", "公告发布失败: "+err.Error())
return err
}
} else {
h.logger.Warn("公告应用服务未初始化,跳过发布", zap.String("announcement_id", payload.AnnouncementID))
h.updateTaskStatus(ctx, t, "failed", "公告应用服务未初始化")
return nil
}
// 更新任务状态为成功
h.updateTaskStatus(ctx, t, "completed", "")
h.logger.Info("公告发布任务处理完成", zap.String("announcement_id", payload.AnnouncementID))
return nil
}
// ArticlePublishPayload 文章发布任务载荷
type ArticlePublishPayload struct {
ArticleID string `json:"article_id"`
@@ -157,9 +205,9 @@ func (p *ArticleCancelPayload) FromJSON(data []byte) error {
// ArticleModifyPayload 文章修改任务载荷
type ArticleModifyPayload struct {
ArticleID string `json:"article_id"`
NewPublishAt time.Time `json:"new_publish_at"`
UserID string `json:"user_id"`
ArticleID string `json:"article_id"`
NewPublishAt time.Time `json:"new_publish_at"`
UserID string `json:"user_id"`
}
// GetType 获取任务类型
@@ -177,6 +225,28 @@ func (p *ArticleModifyPayload) FromJSON(data []byte) error {
return json.Unmarshal(data, p)
}
// AnnouncementPublishPayload 公告发布任务载荷
type AnnouncementPublishPayload struct {
AnnouncementID string `json:"announcement_id"`
PublishAt time.Time `json:"publish_at"`
UserID string `json:"user_id"`
}
// GetType 获取任务类型
func (p *AnnouncementPublishPayload) GetType() types.TaskType {
return types.TaskTypeAnnouncementPublish
}
// ToJSON 序列化为JSON
func (p *AnnouncementPublishPayload) ToJSON() ([]byte, error) {
return json.Marshal(p)
}
// FromJSON 从JSON反序列化
func (p *AnnouncementPublishPayload) FromJSON(data []byte) error {
return json.Unmarshal(data, p)
}
// updateTaskStatus 更新任务状态
func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task, status string, errorMsg string) {
// 从任务载荷中提取任务ID
@@ -189,9 +259,11 @@ func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task
// 尝试从payload中获取任务ID
taskID, ok := payload["task_id"].(string)
if !ok {
// 如果没有task_id尝试从article_id生成
// 如果没有task_id尝试从article_id或announcement_id生成
if articleID, ok := payload["article_id"].(string); ok {
taskID = fmt.Sprintf("article-publish-%s", articleID)
} else if announcementID, ok := payload["announcement_id"].(string); ok {
taskID = fmt.Sprintf("announcement-publish-%s", announcementID)
} else {
h.logger.Error("无法从任务载荷中获取任务ID")
return
@@ -205,7 +277,7 @@ func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task
} else if status == "completed" {
// 成功时:清除错误信息并更新状态
if err := h.asyncTaskRepo.UpdateStatusWithSuccess(ctx, taskID, entities.TaskStatus(status)); err != nil {
h.logger.Error("更新任务状态失败",
h.logger.Error("更新任务状态失败",
zap.String("task_id", taskID),
zap.String("status", status),
zap.Error(err))
@@ -213,14 +285,14 @@ func (h *ArticleTaskHandler) updateTaskStatus(ctx context.Context, t *asynq.Task
} else {
// 其他状态:只更新状态
if err := h.asyncTaskRepo.UpdateStatus(ctx, taskID, entities.TaskStatus(status)); err != nil {
h.logger.Error("更新任务状态失败",
h.logger.Error("更新任务状态失败",
zap.String("task_id", taskID),
zap.String("status", status),
zap.Error(err))
}
}
h.logger.Info("任务状态已更新",
h.logger.Info("任务状态已更新",
zap.String("task_id", taskID),
zap.String("status", status),
zap.String("error_msg", errorMsg))
@@ -237,29 +309,29 @@ func (h *ArticleTaskHandler) handleTaskFailure(ctx context.Context, taskID strin
// 增加重试次数
newRetryCount := task.RetryCount + 1
// 检查是否达到最大重试次数
if newRetryCount >= task.MaxRetries {
// 达到最大重试次数,标记为最终失败
if err := h.asyncTaskRepo.UpdateStatusWithRetryAndError(ctx, taskID, entities.TaskStatusFailed, errorMsg); err != nil {
h.logger.Error("更新任务状态失败",
h.logger.Error("更新任务状态失败",
zap.String("task_id", taskID),
zap.String("status", "failed"),
zap.Error(err))
}
h.logger.Info("任务最终失败,已达到最大重试次数",
h.logger.Info("任务最终失败,已达到最大重试次数",
zap.String("task_id", taskID),
zap.Int("retry_count", newRetryCount),
zap.Int("max_retries", task.MaxRetries))
} else {
// 未达到最大重试次数保持pending状态记录错误信息
if err := h.asyncTaskRepo.UpdateRetryCountAndError(ctx, taskID, newRetryCount, errorMsg); err != nil {
h.logger.Error("更新任务重试次数失败",
h.logger.Error("更新任务重试次数失败",
zap.String("task_id", taskID),
zap.Int("retry_count", newRetryCount),
zap.Error(err))
}
h.logger.Info("任务失败,准备重试",
h.logger.Info("任务失败,准备重试",
zap.String("task_id", taskID),
zap.Int("retry_count", newRetryCount),
zap.Int("max_retries", task.MaxRetries))
@@ -278,9 +350,11 @@ func (h *ArticleTaskHandler) checkTaskStatus(ctx context.Context, t *asynq.Task)
// 尝试从payload中获取任务ID
taskID, ok := payload["task_id"].(string)
if !ok {
// 如果没有task_id尝试从article_id生成
// 如果没有task_id尝试从article_id或announcement_id生成
if articleID, ok := payload["article_id"].(string); ok {
taskID = fmt.Sprintf("article-publish-%s", articleID)
} else if announcementID, ok := payload["announcement_id"].(string); ok {
taskID = fmt.Sprintf("announcement-publish-%s", announcementID)
} else {
h.logger.Error("无法从任务载荷中获取任务ID")
return fmt.Errorf("无法获取任务ID")
@@ -301,4 +375,4 @@ func (h *ArticleTaskHandler) checkTaskStatus(ctx context.Context, t *asynq.Task)
}
return nil
}
}

View File

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

View File

@@ -17,10 +17,10 @@ import (
// TaskManagerImpl 任务管理器实现
type TaskManagerImpl struct {
asynqClient *asynq.Client
asyncTaskRepo repositories.AsyncTaskRepository
logger *zap.Logger
config *interfaces.TaskManagerConfig
asynqClient *asynq.Client
asyncTaskRepo repositories.AsyncTaskRepository
logger *zap.Logger
config *interfaces.TaskManagerConfig
}
// NewTaskManager 创建任务管理器
@@ -42,7 +42,7 @@ func NewTaskManager(
func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entities.AsyncTask) error {
// 1. 保存任务到数据库GORM会自动生成UUID
if err := tm.asyncTaskRepo.Create(ctx, task); err != nil {
tm.logger.Error("保存任务到数据库失败",
tm.logger.Error("保存任务到数据库失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("保存任务失败: %w", err)
@@ -50,7 +50,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entit
// 2. 更新payload中的task_id
if err := tm.updatePayloadTaskID(task); err != nil {
tm.logger.Error("更新payload中的任务ID失败",
tm.logger.Error("更新payload中的任务ID失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新payload中的任务ID失败: %w", err)
@@ -58,7 +58,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entit
// 3. 更新数据库中的payload
if err := tm.asyncTaskRepo.Update(ctx, task); err != nil {
tm.logger.Error("更新任务payload失败",
tm.logger.Error("更新任务payload失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新任务payload失败: %w", err)
@@ -71,7 +71,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueTask(ctx context.Context, task *entit
return fmt.Errorf("任务入队失败: %w", err)
}
tm.logger.Info("任务创建并入队成功",
tm.logger.Info("任务创建并入队成功",
zap.String("task_id", task.ID),
zap.String("task_type", task.Type))
@@ -86,7 +86,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
// 2. 保存任务到数据库GORM会自动生成UUID
if err := tm.asyncTaskRepo.Create(ctx, task); err != nil {
tm.logger.Error("保存延时任务到数据库失败",
tm.logger.Error("保存延时任务到数据库失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("保存延时任务失败: %w", err)
@@ -94,7 +94,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
// 3. 更新payload中的task_id
if err := tm.updatePayloadTaskID(task); err != nil {
tm.logger.Error("更新payload中的任务ID失败",
tm.logger.Error("更新payload中的任务ID失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新payload中的任务ID失败: %w", err)
@@ -102,7 +102,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
// 4. 更新数据库中的payload
if err := tm.asyncTaskRepo.Update(ctx, task); err != nil {
tm.logger.Error("更新任务payload失败",
tm.logger.Error("更新任务payload失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新任务payload失败: %w", err)
@@ -115,7 +115,7 @@ func (tm *TaskManagerImpl) CreateAndEnqueueDelayedTask(ctx context.Context, task
return fmt.Errorf("延时任务入队失败: %w", err)
}
tm.logger.Info("延时任务创建并入队成功",
tm.logger.Info("延时任务创建并入队成功",
zap.String("task_id", task.ID),
zap.String("task_type", task.Type),
zap.Duration("delay", delay))
@@ -131,13 +131,13 @@ func (tm *TaskManagerImpl) CancelTask(ctx context.Context, taskID string) error
}
if err := tm.asyncTaskRepo.UpdateStatus(ctx, task.ID, entities.TaskStatusCancelled); err != nil {
tm.logger.Error("更新任务状态为取消失败",
tm.logger.Error("更新任务状态为取消失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("更新任务状态失败: %w", err)
}
tm.logger.Info("任务已标记为取消",
tm.logger.Info("任务已标记为取消",
zap.String("task_id", task.ID),
zap.String("task_type", task.Type))
@@ -152,14 +152,14 @@ func (tm *TaskManagerImpl) UpdateTaskSchedule(ctx context.Context, taskID string
return err
}
tm.logger.Info("找到要更新的任务",
tm.logger.Info("找到要更新的任务",
zap.String("task_id", task.ID),
zap.String("current_status", string(task.Status)),
zap.Time("current_scheduled_at", *task.ScheduledAt))
// 2. 取消旧任务
if err := tm.asyncTaskRepo.UpdateStatus(ctx, task.ID, entities.TaskStatusCancelled); err != nil {
tm.logger.Error("取消旧任务失败",
tm.logger.Error("取消旧任务失败",
zap.String("task_id", task.ID),
zap.Error(err))
return fmt.Errorf("取消旧任务失败: %w", err)
@@ -173,7 +173,7 @@ func (tm *TaskManagerImpl) UpdateTaskSchedule(ctx context.Context, taskID string
return err
}
tm.logger.Info("新任务已创建",
tm.logger.Info("新任务已创建",
zap.String("new_task_id", newTask.ID),
zap.Time("new_scheduled_at", newScheduledAt))
@@ -189,7 +189,7 @@ func (tm *TaskManagerImpl) UpdateTaskSchedule(ctx context.Context, taskID string
return fmt.Errorf("重新入队任务失败: %w", err)
}
tm.logger.Info("任务调度时间更新成功",
tm.logger.Info("任务调度时间更新成功",
zap.String("old_task_id", task.ID),
zap.String("new_task_id", newTask.ID),
zap.Time("new_scheduled_at", newScheduledAt))
@@ -237,7 +237,7 @@ func (tm *TaskManagerImpl) RetryTask(ctx context.Context, taskID string) error {
return fmt.Errorf("重试任务入队失败: %w", err)
}
tm.logger.Info("任务重试成功",
tm.logger.Info("任务重试成功",
zap.String("task_id", taskID),
zap.Int("retry_count", task.RetryCount))
@@ -248,7 +248,7 @@ func (tm *TaskManagerImpl) RetryTask(ctx context.Context, taskID string) error {
func (tm *TaskManagerImpl) CleanupExpiredTasks(ctx context.Context, olderThan time.Time) error {
// 这里可以实现清理逻辑,比如删除超过一定时间的已完成任务
tm.logger.Info("开始清理过期任务", zap.Time("older_than", olderThan))
// TODO: 实现清理逻辑
return nil
}
@@ -274,8 +274,7 @@ func (tm *TaskManagerImpl) updatePayloadTaskID(task *entities.AsyncTask) error {
return nil
}
// findTask 查找任务支持taskID和articleID双重查找
// findTask 查找任务支持taskID、articleID和announcementID三重查找
func (tm *TaskManagerImpl) findTask(ctx context.Context, taskID string) (*entities.AsyncTask, error) {
// 先尝试通过任务ID查找
task, err := tm.asyncTaskRepo.GetByID(ctx, taskID)
@@ -285,21 +284,34 @@ func (tm *TaskManagerImpl) findTask(ctx context.Context, taskID string) (*entiti
// 如果通过任务ID找不到尝试通过文章ID查找
tm.logger.Info("通过任务ID查找失败尝试通过文章ID查找", zap.String("task_id", taskID))
tasks, err := tm.asyncTaskRepo.GetByArticleID(ctx, taskID)
if err != nil || len(tasks) == 0 {
tm.logger.Error("通过文章ID也找不到任务",
if err == nil && len(tasks) > 0 {
// 使用找到的第一个任务
task = tasks[0]
tm.logger.Info("通过文章ID找到任务",
zap.String("article_id", taskID),
zap.String("task_id", task.ID))
return task, nil
}
// 如果通过文章ID也找不到尝试通过公告ID查找
tm.logger.Info("通过文章ID查找失败尝试通过公告ID查找", zap.String("task_id", taskID))
announcementTasks, err := tm.asyncTaskRepo.GetByAnnouncementID(ctx, taskID)
if err != nil || len(announcementTasks) == 0 {
tm.logger.Error("通过公告ID也找不到任务",
zap.String("announcement_id", taskID),
zap.Error(err))
return nil, fmt.Errorf("获取任务信息失败: %w", err)
}
// 使用找到的第一个任务
task = tasks[0]
tm.logger.Info("通过文章ID找到任务",
zap.String("article_id", taskID),
task = announcementTasks[0]
tm.logger.Info("通过公告ID找到任务",
zap.String("announcement_id", taskID),
zap.String("task_id", task.ID))
return task, nil
}
@@ -317,7 +329,7 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
// 保存到数据库GORM会自动生成UUID
if err := tm.asyncTaskRepo.Create(ctx, newTask); err != nil {
tm.logger.Error("创建新任务失败",
tm.logger.Error("创建新任务失败",
zap.String("new_task_id", newTask.ID),
zap.Error(err))
return nil, fmt.Errorf("创建新任务失败: %w", err)
@@ -325,7 +337,7 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
// 更新payload中的task_id
if err := tm.updatePayloadTaskID(newTask); err != nil {
tm.logger.Error("更新payload中的任务ID失败",
tm.logger.Error("更新payload中的任务ID失败",
zap.String("new_task_id", newTask.ID),
zap.Error(err))
return nil, fmt.Errorf("更新payload中的任务ID失败: %w", err)
@@ -333,7 +345,7 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
// 更新数据库中的payload
if err := tm.asyncTaskRepo.Update(ctx, newTask); err != nil {
tm.logger.Error("更新新任务payload失败",
tm.logger.Error("更新新任务payload失败",
zap.String("new_task_id", newTask.ID),
zap.Error(err))
return nil, fmt.Errorf("更新新任务payload失败: %w", err)
@@ -346,23 +358,24 @@ func (tm *TaskManagerImpl) createAndSaveTask(ctx context.Context, originalTask *
func (tm *TaskManagerImpl) enqueueTaskWithDelay(ctx context.Context, task *entities.AsyncTask, delay time.Duration) error {
queueName := tm.getQueueName(task.Type)
asynqTask := asynq.NewTask(task.Type, []byte(task.Payload))
var err error
if delay > 0 {
_, err = tm.asynqClient.EnqueueContext(ctx, asynqTask,
_, err = tm.asynqClient.EnqueueContext(ctx, asynqTask,
asynq.Queue(queueName),
asynq.ProcessIn(delay))
} else {
_, err = tm.asynqClient.EnqueueContext(ctx, asynqTask, asynq.Queue(queueName))
}
return err
}
// getQueueName 根据任务类型获取队列名称
func (tm *TaskManagerImpl) getQueueName(taskType string) string {
switch taskType {
case string(types.TaskTypeArticlePublish), string(types.TaskTypeArticleCancel), string(types.TaskTypeArticleModify):
case string(types.TaskTypeArticlePublish), string(types.TaskTypeArticleCancel), string(types.TaskTypeArticleModify),
string(types.TaskTypeAnnouncementPublish):
return "article"
case string(types.TaskTypeApiCall), string(types.TaskTypeApiLog), string(types.TaskTypeDeduction), string(types.TaskTypeUsageStats):
return "api"

View File

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

View File

@@ -9,12 +9,15 @@ const (
TaskTypeArticleCancel TaskType = "article_cancel"
TaskTypeArticleModify TaskType = "article_modify"
// 公告相关任务
TaskTypeAnnouncementPublish TaskType = "announcement_publish"
// API相关任务
TaskTypeApiCall TaskType = "api_call"
TaskTypeApiLog TaskType = "api_log"
// 财务相关任务
TaskTypeDeduction TaskType = "deduction"
TaskTypeDeduction TaskType = "deduction"
TaskTypeCompensation TaskType = "compensation"
// 产品相关任务
@@ -26,4 +29,4 @@ type TaskPayload interface {
GetType() TaskType
ToJSON() ([]byte, error)
FromJSON(data []byte) error
}
}

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