mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 19:37:33 +08:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e2aca968 | ||
|
|
52ea019374 | ||
|
|
4c738d1030 | ||
|
|
ec00f2d823 | ||
|
|
54542ff8ee | ||
|
|
0050c6bba3 | ||
|
|
4ceed8fd4b | ||
|
|
2e5dd8360e | ||
|
|
40ad48f5cf | ||
|
|
921bdc43cb | ||
|
|
0df7d8bf23 | ||
|
|
fdc75705aa | ||
|
|
a28dd4840b | ||
|
|
061b94cf61 | ||
|
|
0d28b322b7 | ||
|
|
ee06e110bd | ||
|
|
7acfa300ea | ||
|
|
b4689d2f99 | ||
|
|
6074d91467 | ||
|
|
e30e381adf | ||
|
|
516746f722 | ||
|
|
4da07b3ea4 | ||
|
|
da8a2ad169 | ||
|
|
e2832b9e36 | ||
|
|
bdb43531e8 | ||
|
|
51dbf0f03a | ||
|
|
10294e093f | ||
|
|
6816ab0550 | ||
|
|
357e09ef52 | ||
|
|
3a50af844e | ||
|
|
01c371b503 | ||
|
|
338a535531 | ||
|
|
19e92719c3 | ||
|
|
2727bef91b | ||
|
|
193ed24316 | ||
|
|
ba155bd253 | ||
|
|
4ca6e05fe0 | ||
|
|
169706bfbc | ||
|
|
2568d9b6a4 | ||
|
|
d3279ded92 | ||
|
|
5bcf1bb5ef | ||
|
|
547b58c7ba | ||
|
|
b9fbe58a3d | ||
|
|
6b92061d09 | ||
|
|
3aa2963211 | ||
|
|
6fa9036705 | ||
|
|
091be5ef70 | ||
|
|
a24d32776c | ||
|
|
982e4f942e | ||
|
|
9d2c4e8978 | ||
|
|
cd8c519b3a | ||
|
|
1eb37baa87 | ||
|
|
b97f56c455 | ||
|
|
8ced3d0327 | ||
|
|
bada678490 | ||
|
|
8be837fcbf | ||
|
|
cb0c77a565 | ||
|
|
2ef6e4debb | ||
|
|
5a4d3b9eb4 | ||
|
|
ade5e4d2ed | ||
|
|
595a0a917c | ||
|
|
d23a6b26e4 | ||
|
|
9690a63646 | ||
|
|
2a5bf19e7d | ||
|
|
eeeb2aefbb | ||
|
|
9c838e369f | ||
|
|
5a4918812a | ||
|
|
08af3d9b6f | ||
|
|
cafe2ce406 | ||
|
|
e481775e27 | ||
|
|
4c9cef249e | ||
|
|
056aa229fe | ||
|
|
6f8bcfd356 | ||
|
|
5b0e4ea4a7 | ||
|
|
fc77d43614 | ||
|
|
67828458b0 | ||
|
|
e51446abf8 | ||
|
|
1d6929db00 | ||
|
|
b58e805718 | ||
|
|
aa1aa47eba | ||
|
|
3aed6bd24d | ||
|
|
1c71156784 | ||
|
|
f2ee574fae | ||
|
|
074058ac5c | ||
|
|
07cb6977e4 | ||
|
|
baae1da1e0 | ||
|
|
9e7b214812 | ||
|
|
37004107d0 | ||
|
|
4aab45cda5 | ||
|
|
2853287b1d | ||
|
|
46e5cee810 | ||
|
|
fac32cdfe6 | ||
|
|
3a90a89b08 | ||
|
|
80a94c0f05 | ||
|
|
d49ce77350 | ||
|
|
292384f281 | ||
|
|
b8b0cc760d | ||
|
|
002267e436 | ||
|
|
0d54dffa19 | ||
|
|
d2c9d79658 | ||
|
|
f70850d465 | ||
|
|
223b1af714 | ||
|
|
76a64492a2 | ||
|
|
d6224ab25c | ||
|
|
9708157566 | ||
|
|
bfaf93c849 | ||
|
|
1b898eda37 |
62
.github/workflows/release.yml
vendored
Normal file
62
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Release
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
id-token: write
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
# 可选:添加输入参数,用于测试不同的场景
|
||||
inputs:
|
||||
version-suffix:
|
||||
description: 'Version suffix for testing (e.g., -test, -rc)'
|
||||
required: false
|
||||
default: '-test'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build Linux binary
|
||||
run: |
|
||||
chmod +x scripts/build.sh
|
||||
./scripts/build.sh build-linux
|
||||
|
||||
- name: Rename binary
|
||||
run: mv main urldb-${{ github.ref_name }}-linux-amd64
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build Frontend
|
||||
run: |
|
||||
cd web
|
||||
npm install --frozen-lockfile
|
||||
npm run build
|
||||
|
||||
- name: Create frontend archive
|
||||
run: |
|
||||
cd web
|
||||
tar -czf ../frontend-${{ github.ref_name }}.tar.gz .output/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
urldb-${{ github.ref_name }}-linux-amd64
|
||||
frontend-${{ github.ref_name }}.tar.gz
|
||||
generate_release_notes: true
|
||||
43
ChangeLog.md
Normal file
43
ChangeLog.md
Normal file
@@ -0,0 +1,43 @@
|
||||
### v1.3.1
|
||||
1. 添加API访问日志
|
||||
2. 添加首页公告
|
||||
3. TG机器人,添加资源选择模式
|
||||
|
||||
### v1.3.0
|
||||
1. 新增 Telegram Bot
|
||||
2. 新增扩容
|
||||
3. 支持迅雷云盘
|
||||
4. UI优化
|
||||
|
||||
### v1.2.5
|
||||
1. 修复一些Bug
|
||||
|
||||
### v1.2.4
|
||||
|
||||
1. 搜索增强,毫秒级响应,关键字高亮显示
|
||||
2. 修复版本显示不正确的问题
|
||||
3. 配置项新增Meilisearch配置
|
||||
|
||||
### v1.2.3
|
||||
1. 添加图片上传功能
|
||||
2. 添加Logo配置项,首页Logo显示
|
||||
3. 后台界面体验优化
|
||||
|
||||
### v1.2.1
|
||||
1. 修复转存移除广告失败的问题和添加广告失败的问题
|
||||
2. 管理后台UI优化
|
||||
3. 首页添加描述显示
|
||||
|
||||
### v1.2.0
|
||||
1. 新增手动批量转存
|
||||
2. 新增QQ机器人
|
||||
3. 新增任务管理功能
|
||||
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
|
||||
5. 新增支持第三方统计代码配置
|
||||
|
||||
### v1.0.0
|
||||
1. 支持API,手动批量录入资源
|
||||
2. 支持,自动判断资源有效性
|
||||
3. 支持自动转存
|
||||
4. 支持平台多账号管理(Quark)
|
||||
5. 支持简单的数据统计
|
||||
54
README.md
54
README.md
@@ -10,6 +10,10 @@
|
||||
|
||||
**一个现代化的网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘 **
|
||||
|
||||
免费电报资源频道: [@xypan](https://t.me/xypan) 自动推送资源
|
||||
|
||||
免费电报资源机器人: [@L9ResBot](https://t.me/L9ResBot) 发送 搜索 + 名字 可搜索资源
|
||||
|
||||
🌐 [在线演示](https://pan.l9.lc) | 📖 [文档](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink) | 🐛 [问题反馈](https://github.com/ctwj/urldb/issues) | ⭐ [给个星标](https://github.com/ctwj/urldb)
|
||||
|
||||
### 支持的网盘平台
|
||||
@@ -20,7 +24,7 @@
|
||||
| 阿里云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 夸克网盘 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
|
||||
| 天翼云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 迅雷云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 迅雷云盘 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
|
||||
| UC网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
@@ -34,31 +38,23 @@
|
||||
- [文档说明](https://ecn5khs4t956.feishu.cn/wiki/PsnDwtxghiP0mLkTiruczKtxnwd?from=from_copylink)
|
||||
- [服务器要求](https://ecn5khs4t956.feishu.cn/wiki/W8YBww1Mmiu4Cdkp5W4c8pFNnMf?from=from_copylink)
|
||||
- [QQ机器人](https://github.com/ctwj/astrbot_plugin_urldb)
|
||||
- [Telegram机器人](https://ecn5khs4t956.feishu.cn/wiki/SwkQw6AzRiFes7kxJXac3pd2ncb?from=from_copylink)
|
||||
|
||||
### v1.2.3
|
||||
1. 添加图片上传功能
|
||||
2. 添加Logo配置项,首页Logo显示
|
||||
3. 后台界面体验优化
|
||||
### v1.3.0
|
||||
1. 新增 [Telegram Bot](https://ecn5khs4t956.feishu.cn/wiki/SwkQw6AzRiFes7kxJXac3pd2ncb?from=from_copylink)
|
||||
2. 新增[扩容](https://ecn5khs4t956.feishu.cn/wiki/R3cPwEU6viTWfukHFNycM7O6nMd?from=from_copylink)
|
||||
3. 支持迅雷云盘
|
||||
4. UI优化
|
||||
|
||||
### v1.2.1
|
||||
1. 修复转存移除广告失败的问题和添加广告失败的问题
|
||||
2. 管理后台UI优化
|
||||
3. 首页添加描述显示
|
||||
[详细改动记录](https://github.com/ctwj/urldb/blob/main/ChangeLog.md)
|
||||
|
||||
### v1.2.0
|
||||
1. 新增手动批量转存
|
||||
2. 新增QQ机器人
|
||||
3. 新增任务管理功能
|
||||
4. 自动转存改版(批量转存修改为,显示二维码时自动转存)
|
||||
5. 新增支持第三方统计代码配置
|
||||
|
||||
### v1.0.0
|
||||
当前特性
|
||||
1. 支持API,手动批量录入资源
|
||||
2. 支持,自动判断资源有效性
|
||||
3. 支持自动转存
|
||||
4. 支持平台多账号管理(Quark)
|
||||
4. 支持平台多账号管理
|
||||
5. 支持简单的数据统计
|
||||
|
||||
6. 支持Meilisearch
|
||||
|
||||
|
||||
---
|
||||
@@ -66,7 +62,6 @@
|
||||
## 📸 项目截图
|
||||
|
||||
|
||||
|
||||
### 🏠 首页
|
||||

|
||||
|
||||
@@ -129,16 +124,6 @@ PORT=8080
|
||||
# 时区配置
|
||||
TIMEZONE=Asia/Shanghai
|
||||
```
|
||||
|
||||
### 镜像构建
|
||||
|
||||
```
|
||||
docker build -t ctwj/urldb-frontend:1.0.7 --target frontend .
|
||||
docker build -t ctwj/urldb-backend:1.0.7 --target backend .
|
||||
docker push ctwj/urldb-frontend:1.0.7
|
||||
docker push ctwj/urldb-backend:1.0.7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
@@ -158,11 +143,8 @@ docker push ctwj/urldb-backend:1.0.7
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
- **项目地址**: [https://github.com/ctwj/urldb](https://github.com/ctwj/urldb)
|
||||
- **问题反馈**: [Issues](https://github.com/ctwj/urldb/issues)
|
||||
- **邮箱**: 510199617@qq.com
|
||||
## 📞 交流群
|
||||
- **TG**: [Telegram 技术交流群](https://t.me/+QF9OMpOv-PBjZGEx)
|
||||
|
||||
---
|
||||
|
||||
@@ -172,4 +154,4 @@ docker push ctwj/urldb-backend:1.0.7
|
||||
|
||||
Made with ❤️ by [老九]
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
@@ -255,9 +257,9 @@ func (a *AlipanService) DeleteFiles(fileList []string) (*TransferResult, error)
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (a *AlipanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
func (a *AlipanService) GetUserInfo(cookie *string) (*UserInfo, error) {
|
||||
// 设置Cookie
|
||||
a.SetHeader("Cookie", cookie)
|
||||
a.SetHeader("Cookie", *cookie)
|
||||
|
||||
// 获取access token
|
||||
accessToken, err := a.manageAccessToken()
|
||||
@@ -347,6 +349,11 @@ func (a *AlipanService) getAlipan1(shareID string) (*AlipanShareInfo, error) {
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetUserInfoByEntity 根据 entity.Cks 获取用户信息(待实现)
|
||||
func (a *AlipanService) GetUserInfoByEntity(cks entity.Cks) (*UserInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// getAlipan2 通过分享id获取X-Share-Token
|
||||
func (a *AlipanService) getAlipan2(shareID string) (*AlipanShareToken, error) {
|
||||
data := map[string]interface{}{
|
||||
@@ -399,6 +406,9 @@ func (a *AlipanService) getAlipan4(shareData map[string]interface{}) (*AlipanSha
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (u *AlipanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
|
||||
}
|
||||
|
||||
// manageAccessToken 管理access token
|
||||
func (a *AlipanService) manageAccessToken() (string, error) {
|
||||
if a.accessToken != "" {
|
||||
|
||||
@@ -2,6 +2,9 @@ package pan
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
)
|
||||
|
||||
// BaiduPanService 百度网盘服务
|
||||
@@ -50,9 +53,9 @@ func (b *BaiduPanService) DeleteFiles(fileList []string) (*TransferResult, error
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (b *BaiduPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
func (b *BaiduPanService) GetUserInfo(cookie *string) (*UserInfo, error) {
|
||||
// 设置Cookie
|
||||
b.SetHeader("Cookie", cookie)
|
||||
b.SetHeader("Cookie", *cookie)
|
||||
|
||||
// 调用百度网盘用户信息API
|
||||
userInfoURL := "https://pan.baidu.com/api/gettemplatevariable"
|
||||
@@ -101,3 +104,21 @@ func (b *BaiduPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
ServiceType: "baidu",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserInfoByEntity 根据 entity.Cks 获取用户信息(待实现)
|
||||
func (b *BaiduPanService) GetUserInfoByEntity(cks entity.Cks) (*UserInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (u *BaiduPanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
|
||||
}
|
||||
|
||||
func (x *BaiduPanService) UpdateConfig(config *PanConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
x.config = config
|
||||
if config.Cookie != "" {
|
||||
x.SetHeader("Cookie", config.Cookie)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
)
|
||||
|
||||
// ServiceType 定义网盘服务类型
|
||||
@@ -74,6 +77,7 @@ type UserInfo struct {
|
||||
UsedSpace int64 `json:"usedSpace"` // 已使用空间
|
||||
TotalSpace int64 `json:"totalSpace"` // 总空间
|
||||
ServiceType string `json:"serviceType"` // 服务类型
|
||||
ExtraData string `json:"extraData"` // 额外信息
|
||||
}
|
||||
|
||||
// PanService 网盘服务接口
|
||||
@@ -88,10 +92,14 @@ type PanService interface {
|
||||
DeleteFiles(fileList []string) (*TransferResult, error)
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
GetUserInfo(cookie string) (*UserInfo, error)
|
||||
GetUserInfo(ck *string) (*UserInfo, error)
|
||||
|
||||
// GetServiceType 获取服务类型
|
||||
GetServiceType() ServiceType
|
||||
|
||||
SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks)
|
||||
|
||||
UpdateConfig(config *PanConfig)
|
||||
}
|
||||
|
||||
// PanFactory 网盘工厂
|
||||
@@ -249,6 +257,9 @@ func ExtractShareId(url string) (string, ServiceType) {
|
||||
shareID = url[substring:]
|
||||
|
||||
// 去除可能的锚点
|
||||
if hashIndex := strings.Index(shareID, "?"); hashIndex != -1 {
|
||||
shareID = shareID[:hashIndex]
|
||||
}
|
||||
if hashIndex := strings.Index(shareID, "#"); hashIndex != -1 {
|
||||
shareID = shareID[:hashIndex]
|
||||
}
|
||||
|
||||
@@ -29,35 +29,31 @@ var configRefreshChan = make(chan bool, 1)
|
||||
|
||||
// 单例相关变量
|
||||
var (
|
||||
quarkInstance *QuarkPanService
|
||||
quarkOnce sync.Once
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
systemConfigOnce sync.Once
|
||||
)
|
||||
|
||||
// NewQuarkPanService 创建夸克网盘服务(单例模式)
|
||||
func NewQuarkPanService(config *PanConfig) *QuarkPanService {
|
||||
quarkOnce.Do(func() {
|
||||
quarkInstance = &QuarkPanService{
|
||||
BasePanService: NewBasePanService(config),
|
||||
}
|
||||
quarkInstance := &QuarkPanService{
|
||||
BasePanService: NewBasePanService(config),
|
||||
}
|
||||
|
||||
// 设置夸克网盘的默认请求头
|
||||
quarkInstance.SetHeaders(map[string]string{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Sec-Ch-Ua": `"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"`,
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": `"Windows"`,
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Referer": "https://pan.quark.cn/",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Cookie": config.Cookie,
|
||||
})
|
||||
// 设置夸克网盘的默认请求头
|
||||
quarkInstance.SetHeaders(map[string]string{
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Sec-Ch-Ua": `"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"`,
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": `"Windows"`,
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Referer": "https://pan.quark.cn/",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Cookie": config.Cookie,
|
||||
})
|
||||
|
||||
// 更新配置
|
||||
@@ -947,10 +943,10 @@ type PasswordResult struct {
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (q *QuarkPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
func (q *QuarkPanService) GetUserInfo(cookie *string) (*UserInfo, error) {
|
||||
// 临时设置cookie
|
||||
originalCookie := q.GetHeader("Cookie")
|
||||
q.SetHeader("Cookie", cookie)
|
||||
q.SetHeader("Cookie", *cookie)
|
||||
defer q.SetHeader("Cookie", originalCookie) // 恢复原始cookie
|
||||
|
||||
// 获取用户基本信息
|
||||
@@ -1028,6 +1024,9 @@ func (q *QuarkPanService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (xq *QuarkPanService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
|
||||
}
|
||||
|
||||
// formatBytes 格式化字节数为可读格式
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package pan
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
)
|
||||
|
||||
// UCService UC网盘服务
|
||||
type UCService struct {
|
||||
@@ -47,10 +52,20 @@ func (u *UCService) DeleteFiles(fileList []string) (*TransferResult, error) {
|
||||
return ErrorResult("UC网盘文件删除功能暂未实现"), nil
|
||||
}
|
||||
|
||||
func (x *UCService) UpdateConfig(config *PanConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
x.config = config
|
||||
if config.Cookie != "" {
|
||||
x.SetHeader("Cookie", config.Cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (u *UCService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
func (u *UCService) GetUserInfo(cookie *string) (*UserInfo, error) {
|
||||
// 设置Cookie
|
||||
u.SetHeader("Cookie", cookie)
|
||||
u.SetHeader("Cookie", *cookie)
|
||||
|
||||
// 调用UC网盘用户信息API
|
||||
userInfoURL := "https://drive.uc.cn/api/user/info"
|
||||
@@ -97,3 +112,11 @@ func (u *UCService) GetUserInfo(cookie string) (*UserInfo, error) {
|
||||
ServiceType: "uc",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserInfoByEntity 根据 entity.Cks 获取用户信息(待实现)
|
||||
func (u *UCService) GetUserInfoByEntity(cks entity.Cks) (*UserInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (u *UCService) SetCKSRepository(cksRepo repo.CksRepository, entity entity.Cks) {
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func extractShareID(urlStr string) (string, string) {
|
||||
},
|
||||
XunleiStr: {
|
||||
Domains: []string{"pan.xunlei.com"},
|
||||
Pattern: regexp.MustCompile(`https?://(?:www\.)?pan\.xunlei\.com/s/([a-zA-Z0-9-]+)`),
|
||||
Pattern: regexp.MustCompile(`https?://(?:www\.)?pan\.xunlei\.com/s/([a-zA-Z0-9-_]+)`),
|
||||
},
|
||||
BaiduStr: {
|
||||
Domains: []string{"pan.baidu.com", "yun.baidu.com"},
|
||||
|
||||
444
common/xunlei.txt
Normal file
444
common/xunlei.txt
Normal file
@@ -0,0 +1,444 @@
|
||||
POST /v1/shield/captcha/init HTTP/1.1
|
||||
Host: xluser-ssl.xunlei.com
|
||||
Connection: close
|
||||
Content-Length: 502
|
||||
sec-ch-ua-platform: "macOS"
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
x-device-name: PC-Chrome
|
||||
sec-ch-ua-mobile: ?0
|
||||
x-device-model: chrome%2F139.0.0.0
|
||||
x-provider-name: NONE
|
||||
x-platform-version: 1
|
||||
content-type: application/json
|
||||
x-client-id: XW5SkOhLDjnOZP7J
|
||||
x-protocol-version: 301
|
||||
x-net-work-type: NONE
|
||||
x-os-version: MacIntel
|
||||
x-device-sign: wdi10.c24ecadc44c643637d127fb847dbe36d74ee67f56a443148fc801eb27bb3e058
|
||||
accept-language: zh-cn
|
||||
x-sdk-version: 8.1.4
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-client-version: 1.1.6
|
||||
DNT: 1
|
||||
Accept: */*
|
||||
Origin: https://i.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://i.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
|
||||
{"client_id":"XW5SkOhLDjnOZP7J","action":"POST:/v1/auth/verification","device_id":"c24ecadc44c643637d127fb847dbe36d","captcha_token":"ck0.iomdNE7hSgjR_6Q8bb4T0diVDSUD2Q2XRAdXr3xiVyvgSks1GLMw88pwxSSiTMiPcJojvVGxjKk58tg0iFMLPVOIi1qdstLeWtIJfgk2C2FtyNtl-XveEYFy_gyW4qUVYkeEPoDScctqSBNjDKvCIpLuCh3p6dKXFpiMAMBcY8USOYzutMt0oO_L-a-YisQGG9x6yN2Iik3fPAu4_IbfhdBctqha10OajDCPBaRqjdZtBuFifxq9qMpSUiZWuP6FiZ8hxj66_mrgY-yW90lCYT6JerSal78OYByU8DWh6UnfUzRgrhsqQukgeZv9YEtE","meta":{"phone_number":"+86 18163659661"}}
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 13:50:28 GMT
|
||||
Content-Type: application/json; charset=utf-8
|
||||
Connection: close
|
||||
Vary: Accept-Encoding
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type, Accept, X-Project-Id, X-Device-Id, X-Request-Id, X-Captcha-Token, X-Client-Id, X-Sdk-Version, X-Client-Version, X-Action, X-Auto-Login, X-Device-Name, X-Device-Model, X-Net-Work-Type, X-Os-Version, X-Protocol-Version, X-Platform-Version, X-Provider-Name, X-Device-Sign, X-Client-Channel-Id, X-Peer-Id
|
||||
Access-Control-Allow-Methods: POST, GET, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Origin: https://i.xunlei.com
|
||||
Access-Control-Max-Age: 86400
|
||||
Strict-Transport-Security: max-age=5184000; includeSubDomains
|
||||
Vary: Origin, Accept-Encoding
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Dns-Prefetch-Control: off
|
||||
X-Download-Options: noopen
|
||||
X-Frame-Options: DENY
|
||||
X-Request-Id: 421c1f2621e9acd295973c3df960ce37
|
||||
X-Xss-Protection: 1; mode=block
|
||||
Content-Length: 340
|
||||
|
||||
{"captcha_token":"ck0.yiuSDIIJNBkSmuz9aViGbFVX39L7wcxw6GjlMB7xBDTx7trq1EvQFLQzfzSCrDdicNDrda35Pt6lps-mUFzKehxZo3g_Xmw93R7UpImXB-9zsFVeaNGrQ1V0J0TJ4TbTpKJ6UIEr3agYs29g5DAZmuHxgIg1GjFq53GzGv6sh-eshFYA9tViXoTGo0OjmwdTvFnsPaQhgn0Ubn6Io8LDQpnKOAw74L5zKxxzowd7kSZqpJeeDuFxAeIGOHnl0FGtL6S4e5Cswa-xixQZLq_ufT7rxiJW4bM4ndgsC6jFFxY","expires_in":300}
|
||||
|
||||
|
||||
===================
|
||||
|
||||
POST /v1/auth/verification HTTP/1.1
|
||||
Host: xluser-ssl.xunlei.com
|
||||
Connection: close
|
||||
Content-Length: 98
|
||||
sec-ch-ua-platform: "macOS"
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
x-device-name: PC-Chrome
|
||||
sec-ch-ua-mobile: ?0
|
||||
x-device-model: chrome%2F139.0.0.0
|
||||
x-provider-name: NONE
|
||||
x-platform-version: 1
|
||||
content-type: application/json
|
||||
x-client-id: XW5SkOhLDjnOZP7J
|
||||
x-protocol-version: 301
|
||||
x-net-work-type: NONE
|
||||
x-os-version: MacIntel
|
||||
x-device-sign: wdi10.c24ecadc44c643637d127fb847dbe36d74ee67f56a443148fc801eb27bb3e058
|
||||
accept-language: zh-cn
|
||||
x-sdk-version: 8.1.4
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-captcha-token: ck0.yiuSDIIJNBkSmuz9aViGbFVX39L7wcxw6GjlMB7xBDTx7trq1EvQFLQzfzSCrDdicNDrda35Pt6lps-mUFzKehxZo3g_Xmw93R7UpImXB-9zsFVeaNGrQ1V0J0TJ4TbTpKJ6UIEr3agYs29g5DAZmuHxgIg1GjFq53GzGv6sh-eshFYA9tViXoTGo0OjmwdTvFnsPaQhgn0Ubn6Io8LDQpnKOAw74L5zKxxzowd7kSZqpJeeDuFxAeIGOHnl0FGtL6S4e5Cswa-xixQZLq_ufT7rxiJW4bM4ndgsC6jFFxY
|
||||
x-client-version: 1.1.6
|
||||
DNT: 1
|
||||
Accept: */*
|
||||
Origin: https://i.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://i.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
|
||||
{"phone_number":"+86 18163659661","target":"ANY","usage":"SIGN_IN","client_id":"XW5SkOhLDjnOZP7J"}
|
||||
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 13:50:28 GMT
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
Vary: Accept-Encoding
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type,Accept, X-Project-Id, X-Device-Id, X-Request-Id, X-Captcha-Token, X-Client-Id, x-sdk-version, x-client-version, x-device-name, x-device-model, x-captcha-token, x-net-work-type, x-os-version, x-protocol-version, x-platform-version, x-provider-name, x-client-channel-id, x-appname, x-appid, x-device-sign, x-auto-login, x-peer-id, x-action
|
||||
Access-Control-Allow-Methods: POST, GET, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Origin: https://i.xunlei.com
|
||||
Access-Control-Max-Age: 86400
|
||||
Vary: Origin, Accept-Encoding
|
||||
Vary: Accept-Encoding
|
||||
Content-Length: 691
|
||||
|
||||
{"verification_id":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImRhMjNiMGFiLTc5NjAtNDQzNS1hMmY1LWNmMGMzMjAxNWE0NiJ9.eyJhdWQiOiJYVzVTa09oTERqbk9aUDdKIiwiZXhwIjoxNzU1Njk4MTI4LCJwIjoiKzg2IDE4MTYzNjU5NjYxIiwicHJvamVjdF9pZCI6IjJydms0ZTNna2RubDd1MWtsMGsiLCJ0IjoiYUtYU3BHTnljSFFGelYtVTVicFQifQ.JN2i0feZedA4VDrCZzMDc0MEnVSe6j6jhB11RsSvt9qiByge5lCsYuMGz-RwxMiU_FEnUxYHSzPJu82sskU4a66k8AOXBqCyhLy3TlSq1KkUXl7uylGRxf99AZfJhZs0Rgm_H---rWIxx8x4DJdrQxWp5hcUCmSGL95p47xJGQDayNhb4Y-eOup9DYik6KOAzHtGl8NRzeE-k-XCXiGMRc-sv2mILPpWWinVhSExR2fHhDcjNtsPJgSguEv7Kqevg029fXSQ-uZAh9WmPkW5rHnb-e7buXMrSOGtKdV7AVarRRWWa039M7L8rrYmq33dv5IX_BvGUk7elAaMmWXrxw", "is_user":true, "expires_in":300, "selected_channel":"VERIFICATION_PHONE"}
|
||||
|
||||
=======================
|
||||
|
||||
POST /v1/auth/verification/verify HTTP/1.1
|
||||
Host: xluser-ssl.xunlei.com
|
||||
Connection: close
|
||||
Content-Length: 676
|
||||
sec-ch-ua-platform: "macOS"
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
x-device-name: PC-Chrome
|
||||
sec-ch-ua-mobile: ?0
|
||||
x-device-model: chrome%2F139.0.0.0
|
||||
x-provider-name: NONE
|
||||
x-platform-version: 1
|
||||
content-type: application/json
|
||||
x-client-id: XW5SkOhLDjnOZP7J
|
||||
x-protocol-version: 301
|
||||
x-net-work-type: NONE
|
||||
x-os-version: MacIntel
|
||||
x-device-sign: wdi10.c24ecadc44c643637d127fb847dbe36d74ee67f56a443148fc801eb27bb3e058
|
||||
accept-language: zh-cn
|
||||
x-sdk-version: 8.1.4
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-client-version: 1.1.6
|
||||
DNT: 1
|
||||
Accept: */*
|
||||
Origin: https://i.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://i.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
|
||||
{"verification_id":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImRhMjNiMGFiLTc5NjAtNDQzNS1hMmY1LWNmMGMzMjAxNWE0NiJ9.eyJhdWQiOiJYVzVTa09oTERqbk9aUDdKIiwiZXhwIjoxNzU1Njk4MTI4LCJwIjoiKzg2IDE4MTYzNjU5NjYxIiwicHJvamVjdF9pZCI6IjJydms0ZTNna2RubDd1MWtsMGsiLCJ0IjoiYUtYU3BHTnljSFFGelYtVTVicFQifQ.JN2i0feZedA4VDrCZzMDc0MEnVSe6j6jhB11RsSvt9qiByge5lCsYuMGz-RwxMiU_FEnUxYHSzPJu82sskU4a66k8AOXBqCyhLy3TlSq1KkUXl7uylGRxf99AZfJhZs0Rgm_H---rWIxx8x4DJdrQxWp5hcUCmSGL95p47xJGQDayNhb4Y-eOup9DYik6KOAzHtGl8NRzeE-k-XCXiGMRc-sv2mILPpWWinVhSExR2fHhDcjNtsPJgSguEv7Kqevg029fXSQ-uZAh9WmPkW5rHnb-e7buXMrSOGtKdV7AVarRRWWa039M7L8rrYmq33dv5IX_BvGUk7elAaMmWXrxw","verification_code":"454882","client_id":"XW5SkOhLDjnOZP7J"}
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 13:50:46 GMT
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
Vary: Accept-Encoding
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type,Accept, X-Project-Id, X-Device-Id, X-Request-Id, X-Captcha-Token, X-Client-Id, x-sdk-version, x-client-version, x-device-name, x-device-model, x-captcha-token, x-net-work-type, x-os-version, x-protocol-version, x-platform-version, x-provider-name, x-client-channel-id, x-appname, x-appid, x-device-sign, x-auto-login, x-peer-id, x-action
|
||||
Access-Control-Allow-Methods: POST, GET, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Origin: https://i.xunlei.com
|
||||
Access-Control-Max-Age: 86400
|
||||
Vary: Origin, Accept-Encoding
|
||||
Vary: Accept-Encoding
|
||||
Content-Length: 706
|
||||
|
||||
{"verification_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImRhMjNiMGFiLTc5NjAtNDQzNS1hMmY1LWNmMGMzMjAxNWE0NiJ9.eyJhdWQiOiJYVzVTa09oTERqbk9aUDdKIiwiZXhwIjoxNzU1Njk4NDQ2LCJpc3MiOiJodHRwczovL2FwaXMueGJhc2UuY2xvdWQiLCJwaG9uZV9udW1iZXIiOiIrODYgMTgxNjM2NTk2NjEiLCJwcm9qZWN0X2lkIjoiMnJ2azRlM2drZG5sN3Uxa2wwayIsInJlc3VsdCI6IjAiLCJ0eXBlIjoidmVyaWZpY2F0aW9uIn0.EGyqiswEF72e_OiiL0sLhZRkZpCbnd-atG4zCAXTIkaoWQ5Gpuceg1lyXGDT-HNo-BtmtqjZBXgJveO8j1q12w2l1iloaYvarVDmIgEzH-Iq-LN5BHcUYJCLZKIhd0sU1SpoU7U3Hjv837TACJ9L2PS3g9evtqyXNv-E6_9U0xwTj_0BCKbil3qyOtlp-W24RY2yOkUPN4uKLlQAUpIcujDsKRjTsZvIzoED7RZutHsdwg4qhy5VaquP9hc62z6HSAwhtTlp4cwXEMpkev3PfjDzAbE1h935UqVgm3NmaylCAcICRB5VwfLwe8qLAT_N7-gFXMwdaqJPrDoOkWZZpg", "expires_in":600}
|
||||
|
||||
====================================
|
||||
|
||||
|
||||
POST /v1/auth/signin HTTP/1.1
|
||||
Host: xluser-ssl.xunlei.com
|
||||
Connection: close
|
||||
Content-Length: 777
|
||||
sec-ch-ua-platform: "macOS"
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
x-device-name: PC-Chrome
|
||||
sec-ch-ua-mobile: ?0
|
||||
x-device-model: chrome%2F139.0.0.0
|
||||
x-provider-name: NONE
|
||||
x-platform-version: 1
|
||||
content-type: application/json
|
||||
x-client-id: XW5SkOhLDjnOZP7J
|
||||
x-protocol-version: 301
|
||||
x-net-work-type: NONE
|
||||
x-os-version: MacIntel
|
||||
x-device-sign: wdi10.c24ecadc44c643637d127fb847dbe36d74ee67f56a443148fc801eb27bb3e058
|
||||
accept-language: zh-cn
|
||||
x-sdk-version: 8.1.4
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-captcha-token: ck0.yiuSDIIJNBkSmuz9aViGbFVX39L7wcxw6GjlMB7xBDTx7trq1EvQFLQzfzSCrDdicNDrda35Pt6lps-mUFzKehxZo3g_Xmw93R7UpImXB-9zsFVeaNGrQ1V0J0TJ4TbTpKJ6UIEr3agYs29g5DAZmuHxgIg1GjFq53GzGv6sh-eshFYA9tViXoTGo0OjmwdTvFnsPaQhgn0Ubn6Io8LDQpnKOAw74L5zKxxzowd7kSZqpJeeDuFxAeIGOHnl0FGtL6S4e5Cswa-xixQZLq_ufT7rxiJW4bM4ndgsC6jFFxY
|
||||
x-client-version: 1.1.6
|
||||
DNT: 1
|
||||
Accept: */*
|
||||
Origin: https://i.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://i.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
|
||||
{"username":"+86 18163659661","verification_code":"454882","verification_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImRhMjNiMGFiLTc5NjAtNDQzNS1hMmY1LWNmMGMzMjAxNWE0NiJ9.eyJhdWQiOiJYVzVTa09oTERqbk9aUDdKIiwiZXhwIjoxNzU1Njk4NDQ2LCJpc3MiOiJodHRwczovL2FwaXMueGJhc2UuY2xvdWQiLCJwaG9uZV9udW1iZXIiOiIrODYgMTgxNjM2NTk2NjEiLCJwcm9qZWN0X2lkIjoiMnJ2azRlM2drZG5sN3Uxa2wwayIsInJlc3VsdCI6IjAiLCJ0eXBlIjoidmVyaWZpY2F0aW9uIn0.EGyqiswEF72e_OiiL0sLhZRkZpCbnd-atG4zCAXTIkaoWQ5Gpuceg1lyXGDT-HNo-BtmtqjZBXgJveO8j1q12w2l1iloaYvarVDmIgEzH-Iq-LN5BHcUYJCLZKIhd0sU1SpoU7U3Hjv837TACJ9L2PS3g9evtqyXNv-E6_9U0xwTj_0BCKbil3qyOtlp-W24RY2yOkUPN4uKLlQAUpIcujDsKRjTsZvIzoED7RZutHsdwg4qhy5VaquP9hc62z6HSAwhtTlp4cwXEMpkev3PfjDzAbE1h935UqVgm3NmaylCAcICRB5VwfLwe8qLAT_N7-gFXMwdaqJPrDoOkWZZpg","client_id":"XW5SkOhLDjnOZP7J"}
|
||||
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 13:50:47 GMT
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
Vary: Accept-Encoding
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type,Accept, X-Project-Id, X-Device-Id, X-Request-Id, X-Captcha-Token, X-Client-Id, x-sdk-version, x-client-version, x-device-name, x-device-model, x-captcha-token, x-net-work-type, x-os-version, x-protocol-version, x-platform-version, x-provider-name, x-client-channel-id, x-appname, x-appid, x-device-sign, x-auto-login, x-peer-id, x-action
|
||||
Access-Control-Allow-Methods: POST, GET, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Origin: https://i.xunlei.com
|
||||
Access-Control-Max-Age: 86400
|
||||
Vary: Origin, Accept-Encoding
|
||||
Vary: Accept-Encoding
|
||||
Content-Length: 983
|
||||
|
||||
{"token_type":"Bearer", "access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjAzZjlmMzYxLWI3MjktNDVjNi04MjI0LTNiNWEwNmJmOGU0NSJ9.eyJpc3MiOiJodHRwczovL3hsdXNlci1zc2wueHVubGVpLmNvbSIsInN1YiI6IjEyMTk2MzY5NTIiLCJhdWQiOiJYVzVTa09oTERqbk9aUDdKIiwiZXhwIjoxNzU1NzA1MDQ3LCJpYXQiOjE3NTU2OTc4NDcsImF0X2hhc2giOiJyLnJLZG5uWDNNRWZDZloyWTBNdlJYRnciLCJzY29wZSI6InVzZXIgcGFuIHByb2ZpbGUgb2ZmbGluZSIsInByb2plY3RfaWQiOiIycnZrNGUzZ2tkbmw3dTFrbDBrIiwibWV0YSI6eyJhIjoiR3hkMjNQK0VreGFXWVJ3K1FwdUtyRTZmb3kwRGh2aE5UMmhSbnd2S3F5VT0ifX0.s6mbN3Imr2WKDexAMXW7C5FoqPF4_eS0oPFyHTe-DbcmvTuC1KcRcDlCjt92An8A2wluvD4t1BbHSGv_1U8CFcE_VGtWJoy3yPoscfyGLQCbz38UY-q9r94s8ABtYTe4fZLOHRB20uc71aB87rGDe0IyzafkimgSbrETZiS4v95VvuZbP_YTwAdcAuiRRgMb1YWvAkkBEWTlvRVUFryCZVP0oecanpeDrXxUxV_SqAtI-ix-mCw5N1g91B88tkg7FtJfGTS5LA8KTXBIiAq73-jPzZ0padssq4uFVEiKXGOAO9rKtsI7gBxsQpW9do_bh0g2JYVz7Op5OLvMrGwJTw", "refresh_token":"a1.TK4L3Xi38Gil0rcGFvQx777bbE7luNneIpEPbPOFLF1pxmSu62Yr", "expires_in":7200, "sub":"1219636952", "user_id":"1219636952"}
|
||||
|
||||
|
||||
|
||||
======================
|
||||
|
||||
|
||||
|
||||
GET /v1/user/me HTTP/1.1
|
||||
Host: xluser-ssl.xunlei.com
|
||||
Connection: close
|
||||
sec-ch-ua-platform: "macOS"
|
||||
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAzZjlmMzYxLWI3MjktNDVjNi04MjI0LTNiNWEwNmJmOGU0NSJ9.eyJpc3MiOiJodHRwczovL3hsdXNlci1zc2wueHVubGVpLmNvbSIsInN1YiI6IjEyMTk2MzY5NTIiLCJhdWQiOiJYVzVTa09oTERqbk9aUDdKIiwiZXhwIjoxNzU1NzA1MDQ3LCJpYXQiOjE3NTU2OTc4NDcsImF0X2hhc2giOiJyLnJLZG5uWDNNRWZDZloyWTBNdlJYRnciLCJzY29wZSI6InVzZXIgcGFuIHByb2ZpbGUgb2ZmbGluZSIsInByb2plY3RfaWQiOiIycnZrNGUzZ2tkbmw3dTFrbDBrIiwibWV0YSI6eyJhIjoiR3hkMjNQK0VreGFXWVJ3K1FwdUtyRTZmb3kwRGh2aE5UMmhSbnd2S3F5VT0ifX0.s6mbN3Imr2WKDexAMXW7C5FoqPF4_eS0oPFyHTe-DbcmvTuC1KcRcDlCjt92An8A2wluvD4t1BbHSGv_1U8CFcE_VGtWJoy3yPoscfyGLQCbz38UY-q9r94s8ABtYTe4fZLOHRB20uc71aB87rGDe0IyzafkimgSbrETZiS4v95VvuZbP_YTwAdcAuiRRgMb1YWvAkkBEWTlvRVUFryCZVP0oecanpeDrXxUxV_SqAtI-ix-mCw5N1g91B88tkg7FtJfGTS5LA8KTXBIiAq73-jPzZ0padssq4uFVEiKXGOAO9rKtsI7gBxsQpW9do_bh0g2JYVz7Op5OLvMrGwJTw
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
x-device-name: PC-Chrome
|
||||
sec-ch-ua-mobile: ?0
|
||||
x-device-model: chrome%2F139.0.0.0
|
||||
x-provider-name: NONE
|
||||
x-platform-version: 1
|
||||
content-type: application/json
|
||||
x-client-id: XW5SkOhLDjnOZP7J
|
||||
x-protocol-version: 301
|
||||
x-net-work-type: NONE
|
||||
x-os-version: MacIntel
|
||||
x-device-sign: wdi10.c24ecadc44c643637d127fb847dbe36d74ee67f56a443148fc801eb27bb3e058
|
||||
accept-language: zh-cn
|
||||
x-sdk-version: 8.1.4
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-client-version: 1.1.6
|
||||
DNT: 1
|
||||
Accept: */*
|
||||
Origin: https://i.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://i.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 13:50:47 GMT
|
||||
Content-Type: application/json
|
||||
Content-Length: 1954
|
||||
Connection: close
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type,Accept, X-Project-Id, X-Device-Id, X-Request-Id, X-Captcha-Token, X-Client-Id, x-sdk-version, x-client-version, x-device-name, x-device-model, x-captcha-token, x-net-work-type, x-os-version, x-protocol-version, x-platform-version, x-provider-name, x-client-channel-id, x-appname, x-appid, x-device-sign, x-auto-login, x-peer-id, x-action
|
||||
Access-Control-Allow-Methods: POST, GET, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Origin: https://i.xunlei.com
|
||||
Access-Control-Max-Age: 86400
|
||||
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
|
||||
Vary: Origin, Accept-Encoding
|
||||
Vary: Accept-Encoding
|
||||
|
||||
{"sub":"1219636952", "name":"王维ด้้้้้็็", "picture":"https://xfile2.a.88cdn.com/file/k/avatar/default", "phone_number":"+86 181***661", "providers":[{"id":"u", "provider_user_id":"2327081043"}, {"id":"qq.com", "provider_user_id":"UID_AC1EE453B67AF1B266C5CA0B4FB99A49"}], "status":"ACTIVE", "created_at":"2025-07-09T09:34:56Z", "password_updated_at":"2025-07-09T09:34:56Z", "id":"1219636952", "vips":[{"id":"vip15_0_0_2_15_0"}], "vip_info":[{"register":"19700101", "autodeduct":"0", "daily":"-10", "expire":"0", "grow":"0", "is_vip":"0", "last_pay":"0", "level":"0", "pay_id":"0", "remind":"0", "is_year":"0", "user_vas":"2", "vas_type":"0", "vip_icon":{"general":"https://xluser-ssl.xunlei.com/v1/file/icon/level/svip/deactivate_a/svip_level1_deactivate.png", "small":"https://xluser-ssl.xunlei.com/v1/file/icon/level/svip/deactivate_b/svip_level1_deactivate-1.png"}}, {"register":"0", "autodeduct":"0", "daily":"0", "expire":"0", "grow":"0", "is_vip":"0", "last_pay":"0", "level":"0", "pay_id":"0", "remind":"0", "is_year":"0", "user_vas":"15", "vas_type":"2", "vip_icon":{}}, {"register":"19700101", "autodeduct":"0", "daily":"0", "expire":"0", "grow":"0", "is_vip":"0", "last_pay":"0", "level":"0", "pay_id":"0", "remind":"0", "is_year":"0", "user_vas":"33", "vas_type":"0", "vip_icon":{}}, {"register":"19700101", "autodeduct":"0", "daily":"0", "expire":"0", "grow":"0", "is_vip":"0", "last_pay":"0", "level":"0", "pay_id":"0", "remind":"0", "is_year":"0", "user_vas":"303", "vas_type":"0", "vip_icon":{}}, {"register":"19700101", "autodeduct":"0", "daily":"0", "expire":"0", "grow":"0", "is_vip":"0", "last_pay":"0", "level":"0", "pay_id":"0", "remind":"0", "is_year":"0", "user_vas":"306", "vas_type":"0", "vip_icon":{"general":"https://xluser-ssl.xunlei.com/v1/file/icon/level/svip/snnual_deactivate/im_ypvip_deactivate.png", "small":"https://xluser-ssl.xunlei.com/v1/file/icon/level/svip/normal_b/im_ypvip_pure_normal.png"}}]}
|
||||
|
||||
|
||||
===========================
|
||||
|
||||
|
||||
|
||||
POST /v1/auth/token HTTP/1.1
|
||||
Host: xluser-ssl.xunlei.com
|
||||
Connection: close
|
||||
Content-Length: 427
|
||||
sec-ch-ua-platform: "macOS"
|
||||
x-device-sign: wdi10.c24ecadc44c643637d127fb847dbe36d74ee67f56a443148fc801eb27bb3e058
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
x-sdk-version: 3.4.20
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
sec-ch-ua-mobile: ?0
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
DNT: 1
|
||||
content-type: application/json
|
||||
x-client-id: Xqp0kJBXWhwaTpB6
|
||||
x-protocol-version: 301
|
||||
Accept: */*
|
||||
Origin: https://pan.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://pan.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
Accept-Language: zh-CN,zh;q=0.9,ko-KR;q=0.8,ko;q=0.7
|
||||
|
||||
{"code":"a1.oGotq0yXVGil0zJF5BS1YPllaP2RT3SbqOTaGs7SjmtE7VIPc9LcpaFchdkrjN3xTGPlXo7Q7SlEu6oNg_aW76tbjo6524JMW5vS_Ga8jHFTGuhiLXiJ3UP6qBx0C79hRFS_zFLuzIzCwQtkGF8Eksuyeg3G42jxWPLrzQBswiz3oqU8Ssusbw","grant_type":"authorization_code","code_verifier":"NnmDL5IumVBn9i8TOU15QrhBvbb995tv","redirect_uri":"https://pan.xunlei.com/login/?path=%2F%E6%88%91%E7%9A%84%E8%BD%AC%E5%AD%98&sso_sign_in_in_iframe=","client_id":"Xqp0kJBXWhwaTpB6"}
|
||||
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 13:50:51 GMT
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
Vary: Accept-Encoding
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Headers: Authorization, Content-Type,Accept, X-Project-Id, X-Device-Id, X-Request-Id, X-Captcha-Token, X-Client-Id, x-sdk-version, x-client-version, x-device-name, x-device-model, x-captcha-token, x-net-work-type, x-os-version, x-protocol-version, x-platform-version, x-provider-name, x-client-channel-id, x-appname, x-appid, x-device-sign, x-auto-login, x-peer-id, x-action
|
||||
Access-Control-Allow-Methods: POST, GET, PUT, DELETE, PATCH, OPTIONS
|
||||
Access-Control-Allow-Origin: https://pan.xunlei.com
|
||||
Access-Control-Max-Age: 86400
|
||||
Vary: Origin, Accept-Encoding
|
||||
Vary: Accept-Encoding
|
||||
Content-Length: 975
|
||||
|
||||
{"token_type":"Bearer", "access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjAzZjlmMzYxLWI3MjktNDVjNi04MjI0LTNiNWEwNmJmOGU0NSJ9.eyJpc3MiOiJodHRwczovL3hsdXNlci1zc2wueHVubGVpLmNvbSIsInN1YiI6IjEyMTk2MzY5NTIiLCJhdWQiOiJYcXAwa0pCWFdod2FUcEI2IiwiZXhwIjoxNzU1NzQxMDUxLCJpYXQiOjE3NTU2OTc4NTEsImF0X2hhc2giOiJyLmN0UWtJMUNmU1NpNkt2RFVUa0xidHciLCJzY29wZSI6InByb2ZpbGUgcGFuIHNzbyB1c2VyIiwicHJvamVjdF9pZCI6IjJydms0ZTNna2RubDd1MWtsMGsiLCJtZXRhIjp7ImEiOiJHOHNKVlJ6RjUzOE51eDRLOCt0VUtUR1hqdlZOTUczZlNrUEVaUWZLSWRBPSJ9fQ.ktWdGaShnGuU9CFj_awHaHfAoH0gkVBvdf4A24WPkRn-MUSpGRtUuo30VZay-wTbJ2UBEmw0pNtAeAfB97qzpPtVigDf9vD2WE9jIz61dASPxi6JIvioUfRzbgWa_Qj8a0bHc1Zvfufu9cSIzm0PPE7rSHYyq5oNfFxAAaY5k9cp2I4DclZon7hHJ0439knsu8MQEwQY42bRWppKqaI5q66Zlzeoc0t2I0Ehs2XYIyVyG2EYmFlbIWrpahudUTz6PbcrDMoHf8Y1hPnJn5ij7D32YSLXeP7ighQZDqaFoJHXJxqcHYToKut4JfQHCROkbqSwq3_I75k_A36gYtxVdA", "refresh_token":"a1.wve0uF2TK2il0rsGZhkUjjZRACg1R12R9OUdpmPbat2kKwtM", "expires_in":43200, "sub":"1219636952", "user_id":"1219636952"}
|
||||
|
||||
|
||||
==============================
|
||||
|
||||
GET /drive/v1/share?share_id=VOY4fDN-35yNfnqBJ3lSXfK4A1&pass_code=t84g&limit=100&pass_code_token=GdrGFHvaZTbIUKxOiJKyrWZpcdGoHysJZBa5iv7N8mmsNElcU%2F3M8%2BJkp1NO0cMKlIN%2F0QHZ%2FpmCTyNmiGIs4g%3D%3D&page_token=&thumbnail_size=SIZE_SMALL HTTP/1.1
|
||||
Host: api-pan.xunlei.com
|
||||
Connection: close
|
||||
sec-ch-ua-platform: "macOS"
|
||||
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAzZjlmMzYxLWI3MjktNDVjNi04MjI0LTNiNWEwNmJmOGU0NSJ9.eyJpc3MiOiJodHRwczovL3hsdXNlci1zc2wueHVubGVpLmNvbSIsInN1YiI6IjEyMTk2MzY5NTIiLCJhdWQiOiJYcXAwa0pCWFdod2FUcEI2IiwiZXhwIjoxNzU1NzQxMDUxLCJpYXQiOjE3NTU2OTc4NTEsImF0X2hhc2giOiJyLmN0UWtJMUNmU1NpNkt2RFVUa0xidHciLCJzY29wZSI6InByb2ZpbGUgcGFuIHNzbyB1c2VyIiwicHJvamVjdF9pZCI6IjJydms0ZTNna2RubDd1MWtsMGsiLCJtZXRhIjp7ImEiOiJHOHNKVlJ6RjUzOE51eDRLOCt0VUtUR1hqdlZOTUczZlNrUEVaUWZLSWRBPSJ9fQ.ktWdGaShnGuU9CFj_awHaHfAoH0gkVBvdf4A24WPkRn-MUSpGRtUuo30VZay-wTbJ2UBEmw0pNtAeAfB97qzpPtVigDf9vD2WE9jIz61dASPxi6JIvioUfRzbgWa_Qj8a0bHc1Zvfufu9cSIzm0PPE7rSHYyq5oNfFxAAaY5k9cp2I4DclZon7hHJ0439knsu8MQEwQY42bRWppKqaI5q66Zlzeoc0t2I0Ehs2XYIyVyG2EYmFlbIWrpahudUTz6PbcrDMoHf8Y1hPnJn5ij7D32YSLXeP7ighQZDqaFoJHXJxqcHYToKut4JfQHCROkbqSwq3_I75k_A36gYtxVdA
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
sec-ch-ua-mobile: ?0
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-captcha-token: ck0.heh7e1aWMkJ3z0Lz7aFmAWoI0-wDx6Os2o8d_ZF5nJEO_jk11h3FtCwOQG68zkXSaXe4T-dz5NSeT6xuqsy0RoNY5xzgT9J5b6WRAMeTsCqmb_DbntFifcvedEWK92zeFSGoNThN8MjawUXYsG8Y7C1mpVf4ftkLzjrToROm-ZJzUN18VIVur8F0U7SLKnaom-S63QLDXGcR3Qat0OVjoScqSZ9ESKOKAvd9GZ3ZRbKExXEpp16Rc_t3YHG3HFIJFsjQbn6OTsVyV4Ku1OXqJV-8iM8XVawlOxhBKrk8GtIok-UUI4CFXNjFWlrSHkQzN3GbEVPiDJaD7qoATZNUecCJig_oSWZsYKYLWiFgg2QEEzqI21C67LKygm8o1YXEQCVi2v-aMyojBnJwS3I9RsFqhU4u3ChszjyVFnQOMpCtXdLLmn2fGBYrsZP_dNcZlhZYw3yxEImvLsQ5s_5mzQ.ClMI9dP3v4wzEhBYcXAwa0pCWFdod2FUcEI2GgYxLjkyLjciDnBhbi54dW5sZWkuY29tKiBjMjRlY2FkYzQ0YzY0MzYzN2QxMjdmYjg0N2RiZTM2ZBKAAWU_LJemk9r1ThQlFBr75NH03TjR0K-PMY2-QIsEcET8mqJo4uN7iyjGfFeZmcxczsjMBbBpO_3XeYR3Wdgatr2yqImozJKh8Ek3j1718cLVywaQ79z8j_Lj85J1-JfLYPkfUW1q8uBi4Tjz8vs3uSSuOtO5Fy-OF6NyGoYtvxJF
|
||||
DNT: 1
|
||||
content-type: application/json
|
||||
x-client-id: Xqp0kJBXWhwaTpB6
|
||||
Accept: */*
|
||||
Origin: https://pan.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://pan.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
Accept-Language: zh-CN,zh;q=0.9,ko-KR;q=0.8,ko;q=0.7
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 15:00:15 GMT
|
||||
Content-Type: application/json
|
||||
Content-Length: 1912
|
||||
Connection: close
|
||||
Grpc-Metadata-Content-Type: application/grpc
|
||||
Access-Control-Allow-Origin: https://pan.xunlei.com
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Methods: PUT, POST, GET, DELETE, OPTIONS, PATCH
|
||||
Access-Control-Allow-Headers: DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,csrf-token,X-Captcha-Token,X-Device-Id,X-Guid,X-Project-Id,X-Client-Id,X-Peer-Id,X-User-Id,X-Request-From,X-Client-Version-Code,space-authorization
|
||||
Access-Control-Expose-Headers: csrf-token
|
||||
|
||||
{"share_status":"OK","share_status_text":"","file_num":"1","expiration_left":"-1","expiration_left_seconds":"-1","expiration_at":"-1","restore_count_left":"-1","files":[{"kind":"drive#folder","id":"VOY4UMZhqz1ZHO8_WNwF6V5JA1","parent_id":"VOMiZQDpN_rzJ8WNgSSCMExcA1","name":"金子般我的明星 금쪽같은 내스타 (2025)","user_id":"924119402","size":"0","revision":"5","file_extension":"","mime_type":"","starred":false,"web_content_link":"","created_time":"2025-08-20T11:19:51.349+08:00","modified_time":"2025-08-20T11:25:36.083+08:00","icon_link":"https://backstage-img-ssl.a.88cdn.com/019fc2a136a2881181e73fea74a4836efc02195d","thumbnail_link":"","md5_checksum":"","hash":"","links":{},"phase":"PHASE_TYPE_COMPLETE","audit":{"status":"STATUS_OK","message":"正常资源","title":""},"medias":[],"trashed":false,"delete_time":"","original_url":"","params":{"file_property_count":"2","file_property_size":"2345590740","platform_icon":"https://backstage-img-ssl.a.88cdn.com/05e4f2d4a751f1895746a15da2d391105418a66d","tags":"NEW"},"original_file_index":0,"space":"","apps":[],"writable":true,"folder_type":"NORMAL","collection":null,"sort_name":"金子般我的明星 금쪽같은 내스타 (0000002025)","user_modified_time":"2025-08-20T11:25:35.939+08:00","spell_name":[],"file_category":"OTHER","tags":[],"reference_events":[],"reference_resource":null}],"user_info":{"user_id":"924119402","portrait_url":"https://xfile2.a.88cdn.com/file/k/avatar/default","nickname":"什么都不知道","avatar":"https://xfile2.a.88cdn.com/file/k/avatar/default"},"next_page_token":"","pass_code_token":"GdrGFHvaZTbIUKxOiJKyrWZpcdGoHysJZBa5iv7N8mmsNElcU/3M8+Jkp1NO0cMKlIN/0QHZ/pmCTyNmiGIs4g==","title":"金子般我的明星 금쪽같은 내스타 (2025)","icon_link":"https://backstage-img-ssl.a.88cdn.com/019fc2a136a2881181e73fea74a4836efc02195d","thumbnail_link":"","contain_sensitive_resource_text":"","params":{}}
|
||||
|
||||
=========================
|
||||
|
||||
|
||||
POST /drive/v1/share/restore HTTP/1.1
|
||||
Host: api-pan.xunlei.com
|
||||
Connection: close
|
||||
Content-Length: 250
|
||||
sec-ch-ua-platform: "macOS"
|
||||
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAzZjlmMzYxLWI3MjktNDVjNi04MjI0LTNiNWEwNmJmOGU0NSJ9.eyJpc3MiOiJodHRwczovL3hsdXNlci1zc2wueHVubGVpLmNvbSIsInN1YiI6IjEyMTk2MzY5NTIiLCJhdWQiOiJYcXAwa0pCWFdod2FUcEI2IiwiZXhwIjoxNzU1NzQxMDUxLCJpYXQiOjE3NTU2OTc4NTEsImF0X2hhc2giOiJyLmN0UWtJMUNmU1NpNkt2RFVUa0xidHciLCJzY29wZSI6InByb2ZpbGUgcGFuIHNzbyB1c2VyIiwicHJvamVjdF9pZCI6IjJydms0ZTNna2RubDd1MWtsMGsiLCJtZXRhIjp7ImEiOiJHOHNKVlJ6RjUzOE51eDRLOCt0VUtUR1hqdlZOTUczZlNrUEVaUWZLSWRBPSJ9fQ.ktWdGaShnGuU9CFj_awHaHfAoH0gkVBvdf4A24WPkRn-MUSpGRtUuo30VZay-wTbJ2UBEmw0pNtAeAfB97qzpPtVigDf9vD2WE9jIz61dASPxi6JIvioUfRzbgWa_Qj8a0bHc1Zvfufu9cSIzm0PPE7rSHYyq5oNfFxAAaY5k9cp2I4DclZon7hHJ0439knsu8MQEwQY42bRWppKqaI5q66Zlzeoc0t2I0Ehs2XYIyVyG2EYmFlbIWrpahudUTz6PbcrDMoHf8Y1hPnJn5ij7D32YSLXeP7ighQZDqaFoJHXJxqcHYToKut4JfQHCROkbqSwq3_I75k_A36gYtxVdA
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
sec-ch-ua-mobile: ?0
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-captcha-token: ck0.heh7e1aWMkJ3z0Lz7aFmAWoI0-wDx6Os2o8d_ZF5nJEO_jk11h3FtCwOQG68zkXSaXe4T-dz5NSeT6xuqsy0RoNY5xzgT9J5b6WRAMeTsCqmb_DbntFifcvedEWK92zeFSGoNThN8MjawUXYsG8Y7C1mpVf4ftkLzjrToROm-ZJzUN18VIVur8F0U7SLKnaom-S63QLDXGcR3Qat0OVjoScqSZ9ESKOKAvd9GZ3ZRbKExXEpp16Rc_t3YHG3HFIJFsjQbn6OTsVyV4Ku1OXqJV-8iM8XVawlOxhBKrk8GtIok-UUI4CFXNjFWlrSHkQzN3GbEVPiDJaD7qoATZNUecCJig_oSWZsYKYLWiFgg2QEEzqI21C67LKygm8o1YXEQCVi2v-aMyojBnJwS3I9RsFqhU4u3ChszjyVFnQOMpCtXdLLmn2fGBYrsZP_dNcZlhZYw3yxEImvLsQ5s_5mzQ.ClMI9dP3v4wzEhBYcXAwa0pCWFdod2FUcEI2GgYxLjkyLjciDnBhbi54dW5sZWkuY29tKiBjMjRlY2FkYzQ0YzY0MzYzN2QxMjdmYjg0N2RiZTM2ZBKAAWU_LJemk9r1ThQlFBr75NH03TjR0K-PMY2-QIsEcET8mqJo4uN7iyjGfFeZmcxczsjMBbBpO_3XeYR3Wdgatr2yqImozJKh8Ek3j1718cLVywaQ79z8j_Lj85J1-JfLYPkfUW1q8uBi4Tjz8vs3uSSuOtO5Fy-OF6NyGoYtvxJF
|
||||
DNT: 1
|
||||
content-type: application/json
|
||||
x-client-id: Xqp0kJBXWhwaTpB6
|
||||
Accept: */*
|
||||
Origin: https://pan.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://pan.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
Accept-Language: zh-CN,zh;q=0.9,ko-KR;q=0.8,ko;q=0.7
|
||||
|
||||
{"parent_id":"","share_id":"VOY4fDN-35yNfnqBJ3lSXfK4A1","pass_code_token":"GdrGFHvaZTbIUKxOiJKyrWZpcdGoHysJZBa5iv7N8mmsNElcU/3M8+Jkp1NO0cMKlIN/0QHZ/pmCTyNmiGIs4g==","ancestor_ids":[],"file_ids":["VOY4UMZhqz1ZHO8_WNwF6V5JA1"],"specify_parent_id":true}
|
||||
|
||||
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 15:02:59 GMT
|
||||
Content-Type: application/json
|
||||
Content-Length: 149
|
||||
Connection: close
|
||||
Grpc-Metadata-Content-Type: application/grpc
|
||||
Access-Control-Allow-Origin: https://pan.xunlei.com
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Methods: PUT, POST, GET, DELETE, OPTIONS, PATCH
|
||||
Access-Control-Allow-Headers: DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,csrf-token,X-Captcha-Token,X-Device-Id,X-Guid,X-Project-Id,X-Client-Id,X-Peer-Id,X-User-Id,X-Request-From,X-Client-Version-Code,space-authorization
|
||||
Access-Control-Expose-Headers: csrf-token
|
||||
|
||||
{"share_status":"OK","share_status_text":"","file_id":"","restore_status":"RESTORE_START","restore_task_id":"VOY7-IPZkcoBobh3Az0dfyxRA1","params":{}}
|
||||
|
||||
==================
|
||||
|
||||
|
||||
|
||||
GET /drive/v1/tasks/VOY7-IPZkcoBobh3Az0dfyxRA1 HTTP/1.1
|
||||
Host: api-pan.xunlei.com
|
||||
Connection: close
|
||||
sec-ch-ua-platform: "macOS"
|
||||
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAzZjlmMzYxLWI3MjktNDVjNi04MjI0LTNiNWEwNmJmOGU0NSJ9.eyJpc3MiOiJodHRwczovL3hsdXNlci1zc2wueHVubGVpLmNvbSIsInN1YiI6IjEyMTk2MzY5NTIiLCJhdWQiOiJYcXAwa0pCWFdod2FUcEI2IiwiZXhwIjoxNzU1NzQxMDUxLCJpYXQiOjE3NTU2OTc4NTEsImF0X2hhc2giOiJyLmN0UWtJMUNmU1NpNkt2RFVUa0xidHciLCJzY29wZSI6InByb2ZpbGUgcGFuIHNzbyB1c2VyIiwicHJvamVjdF9pZCI6IjJydms0ZTNna2RubDd1MWtsMGsiLCJtZXRhIjp7ImEiOiJHOHNKVlJ6RjUzOE51eDRLOCt0VUtUR1hqdlZOTUczZlNrUEVaUWZLSWRBPSJ9fQ.ktWdGaShnGuU9CFj_awHaHfAoH0gkVBvdf4A24WPkRn-MUSpGRtUuo30VZay-wTbJ2UBEmw0pNtAeAfB97qzpPtVigDf9vD2WE9jIz61dASPxi6JIvioUfRzbgWa_Qj8a0bHc1Zvfufu9cSIzm0PPE7rSHYyq5oNfFxAAaY5k9cp2I4DclZon7hHJ0439knsu8MQEwQY42bRWppKqaI5q66Zlzeoc0t2I0Ehs2XYIyVyG2EYmFlbIWrpahudUTz6PbcrDMoHf8Y1hPnJn5ij7D32YSLXeP7ighQZDqaFoJHXJxqcHYToKut4JfQHCROkbqSwq3_I75k_A36gYtxVdA
|
||||
x-device-id: c24ecadc44c643637d127fb847dbe36d
|
||||
sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"
|
||||
sec-ch-ua-mobile: ?0
|
||||
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36
|
||||
x-captcha-token: ck0.heh7e1aWMkJ3z0Lz7aFmAWoI0-wDx6Os2o8d_ZF5nJEO_jk11h3FtCwOQG68zkXSaXe4T-dz5NSeT6xuqsy0RoNY5xzgT9J5b6WRAMeTsCqmb_DbntFifcvedEWK92zeFSGoNThN8MjawUXYsG8Y7C1mpVf4ftkLzjrToROm-ZJzUN18VIVur8F0U7SLKnaom-S63QLDXGcR3Qat0OVjoScqSZ9ESKOKAvd9GZ3ZRbKExXEpp16Rc_t3YHG3HFIJFsjQbn6OTsVyV4Ku1OXqJV-8iM8XVawlOxhBKrk8GtIok-UUI4CFXNjFWlrSHkQzN3GbEVPiDJaD7qoATZNUecCJig_oSWZsYKYLWiFgg2QEEzqI21C67LKygm8o1YXEQCVi2v-aMyojBnJwS3I9RsFqhU4u3ChszjyVFnQOMpCtXdLLmn2fGBYrsZP_dNcZlhZYw3yxEImvLsQ5s_5mzQ.ClMI9dP3v4wzEhBYcXAwa0pCWFdod2FUcEI2GgYxLjkyLjciDnBhbi54dW5sZWkuY29tKiBjMjRlY2FkYzQ0YzY0MzYzN2QxMjdmYjg0N2RiZTM2ZBKAAWU_LJemk9r1ThQlFBr75NH03TjR0K-PMY2-QIsEcET8mqJo4uN7iyjGfFeZmcxczsjMBbBpO_3XeYR3Wdgatr2yqImozJKh8Ek3j1718cLVywaQ79z8j_Lj85J1-JfLYPkfUW1q8uBi4Tjz8vs3uSSuOtO5Fy-OF6NyGoYtvxJF
|
||||
DNT: 1
|
||||
content-type: application/json
|
||||
x-client-id: Xqp0kJBXWhwaTpB6
|
||||
Accept: */*
|
||||
Origin: https://pan.xunlei.com
|
||||
Sec-Fetch-Site: same-site
|
||||
Sec-Fetch-Mode: cors
|
||||
Sec-Fetch-Dest: empty
|
||||
Referer: https://pan.xunlei.com/
|
||||
Accept-Encoding: gzip, deflate
|
||||
Accept-Language: zh-CN,zh;q=0.9,ko-KR;q=0.8,ko;q=0.7
|
||||
|
||||
|
||||
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Date: Wed, 20 Aug 2025 15:03:01 GMT
|
||||
Content-Type: application/json
|
||||
Content-Length: 745
|
||||
Connection: close
|
||||
Grpc-Metadata-Content-Type: application/grpc
|
||||
Access-Control-Allow-Origin: https://pan.xunlei.com
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Allow-Methods: PUT, POST, GET, DELETE, OPTIONS, PATCH
|
||||
Access-Control-Allow-Headers: DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,csrf-token,X-Captcha-Token,X-Device-Id,X-Guid,X-Project-Id,X-Client-Id,X-Peer-Id,X-User-Id,X-Request-From,X-Client-Version-Code,space-authorization
|
||||
Access-Control-Expose-Headers: csrf-token
|
||||
|
||||
{"kind":"drive#task","id":"VOY7-IPZkcoBobh3Az0dfyxRA1","name":"restore","type":"restore","user_id":"1219636952","statuses":[],"status_size":0,"params":{"notify_restore_reward":"VOY7-IcLzcXgdt9SPIA0Naa-A1","notify_restore_skin":"VOY7-Ic2zcXgdt9SPIA0Na_kA1","share_id":"VOY4fDN-35yNfnqBJ3lSXfK4A1","trace_file_ids":"{\"VOY4UMZhqz1ZHO8_WNwF6V5JA1\":\"VOY7-IXUzcXgdt9SPIA0NaWuA1\"}"},"file_id":"","file_name":"","file_size":"0","message":"完成","created_time":"2025-08-20T23:02:59.492+08:00","updated_time":"2025-08-20T23:03:00.376+08:00","third_task_id":"","phase":"PHASE_TYPE_COMPLETE","progress":100,"icon_link":"https://backstage-img-ssl.a.88cdn.com/05e4f2d4a751f1895746a15da2d391105418a66d","callback":"","reference_resource":null,"space":""}
|
||||
|
||||
|
||||
|
||||
================================
|
||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,10 @@ func InitDB() error {
|
||||
&entity.Task{},
|
||||
&entity.TaskItem{},
|
||||
&entity.File{},
|
||||
&entity.TelegramChannel{},
|
||||
&entity.APIAccessLog{},
|
||||
&entity.APIAccessLogStats{},
|
||||
&entity.APIAccessLogSummary{},
|
||||
)
|
||||
if err != nil {
|
||||
utils.Fatal("数据库迁移失败: %v", err)
|
||||
@@ -146,6 +150,7 @@ func autoMigrate() error {
|
||||
&entity.SearchStat{},
|
||||
&entity.HotDrama{},
|
||||
&entity.File{},
|
||||
&entity.TelegramChannel{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
66
db/converter/api_access_log_converter.go
Normal file
66
db/converter/api_access_log_converter.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ToAPIAccessLogResponse 将APIAccessLog实体转换为APIAccessLogResponse
|
||||
func ToAPIAccessLogResponse(log *entity.APIAccessLog) dto.APIAccessLogResponse {
|
||||
return dto.APIAccessLogResponse{
|
||||
ID: log.ID,
|
||||
IP: log.IP,
|
||||
UserAgent: log.UserAgent,
|
||||
Endpoint: log.Endpoint,
|
||||
Method: log.Method,
|
||||
RequestParams: log.RequestParams,
|
||||
ResponseStatus: log.ResponseStatus,
|
||||
ResponseData: log.ResponseData,
|
||||
ProcessCount: log.ProcessCount,
|
||||
ErrorMessage: log.ErrorMessage,
|
||||
ProcessingTime: log.ProcessingTime,
|
||||
CreatedAt: log.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIAccessLogResponseList 将APIAccessLog实体列表转换为APIAccessLogResponse列表
|
||||
func ToAPIAccessLogResponseList(logs []entity.APIAccessLog) []dto.APIAccessLogResponse {
|
||||
responses := make([]dto.APIAccessLogResponse, len(logs))
|
||||
for i, log := range logs {
|
||||
responses[i] = ToAPIAccessLogResponse(&log)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToAPIAccessLogSummaryResponse 将APIAccessLogSummary实体转换为APIAccessLogSummaryResponse
|
||||
func ToAPIAccessLogSummaryResponse(summary *entity.APIAccessLogSummary) dto.APIAccessLogSummaryResponse {
|
||||
return dto.APIAccessLogSummaryResponse{
|
||||
TotalRequests: summary.TotalRequests,
|
||||
TodayRequests: summary.TodayRequests,
|
||||
WeekRequests: summary.WeekRequests,
|
||||
MonthRequests: summary.MonthRequests,
|
||||
ErrorRequests: summary.ErrorRequests,
|
||||
UniqueIPs: summary.UniqueIPs,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIAccessLogStatsResponse 将APIAccessLogStats实体转换为APIAccessLogStatsResponse
|
||||
func ToAPIAccessLogStatsResponse(stat entity.APIAccessLogStats) dto.APIAccessLogStatsResponse {
|
||||
return dto.APIAccessLogStatsResponse{
|
||||
Endpoint: stat.Endpoint,
|
||||
Method: stat.Method,
|
||||
RequestCount: stat.RequestCount,
|
||||
ErrorCount: stat.ErrorCount,
|
||||
AvgProcessTime: stat.AvgProcessTime,
|
||||
LastAccess: stat.LastAccess,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIAccessLogStatsResponseList 将APIAccessLogStats实体列表转换为APIAccessLogStatsResponse列表
|
||||
func ToAPIAccessLogStatsResponseList(stats []entity.APIAccessLogStats) []dto.APIAccessLogStatsResponse {
|
||||
responses := make([]dto.APIAccessLogStatsResponse, len(stats))
|
||||
for i, stat := range stats {
|
||||
responses[i] = ToAPIAccessLogStatsResponse(stat)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
@@ -73,6 +73,9 @@ func ToResourceResponseFromMeilisearch(doc interface{}) dto.ResourceResponse {
|
||||
if urlField := docValue.FieldByName("URL"); urlField.IsValid() {
|
||||
response.URL = urlField.String()
|
||||
}
|
||||
if coverField := docValue.FieldByName("Cover"); coverField.IsValid() {
|
||||
response.Cover = coverField.String()
|
||||
}
|
||||
if saveURLField := docValue.FieldByName("SaveURL"); saveURLField.IsValid() {
|
||||
response.SaveURL = saveURLField.String()
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func HotDramaToResponse(drama *entity.HotDrama) *dto.HotDramaResponse {
|
||||
PosterURL: drama.PosterURL,
|
||||
Category: drama.Category,
|
||||
SubType: drama.SubType,
|
||||
Rank: drama.Rank,
|
||||
Source: drama.Source,
|
||||
DoubanID: drama.DoubanID,
|
||||
DoubanURI: drama.DoubanURI,
|
||||
@@ -49,6 +50,7 @@ func RequestToHotDrama(req *dto.HotDramaRequest) *entity.HotDrama {
|
||||
Actors: req.Actors,
|
||||
Category: req.Category,
|
||||
SubType: req.SubType,
|
||||
Rank: req.Rank,
|
||||
Source: req.Source,
|
||||
DoubanID: req.DoubanID,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -90,6 +91,25 @@ func SystemConfigToResponse(configs []entity.SystemConfig) *dto.SystemConfigResp
|
||||
response.MeilisearchMasterKey = config.Value
|
||||
case entity.ConfigKeyMeilisearchIndexName:
|
||||
response.MeilisearchIndexName = config.Value
|
||||
case entity.ConfigKeyEnableAnnouncements:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response.EnableAnnouncements = val
|
||||
}
|
||||
case entity.ConfigKeyAnnouncements:
|
||||
if config.Value == "" || config.Value == "[]" {
|
||||
response.Announcements = ""
|
||||
} else {
|
||||
// 在响应时保持为字符串,后续由前端处理
|
||||
response.Announcements = config.Value
|
||||
}
|
||||
case entity.ConfigKeyEnableFloatButtons:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response.EnableFloatButtons = val
|
||||
}
|
||||
case entity.ConfigKeyWechatSearchImage:
|
||||
response.WechatSearchImage = config.Value
|
||||
case entity.ConfigKeyTelegramQrImage:
|
||||
response.TelegramQrImage = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +241,31 @@ func RequestToSystemConfig(req *dto.SystemConfigRequest) []entity.SystemConfig {
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyMeilisearchIndexName)
|
||||
}
|
||||
|
||||
// 界面配置处理
|
||||
if req.EnableAnnouncements != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyEnableAnnouncements, Value: strconv.FormatBool(*req.EnableAnnouncements), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyEnableAnnouncements)
|
||||
}
|
||||
if req.Announcements != nil {
|
||||
// 将数组转换为JSON字符串
|
||||
if jsonBytes, err := json.Marshal(*req.Announcements); err == nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyAnnouncements, Value: string(jsonBytes), Type: entity.ConfigTypeJSON})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyAnnouncements)
|
||||
}
|
||||
}
|
||||
if req.EnableFloatButtons != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyEnableFloatButtons, Value: strconv.FormatBool(*req.EnableFloatButtons), Type: entity.ConfigTypeBool})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyEnableFloatButtons)
|
||||
}
|
||||
if req.WechatSearchImage != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyWechatSearchImage, Value: *req.WechatSearchImage, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyWechatSearchImage)
|
||||
}
|
||||
if req.TelegramQrImage != nil {
|
||||
configs = append(configs, entity.SystemConfig{Key: entity.ConfigKeyTelegramQrImage, Value: *req.TelegramQrImage, Type: entity.ConfigTypeString})
|
||||
updatedKeys = append(updatedKeys, entity.ConfigKeyTelegramQrImage)
|
||||
}
|
||||
|
||||
// 记录更新的配置项
|
||||
if len(updatedKeys) > 0 {
|
||||
utils.Info("配置更新 - 被修改的配置项: %v", updatedKeys)
|
||||
@@ -332,6 +377,24 @@ func SystemConfigToPublicResponse(configs []entity.SystemConfig) map[string]inte
|
||||
response[entity.ConfigResponseFieldMeilisearchMasterKey] = config.Value
|
||||
case entity.ConfigKeyMeilisearchIndexName:
|
||||
response[entity.ConfigResponseFieldMeilisearchIndexName] = config.Value
|
||||
case entity.ConfigKeyEnableAnnouncements:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["enable_announcements"] = val
|
||||
}
|
||||
case entity.ConfigKeyAnnouncements:
|
||||
if config.Value == "" || config.Value == "[]" {
|
||||
response["announcements"] = ""
|
||||
} else {
|
||||
response["announcements"] = config.Value
|
||||
}
|
||||
case entity.ConfigKeyEnableFloatButtons:
|
||||
if val, err := strconv.ParseBool(config.Value); err == nil {
|
||||
response["enable_float_buttons"] = val
|
||||
}
|
||||
case entity.ConfigKeyWechatSearchImage:
|
||||
response["wechat_search_image"] = config.Value
|
||||
case entity.ConfigKeyTelegramQrImage:
|
||||
response["telegram_qr_image"] = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,5 +435,10 @@ func getDefaultConfigResponse() *dto.SystemConfigResponse {
|
||||
MeilisearchPort: entity.ConfigDefaultMeilisearchPort,
|
||||
MeilisearchMasterKey: entity.ConfigDefaultMeilisearchMasterKey,
|
||||
MeilisearchIndexName: entity.ConfigDefaultMeilisearchIndexName,
|
||||
EnableAnnouncements: false,
|
||||
Announcements: "",
|
||||
EnableFloatButtons: false,
|
||||
WechatSearchImage: entity.ConfigDefaultWechatSearchImage,
|
||||
TelegramQrImage: entity.ConfigDefaultTelegramQrImage,
|
||||
}
|
||||
}
|
||||
|
||||
307
db/converter/telegram_channel_converter.go
Normal file
307
db/converter/telegram_channel_converter.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// TelegramChannelToResponse 将TelegramChannel实体转换为响应DTO
|
||||
func TelegramChannelToResponse(channel entity.TelegramChannel) dto.TelegramChannelResponse {
|
||||
return dto.TelegramChannelResponse{
|
||||
ID: channel.ID,
|
||||
ChatID: channel.ChatID,
|
||||
ChatName: channel.ChatName,
|
||||
ChatType: channel.ChatType,
|
||||
PushEnabled: channel.PushEnabled,
|
||||
PushFrequency: channel.PushFrequency,
|
||||
PushStartTime: channel.PushStartTime,
|
||||
PushEndTime: channel.PushEndTime,
|
||||
ContentCategories: channel.ContentCategories,
|
||||
ContentTags: channel.ContentTags,
|
||||
IsActive: channel.IsActive,
|
||||
ResourceStrategy: channel.ResourceStrategy,
|
||||
TimeLimit: channel.TimeLimit,
|
||||
LastPushAt: channel.LastPushAt,
|
||||
RegisteredBy: channel.RegisteredBy,
|
||||
RegisteredAt: channel.RegisteredAt,
|
||||
}
|
||||
}
|
||||
|
||||
// TelegramChannelsToResponse 将TelegramChannel实体列表转换为响应DTO列表
|
||||
func TelegramChannelsToResponse(channels []entity.TelegramChannel) []dto.TelegramChannelResponse {
|
||||
var responses []dto.TelegramChannelResponse
|
||||
for _, channel := range channels {
|
||||
responses = append(responses, TelegramChannelToResponse(channel))
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// RequestToTelegramChannel 将请求DTO转换为TelegramChannel实体
|
||||
func RequestToTelegramChannel(req dto.TelegramChannelRequest, registeredBy string) entity.TelegramChannel {
|
||||
channel := entity.TelegramChannel{
|
||||
ChatID: req.ChatID,
|
||||
ChatName: req.ChatName,
|
||||
ChatType: req.ChatType,
|
||||
PushEnabled: req.PushEnabled,
|
||||
PushFrequency: req.PushFrequency,
|
||||
PushStartTime: req.PushStartTime,
|
||||
PushEndTime: req.PushEndTime,
|
||||
ContentCategories: req.ContentCategories,
|
||||
ContentTags: req.ContentTags,
|
||||
IsActive: req.IsActive,
|
||||
RegisteredBy: registeredBy,
|
||||
RegisteredAt: time.Now(),
|
||||
}
|
||||
|
||||
// 设置默认值(如果为空)
|
||||
if req.ResourceStrategy == "" {
|
||||
channel.ResourceStrategy = "random"
|
||||
} else {
|
||||
channel.ResourceStrategy = req.ResourceStrategy
|
||||
}
|
||||
|
||||
if req.TimeLimit == "" {
|
||||
channel.TimeLimit = "none"
|
||||
} else {
|
||||
channel.TimeLimit = req.TimeLimit
|
||||
}
|
||||
|
||||
return channel
|
||||
}
|
||||
|
||||
// TelegramBotConfigToResponse 将Telegram bot配置转换为响应DTO
|
||||
func TelegramBotConfigToResponse(
|
||||
botEnabled bool,
|
||||
botApiKey string,
|
||||
autoReplyEnabled bool,
|
||||
autoReplyTemplate string,
|
||||
autoDeleteEnabled bool,
|
||||
autoDeleteInterval int,
|
||||
proxyEnabled bool,
|
||||
proxyType string,
|
||||
proxyHost string,
|
||||
proxyPort int,
|
||||
proxyUsername string,
|
||||
proxyPassword string,
|
||||
) dto.TelegramBotConfigResponse {
|
||||
return dto.TelegramBotConfigResponse{
|
||||
BotEnabled: botEnabled,
|
||||
BotApiKey: botApiKey,
|
||||
AutoReplyEnabled: autoReplyEnabled,
|
||||
AutoReplyTemplate: autoReplyTemplate,
|
||||
AutoDeleteEnabled: autoDeleteEnabled,
|
||||
AutoDeleteInterval: autoDeleteInterval,
|
||||
ProxyEnabled: proxyEnabled,
|
||||
ProxyType: proxyType,
|
||||
ProxyHost: proxyHost,
|
||||
ProxyPort: proxyPort,
|
||||
ProxyUsername: proxyUsername,
|
||||
ProxyPassword: proxyPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// SystemConfigToTelegramBotConfig 将系统配置转换为Telegram bot配置响应
|
||||
func SystemConfigToTelegramBotConfig(configs []entity.SystemConfig) dto.TelegramBotConfigResponse {
|
||||
botEnabled := false
|
||||
botApiKey := ""
|
||||
autoReplyEnabled := true
|
||||
autoReplyTemplate := "您好!我可以帮您搜索网盘资源,请输入您要搜索的内容。"
|
||||
autoDeleteEnabled := false
|
||||
autoDeleteInterval := 60
|
||||
proxyEnabled := false
|
||||
proxyType := "http"
|
||||
proxyHost := ""
|
||||
proxyPort := 8080
|
||||
proxyUsername := ""
|
||||
proxyPassword := ""
|
||||
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case entity.ConfigKeyTelegramBotEnabled:
|
||||
botEnabled = config.Value == "true"
|
||||
case entity.ConfigKeyTelegramBotApiKey:
|
||||
botApiKey = config.Value
|
||||
case entity.ConfigKeyTelegramAutoReplyEnabled:
|
||||
autoReplyEnabled = config.Value == "true"
|
||||
case entity.ConfigKeyTelegramAutoReplyTemplate:
|
||||
autoReplyTemplate = config.Value
|
||||
case entity.ConfigKeyTelegramAutoDeleteEnabled:
|
||||
autoDeleteEnabled = config.Value == "true"
|
||||
case entity.ConfigKeyTelegramAutoDeleteInterval:
|
||||
if config.Value != "" {
|
||||
// 简单解析整数,这里可以改进错误处理
|
||||
var val int
|
||||
if _, err := fmt.Sscanf(config.Value, "%d", &val); err == nil {
|
||||
autoDeleteInterval = val
|
||||
}
|
||||
}
|
||||
case entity.ConfigKeyTelegramProxyEnabled:
|
||||
proxyEnabled = config.Value == "true"
|
||||
case entity.ConfigKeyTelegramProxyType:
|
||||
proxyType = config.Value
|
||||
case entity.ConfigKeyTelegramProxyHost:
|
||||
proxyHost = config.Value
|
||||
case entity.ConfigKeyTelegramProxyPort:
|
||||
if config.Value != "" {
|
||||
var val int
|
||||
if _, err := fmt.Sscanf(config.Value, "%d", &val); err == nil {
|
||||
proxyPort = val
|
||||
}
|
||||
}
|
||||
case entity.ConfigKeyTelegramProxyUsername:
|
||||
proxyUsername = config.Value
|
||||
case entity.ConfigKeyTelegramProxyPassword:
|
||||
proxyPassword = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
return TelegramBotConfigToResponse(
|
||||
botEnabled,
|
||||
botApiKey,
|
||||
autoReplyEnabled,
|
||||
autoReplyTemplate,
|
||||
autoDeleteEnabled,
|
||||
autoDeleteInterval,
|
||||
proxyEnabled,
|
||||
proxyType,
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
proxyUsername,
|
||||
proxyPassword,
|
||||
)
|
||||
}
|
||||
|
||||
// TelegramBotConfigRequestToSystemConfigs 将Telegram bot配置请求转换为系统配置实体列表
|
||||
func TelegramBotConfigRequestToSystemConfigs(req dto.TelegramBotConfigRequest) []entity.SystemConfig {
|
||||
configs := []entity.SystemConfig{}
|
||||
|
||||
// 添加调试日志
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 转换请求: %+v", req)
|
||||
|
||||
if req.BotEnabled != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramBotEnabled,
|
||||
Value: boolToString(*req.BotEnabled),
|
||||
Type: entity.ConfigTypeBool,
|
||||
})
|
||||
}
|
||||
|
||||
if req.BotApiKey != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramBotApiKey,
|
||||
Value: *req.BotApiKey,
|
||||
Type: entity.ConfigTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
if req.AutoReplyEnabled != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramAutoReplyEnabled,
|
||||
Value: boolToString(*req.AutoReplyEnabled),
|
||||
Type: entity.ConfigTypeBool,
|
||||
})
|
||||
}
|
||||
|
||||
if req.AutoReplyTemplate != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramAutoReplyTemplate,
|
||||
Value: *req.AutoReplyTemplate,
|
||||
Type: entity.ConfigTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
if req.AutoDeleteEnabled != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramAutoDeleteEnabled,
|
||||
Value: boolToString(*req.AutoDeleteEnabled),
|
||||
Type: entity.ConfigTypeBool,
|
||||
})
|
||||
}
|
||||
|
||||
if req.AutoDeleteInterval != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramAutoDeleteInterval,
|
||||
Value: intToString(*req.AutoDeleteInterval),
|
||||
Type: entity.ConfigTypeInt,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ProxyEnabled != nil {
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 添加代理启用配置: %v", *req.ProxyEnabled)
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramProxyEnabled,
|
||||
Value: boolToString(*req.ProxyEnabled),
|
||||
Type: entity.ConfigTypeBool,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ProxyType != nil {
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 添加代理类型配置: %s", *req.ProxyType)
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramProxyType,
|
||||
Value: *req.ProxyType,
|
||||
Type: entity.ConfigTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ProxyHost != nil {
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 添加代理主机配置: %s", *req.ProxyHost)
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramProxyHost,
|
||||
Value: *req.ProxyHost,
|
||||
Type: entity.ConfigTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ProxyPort != nil {
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 添加代理端口配置: %d", *req.ProxyPort)
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramProxyPort,
|
||||
Value: intToString(*req.ProxyPort),
|
||||
Type: entity.ConfigTypeInt,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ProxyUsername != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramProxyUsername,
|
||||
Value: *req.ProxyUsername,
|
||||
Type: entity.ConfigTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ProxyPassword != nil {
|
||||
configs = append(configs, entity.SystemConfig{
|
||||
Key: entity.ConfigKeyTelegramProxyPassword,
|
||||
Value: *req.ProxyPassword,
|
||||
Type: entity.ConfigTypeString,
|
||||
})
|
||||
}
|
||||
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 转换完成,共生成 %d 个配置项", len(configs))
|
||||
for i, config := range configs {
|
||||
if strings.Contains(config.Key, "proxy") {
|
||||
utils.Debug("[TELEGRAM:CONVERTER] 配置项 %d: %s = %s", i+1, config.Key, config.Value)
|
||||
}
|
||||
}
|
||||
|
||||
return configs
|
||||
}
|
||||
|
||||
// 辅助函数:布尔转换为字符串
|
||||
func boolToString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// 辅助函数:整数转换为字符串
|
||||
func intToString(i int) string {
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
55
db/dto/api_access_log.go
Normal file
55
db/dto/api_access_log.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// APIAccessLogResponse API访问日志响应
|
||||
type APIAccessLogResponse struct {
|
||||
ID uint `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
RequestParams string `json:"request_params"`
|
||||
ResponseStatus int `json:"response_status"`
|
||||
ResponseData string `json:"response_data"`
|
||||
ProcessCount int `json:"process_count"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
ProcessingTime int64 `json:"processing_time"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// APIAccessLogSummaryResponse API访问日志汇总响应
|
||||
type APIAccessLogSummaryResponse struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TodayRequests int64 `json:"today_requests"`
|
||||
WeekRequests int64 `json:"week_requests"`
|
||||
MonthRequests int64 `json:"month_requests"`
|
||||
ErrorRequests int64 `json:"error_requests"`
|
||||
UniqueIPs int64 `json:"unique_ips"`
|
||||
}
|
||||
|
||||
// APIAccessLogStatsResponse 按端点统计响应
|
||||
type APIAccessLogStatsResponse struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
AvgProcessTime int64 `json:"avg_process_time"`
|
||||
LastAccess time.Time `json:"last_access"`
|
||||
}
|
||||
|
||||
// APIAccessLogListResponse API访问日志列表响应
|
||||
type APIAccessLogListResponse struct {
|
||||
Data []APIAccessLogResponse `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// APIAccessLogFilterRequest API访问日志过滤请求
|
||||
type APIAccessLogFilterRequest struct {
|
||||
StartDate string `json:"start_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Page int `json:"page,omitempty" default:"1"`
|
||||
PageSize int `json:"page_size,omitempty" default:"20"`
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type HotDramaRequest struct {
|
||||
PosterURL string `json:"poster_url"`
|
||||
Category string `json:"category"`
|
||||
SubType string `json:"sub_type"`
|
||||
Rank int `json:"rank"`
|
||||
Source string `json:"source"`
|
||||
DoubanID string `json:"douban_id"`
|
||||
DoubanURI string `json:"douban_uri"`
|
||||
@@ -41,6 +42,7 @@ type HotDramaResponse struct {
|
||||
PosterURL string `json:"poster_url"`
|
||||
Category string `json:"category"`
|
||||
SubType string `json:"sub_type"`
|
||||
Rank int `json:"rank"`
|
||||
Source string `json:"source"`
|
||||
DoubanID string `json:"douban_id"`
|
||||
DoubanURI string `json:"douban_uri"`
|
||||
|
||||
@@ -37,6 +37,9 @@ type ResourceResponse struct {
|
||||
DescriptionHighlight string `json:"description_highlight,omitempty"`
|
||||
CategoryHighlight string `json:"category_highlight,omitempty"`
|
||||
TagsHighlight []string `json:"tags_highlight,omitempty"`
|
||||
// 违禁词相关字段
|
||||
HasForbiddenWords bool `json:"has_forbidden_words"`
|
||||
ForbiddenWords []string `json:"forbidden_words"`
|
||||
}
|
||||
|
||||
// CategoryResponse 分类响应
|
||||
|
||||
@@ -42,6 +42,13 @@ type SystemConfigRequest struct {
|
||||
MeilisearchPort *string `json:"meilisearch_port,omitempty"`
|
||||
MeilisearchMasterKey *string `json:"meilisearch_master_key,omitempty"`
|
||||
MeilisearchIndexName *string `json:"meilisearch_index_name,omitempty"`
|
||||
|
||||
// 界面配置
|
||||
EnableAnnouncements *bool `json:"enable_announcements,omitempty"`
|
||||
Announcements *[]map[string]interface{} `json:"announcements,omitempty"`
|
||||
EnableFloatButtons *bool `json:"enable_float_buttons,omitempty"`
|
||||
WechatSearchImage *string `json:"wechat_search_image,omitempty"`
|
||||
TelegramQrImage *string `json:"telegram_qr_image,omitempty"`
|
||||
}
|
||||
|
||||
// SystemConfigResponse 系统配置响应
|
||||
@@ -90,6 +97,13 @@ type SystemConfigResponse struct {
|
||||
MeilisearchPort string `json:"meilisearch_port"`
|
||||
MeilisearchMasterKey string `json:"meilisearch_master_key"`
|
||||
MeilisearchIndexName string `json:"meilisearch_index_name"`
|
||||
|
||||
// 界面配置
|
||||
EnableAnnouncements bool `json:"enable_announcements"`
|
||||
Announcements string `json:"announcements"`
|
||||
EnableFloatButtons bool `json:"enable_float_buttons"`
|
||||
WechatSearchImage string `json:"wechat_search_image"`
|
||||
TelegramQrImage string `json:"telegram_qr_image"`
|
||||
}
|
||||
|
||||
// SystemConfigItem 单个配置项
|
||||
|
||||
105
db/dto/telegram_channel.go
Normal file
105
db/dto/telegram_channel.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// TelegramChannelRequest 创建 Telegram 频道/群组请求
|
||||
type TelegramChannelRequest struct {
|
||||
ChatID int64 `json:"chat_id" binding:"required"`
|
||||
ChatName string `json:"chat_name" binding:"required"`
|
||||
ChatType string `json:"chat_type" binding:"required"` // channel 或 group
|
||||
PushEnabled bool `json:"push_enabled"`
|
||||
PushFrequency int `json:"push_frequency"`
|
||||
PushStartTime string `json:"push_start_time"`
|
||||
PushEndTime string `json:"push_end_time"`
|
||||
ContentCategories string `json:"content_categories"`
|
||||
ContentTags string `json:"content_tags"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ResourceStrategy string `json:"resource_strategy"`
|
||||
TimeLimit string `json:"time_limit"`
|
||||
}
|
||||
|
||||
// TelegramChannelUpdateRequest 更新 Telegram 频道/群组请求(ChatID可选)
|
||||
type TelegramChannelUpdateRequest struct {
|
||||
ChatID int64 `json:"chat_id"` // 可选,用于验证
|
||||
ChatName string `json:"chat_name" binding:"required"`
|
||||
ChatType string `json:"chat_type" binding:"required"` // channel 或 group
|
||||
PushEnabled bool `json:"push_enabled"`
|
||||
PushFrequency int `json:"push_frequency"`
|
||||
PushStartTime string `json:"push_start_time"`
|
||||
PushEndTime string `json:"push_end_time"`
|
||||
ContentCategories string `json:"content_categories"`
|
||||
ContentTags string `json:"content_tags"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ResourceStrategy string `json:"resource_strategy"`
|
||||
TimeLimit string `json:"time_limit"`
|
||||
}
|
||||
|
||||
// TelegramChannelResponse Telegram 频道/群组响应
|
||||
type TelegramChannelResponse struct {
|
||||
ID uint `json:"id"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
ChatName string `json:"chat_name"`
|
||||
ChatType string `json:"chat_type"`
|
||||
PushEnabled bool `json:"push_enabled"`
|
||||
PushFrequency int `json:"push_frequency"`
|
||||
PushStartTime string `json:"push_start_time"`
|
||||
PushEndTime string `json:"push_end_time"`
|
||||
ContentCategories string `json:"content_categories"`
|
||||
ContentTags string `json:"content_tags"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ResourceStrategy string `json:"resource_strategy"`
|
||||
TimeLimit string `json:"time_limit"`
|
||||
LastPushAt *time.Time `json:"last_push_at"`
|
||||
RegisteredBy string `json:"registered_by"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
}
|
||||
|
||||
// TelegramBotConfigRequest Telegram 机器人配置请求
|
||||
type TelegramBotConfigRequest struct {
|
||||
BotEnabled *bool `json:"bot_enabled"`
|
||||
BotApiKey *string `json:"bot_api_key"`
|
||||
AutoReplyEnabled *bool `json:"auto_reply_enabled"`
|
||||
AutoReplyTemplate *string `json:"auto_reply_template"`
|
||||
AutoDeleteEnabled *bool `json:"auto_delete_enabled"`
|
||||
AutoDeleteInterval *int `json:"auto_delete_interval"`
|
||||
ProxyEnabled *bool `json:"proxy_enabled"`
|
||||
ProxyType *string `json:"proxy_type"`
|
||||
ProxyHost *string `json:"proxy_host"`
|
||||
ProxyPort *int `json:"proxy_port"`
|
||||
ProxyUsername *string `json:"proxy_username"`
|
||||
ProxyPassword *string `json:"proxy_password"`
|
||||
}
|
||||
|
||||
// TelegramBotConfigResponse Telegram 机器人配置响应
|
||||
type TelegramBotConfigResponse struct {
|
||||
BotEnabled bool `json:"bot_enabled"`
|
||||
BotApiKey string `json:"bot_api_key"`
|
||||
AutoReplyEnabled bool `json:"auto_reply_enabled"`
|
||||
AutoReplyTemplate string `json:"auto_reply_template"`
|
||||
AutoDeleteEnabled bool `json:"auto_delete_enabled"`
|
||||
AutoDeleteInterval int `json:"auto_delete_interval"`
|
||||
ProxyEnabled bool `json:"proxy_enabled"`
|
||||
ProxyType string `json:"proxy_type"`
|
||||
ProxyHost string `json:"proxy_host"`
|
||||
ProxyPort int `json:"proxy_port"`
|
||||
ProxyUsername string `json:"proxy_username"`
|
||||
ProxyPassword string `json:"proxy_password"`
|
||||
}
|
||||
|
||||
// ValidateTelegramApiKeyRequest 验证 Telegram API Key 请求
|
||||
type ValidateTelegramApiKeyRequest struct {
|
||||
ApiKey string `json:"api_key" binding:"required"`
|
||||
ProxyEnabled bool `json:"proxy_enabled"`
|
||||
ProxyType string `json:"proxy_type"`
|
||||
ProxyHost string `json:"proxy_host"`
|
||||
ProxyPort int `json:"proxy_port"`
|
||||
ProxyUsername string `json:"proxy_username"`
|
||||
ProxyPassword string `json:"proxy_password"`
|
||||
}
|
||||
|
||||
// ValidateTelegramApiKeyResponse 验证 Telegram API Key 响应
|
||||
type ValidateTelegramApiKeyResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
BotInfo map[string]interface{} `json:"bot_info,omitempty"`
|
||||
}
|
||||
50
db/entity/api_access_log.go
Normal file
50
db/entity/api_access_log.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIAccessLog API访问日志模型
|
||||
type APIAccessLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
IP string `json:"ip" gorm:"size:45;not null;comment:客户端IP地址"`
|
||||
UserAgent string `json:"user_agent" gorm:"size:500;comment:用户代理"`
|
||||
Endpoint string `json:"endpoint" gorm:"size:255;not null;comment:访问的接口路径"`
|
||||
Method string `json:"method" gorm:"size:10;not null;comment:HTTP方法"`
|
||||
RequestParams string `json:"request_params" gorm:"type:text;comment:查询参数(JSON格式)"`
|
||||
ResponseStatus int `json:"response_status" gorm:"default:200;comment:响应状态码"`
|
||||
ResponseData string `json:"response_data" gorm:"type:text;comment:响应数据(JSON格式)"`
|
||||
ProcessCount int `json:"process_count" gorm:"default:0;comment:处理数量(查询结果数或添加的数量)"`
|
||||
ErrorMessage string `json:"error_message" gorm:"size:500;comment:错误消息"`
|
||||
ProcessingTime int64 `json:"processing_time" gorm:"comment:处理时间(毫秒)"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (APIAccessLog) TableName() string {
|
||||
return "api_access_logs"
|
||||
}
|
||||
|
||||
// APIAccessLogSummary API访问日志汇总统计
|
||||
type APIAccessLogSummary struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TodayRequests int64 `json:"today_requests"`
|
||||
WeekRequests int64 `json:"week_requests"`
|
||||
MonthRequests int64 `json:"month_requests"`
|
||||
ErrorRequests int64 `json:"error_requests"`
|
||||
UniqueIPs int64 `json:"unique_ips"`
|
||||
}
|
||||
|
||||
// APIAccessLogStats 按端点统计
|
||||
type APIAccessLogStats struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
AvgProcessTime int64 `json:"avg_process_time"`
|
||||
LastAccess time.Time `json:"last_access"`
|
||||
}
|
||||
@@ -20,6 +20,7 @@ type Cks struct {
|
||||
VipStatus bool `json:"vip_status" gorm:"default:false;comment:VIP状态"`
|
||||
ServiceType string `json:"service_type" gorm:"size:20;comment:服务类型"`
|
||||
Remark string `json:"remark" gorm:"size:64;not null;comment:备注"`
|
||||
Extra string `json:"extra" gorm:"type:text;comment:额外的中间数据如token等"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
|
||||
@@ -27,6 +27,7 @@ type HotDrama struct {
|
||||
// 分类信息
|
||||
Category string `json:"category" gorm:"size:50"` // 分类(电影/电视剧)
|
||||
SubType string `json:"sub_type" gorm:"size:50"` // 子类型(华语/欧美/韩国/日本等)
|
||||
Rank int `json:"rank" gorm:"default:0"` // 排序(豆瓣返回顺序)
|
||||
|
||||
// 数据来源
|
||||
Source string `json:"source" gorm:"size:50;default:'douban'"` // 数据来源
|
||||
|
||||
@@ -41,3 +41,23 @@ type Resource struct {
|
||||
func (Resource) TableName() string {
|
||||
return "resources"
|
||||
}
|
||||
|
||||
// GetTitle 获取资源标题(实现utils.Resource接口)
|
||||
func (r *Resource) GetTitle() string {
|
||||
return r.Title
|
||||
}
|
||||
|
||||
// GetDescription 获取资源描述(实现utils.Resource接口)
|
||||
func (r *Resource) GetDescription() string {
|
||||
return r.Description
|
||||
}
|
||||
|
||||
// SetTitle 设置资源标题(实现utils.Resource接口)
|
||||
func (r *Resource) SetTitle(title string) {
|
||||
r.Title = title
|
||||
}
|
||||
|
||||
// SetDescription 设置资源描述(实现utils.Resource接口)
|
||||
func (r *Resource) SetDescription(description string) {
|
||||
r.Description = description
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ const (
|
||||
ConfigKeyMeilisearchPort = "meilisearch_port"
|
||||
ConfigKeyMeilisearchMasterKey = "meilisearch_master_key"
|
||||
ConfigKeyMeilisearchIndexName = "meilisearch_index_name"
|
||||
|
||||
// Telegram配置
|
||||
ConfigKeyTelegramBotEnabled = "telegram_bot_enabled"
|
||||
ConfigKeyTelegramBotApiKey = "telegram_bot_api_key"
|
||||
ConfigKeyTelegramAutoReplyEnabled = "telegram_auto_reply_enabled"
|
||||
ConfigKeyTelegramAutoReplyTemplate = "telegram_auto_reply_template"
|
||||
ConfigKeyTelegramAutoDeleteEnabled = "telegram_auto_delete_enabled"
|
||||
ConfigKeyTelegramAutoDeleteInterval = "telegram_auto_delete_interval"
|
||||
ConfigKeyTelegramProxyEnabled = "telegram_proxy_enabled"
|
||||
ConfigKeyTelegramProxyType = "telegram_proxy_type"
|
||||
ConfigKeyTelegramProxyHost = "telegram_proxy_host"
|
||||
ConfigKeyTelegramProxyPort = "telegram_proxy_port"
|
||||
ConfigKeyTelegramProxyUsername = "telegram_proxy_username"
|
||||
ConfigKeyTelegramProxyPassword = "telegram_proxy_password"
|
||||
|
||||
// 界面配置
|
||||
ConfigKeyEnableAnnouncements = "enable_announcements"
|
||||
ConfigKeyAnnouncements = "announcements"
|
||||
ConfigKeyEnableFloatButtons = "enable_float_buttons"
|
||||
ConfigKeyWechatSearchImage = "wechat_search_image"
|
||||
ConfigKeyTelegramQrImage = "telegram_qr_image"
|
||||
)
|
||||
|
||||
// ConfigType 配置类型常量
|
||||
@@ -98,6 +119,20 @@ const (
|
||||
ConfigResponseFieldMeilisearchPort = "meilisearch_port"
|
||||
ConfigResponseFieldMeilisearchMasterKey = "meilisearch_master_key"
|
||||
ConfigResponseFieldMeilisearchIndexName = "meilisearch_index_name"
|
||||
|
||||
// Telegram配置字段
|
||||
ConfigResponseFieldTelegramBotEnabled = "telegram_bot_enabled"
|
||||
ConfigResponseFieldTelegramBotApiKey = "telegram_bot_api_key"
|
||||
ConfigResponseFieldTelegramAutoReplyEnabled = "telegram_auto_reply_enabled"
|
||||
ConfigResponseFieldTelegramAutoReplyTemplate = "telegram_auto_reply_template"
|
||||
ConfigResponseFieldTelegramAutoDeleteEnabled = "telegram_auto_delete_enabled"
|
||||
ConfigResponseFieldTelegramAutoDeleteInterval = "telegram_auto_delete_interval"
|
||||
ConfigResponseFieldTelegramProxyEnabled = "telegram_proxy_enabled"
|
||||
ConfigResponseFieldTelegramProxyType = "telegram_proxy_type"
|
||||
ConfigResponseFieldTelegramProxyHost = "telegram_proxy_host"
|
||||
ConfigResponseFieldTelegramProxyPort = "telegram_proxy_port"
|
||||
ConfigResponseFieldTelegramProxyUsername = "telegram_proxy_username"
|
||||
ConfigResponseFieldTelegramProxyPassword = "telegram_proxy_password"
|
||||
)
|
||||
|
||||
// ConfigDefaultValue 配置默认值常量
|
||||
@@ -141,4 +176,25 @@ const (
|
||||
ConfigDefaultMeilisearchPort = "7700"
|
||||
ConfigDefaultMeilisearchMasterKey = ""
|
||||
ConfigDefaultMeilisearchIndexName = "resources"
|
||||
|
||||
// Telegram配置默认值
|
||||
ConfigDefaultTelegramBotEnabled = "false"
|
||||
ConfigDefaultTelegramBotApiKey = ""
|
||||
ConfigDefaultTelegramAutoReplyEnabled = "true"
|
||||
ConfigDefaultTelegramAutoReplyTemplate = "您好!我可以帮您搜索网盘资源,请输入您要搜索的内容。"
|
||||
ConfigDefaultTelegramAutoDeleteEnabled = "false"
|
||||
ConfigDefaultTelegramAutoDeleteInterval = "60"
|
||||
ConfigDefaultTelegramProxyEnabled = "false"
|
||||
ConfigDefaultTelegramProxyType = "http"
|
||||
ConfigDefaultTelegramProxyHost = ""
|
||||
ConfigDefaultTelegramProxyPort = "8080"
|
||||
ConfigDefaultTelegramProxyUsername = ""
|
||||
ConfigDefaultTelegramProxyPassword = ""
|
||||
|
||||
// 界面配置默认值
|
||||
ConfigDefaultEnableAnnouncements = "false"
|
||||
ConfigDefaultAnnouncements = ""
|
||||
ConfigDefaultEnableFloatButtons = "false"
|
||||
ConfigDefaultWechatSearchImage = ""
|
||||
ConfigDefaultTelegramQrImage = ""
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ type TaskType string
|
||||
|
||||
const (
|
||||
TaskTypeBatchTransfer TaskType = "batch_transfer" // 批量转存
|
||||
TaskTypeExpansion TaskType = "expansion" // 账号扩容
|
||||
)
|
||||
|
||||
// Task 任务表
|
||||
|
||||
48
db/entity/telegram_channel.go
Normal file
48
db/entity/telegram_channel.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramChannel Telegram 频道/群组实体
|
||||
type TelegramChannel struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Telegram 频道/群组信息
|
||||
ChatID int64 `json:"chat_id" gorm:"not null;comment:Telegram 聊天ID"`
|
||||
ChatName string `json:"chat_name" gorm:"size:255;not null;comment:聊天名称"`
|
||||
ChatType string `json:"chat_type" gorm:"size:50;not null;comment:类型:channel/group"`
|
||||
|
||||
// 推送配置
|
||||
PushEnabled bool `json:"push_enabled" gorm:"default:true;comment:是否启用推送"`
|
||||
PushFrequency int `json:"push_frequency" gorm:"default:5;comment:推送频率(分钟)"`
|
||||
PushStartTime string `json:"push_start_time" gorm:"size:10;comment:推送开始时间,格式HH:mm"`
|
||||
PushEndTime string `json:"push_end_time" gorm:"size:10;comment:推送结束时间,格式HH:mm"`
|
||||
ContentCategories string `json:"content_categories" gorm:"type:text;comment:推送的内容分类,用逗号分隔"`
|
||||
ContentTags string `json:"content_tags" gorm:"type:text;comment:推送的标签,用逗号分隔"`
|
||||
|
||||
// 频道状态
|
||||
IsActive bool `json:"is_active" gorm:"default:true;comment:是否活跃"`
|
||||
LastPushAt *time.Time `json:"last_push_at" gorm:"comment:最后推送时间"`
|
||||
|
||||
// 注册信息
|
||||
RegisteredBy string `json:"registered_by" gorm:"size:100;comment:注册者用户名"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
|
||||
// API配置
|
||||
API string `json:"api" gorm:"size:255;comment:API地址"`
|
||||
Token string `json:"token" gorm:"size:255;comment:访问令牌"`
|
||||
ApiType string `json:"api_type" gorm:"size:50;comment:API类型"`
|
||||
IsPushSavedInfo bool `json:"is_push_saved_info" gorm:"default:false;comment:是否只推送已转存资源"`
|
||||
|
||||
// 资源策略和时间限制配置
|
||||
ResourceStrategy string `json:"resource_strategy" gorm:"size:20;default:'random';comment:资源策略:latest-最新优先,transferred-已转存优先,random-纯随机"`
|
||||
TimeLimit string `json:"time_limit" gorm:"size:20;default:'none';comment:时间限制:none-无限制,week-一周内,month-一月内"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (TelegramChannel) TableName() string {
|
||||
return "telegram_channels"
|
||||
}
|
||||
169
db/repo/api_access_log_repository.go
Normal file
169
db/repo/api_access_log_repository.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// APIAccessLogRepository API访问日志Repository接口
|
||||
type APIAccessLogRepository interface {
|
||||
BaseRepository[entity.APIAccessLog]
|
||||
RecordAccess(ip, userAgent, endpoint, method string, requestParams interface{}, responseStatus int, responseData interface{}, processCount int, errorMessage string, processingTime int64) error
|
||||
GetSummary() (*entity.APIAccessLogSummary, error)
|
||||
GetStatsByEndpoint() ([]entity.APIAccessLogStats, error)
|
||||
FindWithFilters(page, limit int, startDate, endDate *time.Time, endpoint, ip string) ([]entity.APIAccessLog, int64, error)
|
||||
ClearOldLogs(days int) error
|
||||
}
|
||||
|
||||
// APIAccessLogRepositoryImpl API访问日志Repository实现
|
||||
type APIAccessLogRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.APIAccessLog]
|
||||
}
|
||||
|
||||
// NewAPIAccessLogRepository 创建API访问日志Repository
|
||||
func NewAPIAccessLogRepository(db *gorm.DB) APIAccessLogRepository {
|
||||
return &APIAccessLogRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.APIAccessLog]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// RecordAccess 记录API访问
|
||||
func (r *APIAccessLogRepositoryImpl) RecordAccess(ip, userAgent, endpoint, method string, requestParams interface{}, responseStatus int, responseData interface{}, processCount int, errorMessage string, processingTime int64) error {
|
||||
log := entity.APIAccessLog{
|
||||
IP: ip,
|
||||
UserAgent: userAgent,
|
||||
Endpoint: endpoint,
|
||||
Method: method,
|
||||
ResponseStatus: responseStatus,
|
||||
ProcessCount: processCount,
|
||||
ErrorMessage: errorMessage,
|
||||
ProcessingTime: processingTime,
|
||||
}
|
||||
|
||||
// 序列化请求参数
|
||||
if requestParams != nil {
|
||||
if paramsJSON, err := json.Marshal(requestParams); err == nil {
|
||||
log.RequestParams = string(paramsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// 序列化响应数据(限制大小,避免存储大量数据)
|
||||
if responseData != nil {
|
||||
if dataJSON, err := json.Marshal(responseData); err == nil {
|
||||
// 限制响应数据长度,避免存储过多数据
|
||||
dataStr := string(dataJSON)
|
||||
if len(dataStr) > 2000 {
|
||||
dataStr = dataStr[:2000] + "..."
|
||||
}
|
||||
log.ResponseData = dataStr
|
||||
}
|
||||
}
|
||||
|
||||
return r.db.Create(&log).Error
|
||||
}
|
||||
|
||||
// GetSummary 获取访问日志汇总
|
||||
func (r *APIAccessLogRepositoryImpl) GetSummary() (*entity.APIAccessLogSummary, error) {
|
||||
var summary entity.APIAccessLogSummary
|
||||
now := utils.GetCurrentTime()
|
||||
todayStr := now.Format(utils.TimeFormatDate)
|
||||
weekStart := now.AddDate(0, 0, -int(now.Weekday())+1).Format(utils.TimeFormatDate)
|
||||
monthStart := now.Format("2006-01") + "-01"
|
||||
|
||||
// 总请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Count(&summary.TotalRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 今日请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("DATE(created_at) = ?", todayStr).Count(&summary.TodayRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 本周请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("created_at >= ?", weekStart).Count(&summary.WeekRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 本月请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("created_at >= ?", monthStart).Count(&summary.MonthRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 错误请求数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Where("response_status >= 400").Count(&summary.ErrorRequests).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 唯一IP数
|
||||
if err := r.db.Model(&entity.APIAccessLog{}).Distinct("ip").Count(&summary.UniqueIPs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// GetStatsByEndpoint 按端点获取统计
|
||||
func (r *APIAccessLogRepositoryImpl) GetStatsByEndpoint() ([]entity.APIAccessLogStats, error) {
|
||||
var stats []entity.APIAccessLogStats
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
endpoint,
|
||||
method,
|
||||
COUNT(*) as request_count,
|
||||
SUM(CASE WHEN response_status >= 400 THEN 1 ELSE 0 END) as error_count,
|
||||
AVG(processing_time) as avg_process_time,
|
||||
MAX(created_at) as last_access
|
||||
FROM api_access_logs
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY endpoint, method
|
||||
ORDER BY request_count DESC
|
||||
`
|
||||
|
||||
err := r.db.Raw(query).Scan(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// FindWithFilters 带过滤条件的分页查找访问日志
|
||||
func (r *APIAccessLogRepositoryImpl) FindWithFilters(page, limit int, startDate, endDate *time.Time, endpoint, ip string) ([]entity.APIAccessLog, int64, error) {
|
||||
var logs []entity.APIAccessLog
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
query := r.db.Model(&entity.APIAccessLog{})
|
||||
|
||||
// 添加过滤条件
|
||||
if startDate != nil {
|
||||
query = query.Where("created_at >= ?", *startDate)
|
||||
}
|
||||
if endDate != nil {
|
||||
query = query.Where("created_at <= ?", *endDate)
|
||||
}
|
||||
if endpoint != "" {
|
||||
query = query.Where("endpoint LIKE ?", "%"+endpoint+"%")
|
||||
}
|
||||
if ip != "" {
|
||||
query = query.Where("ip = ?", ip)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据,按创建时间倒序排列
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
// ClearOldLogs 清理旧日志
|
||||
func (r *APIAccessLogRepositoryImpl) ClearOldLogs(days int) error {
|
||||
cutoffDate := utils.GetCurrentTime().AddDate(0, 0, -days)
|
||||
return r.db.Where("created_at < ?", cutoffDate).Delete(&entity.APIAccessLog{}).Error
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
type CksRepository interface {
|
||||
BaseRepository[entity.Cks]
|
||||
FindByPanID(panID uint) ([]entity.Cks, error)
|
||||
FindByIds(ids []uint) ([]*entity.Cks, error)
|
||||
FindByIsValid(isValid bool) ([]entity.Cks, error)
|
||||
UpdateSpace(id uint, space, leftSpace int64) error
|
||||
DeleteByPanID(panID uint) error
|
||||
@@ -73,6 +74,15 @@ func (r *CksRepositoryImpl) FindByID(id uint) (*entity.Cks, error) {
|
||||
return &cks, nil
|
||||
}
|
||||
|
||||
func (r *CksRepositoryImpl) FindByIds(ids []uint) ([]*entity.Cks, error) {
|
||||
var cks []*entity.Cks
|
||||
err := r.db.Preload("Pan").Where("id IN ?", ids).Find(&cks).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cks, nil
|
||||
}
|
||||
|
||||
// UpdateWithAllFields 更新Cks,包括零值字段
|
||||
func (r *CksRepositoryImpl) UpdateWithAllFields(cks *entity.Cks) error {
|
||||
return r.db.Save(cks).Error
|
||||
|
||||
@@ -12,6 +12,7 @@ type HotDramaRepository interface {
|
||||
FindByID(id uint) (*entity.HotDrama, error)
|
||||
FindAll(page, pageSize int) ([]entity.HotDrama, int64, error)
|
||||
FindByCategory(category string, page, pageSize int) ([]entity.HotDrama, int64, error)
|
||||
FindByCategoryAndSubType(category, subType string, page, pageSize int) ([]entity.HotDrama, int64, error)
|
||||
FindByDoubanID(doubanID string) (*entity.HotDrama, error)
|
||||
Upsert(drama *entity.HotDrama) error
|
||||
Delete(id uint) error
|
||||
@@ -59,7 +60,7 @@ func (r *hotDramaRepository) FindAll(page, pageSize int) ([]entity.HotDrama, int
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := r.db.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&dramas).Error
|
||||
err := r.db.Order("rank ASC").Offset(offset).Limit(pageSize).Find(&dramas).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -80,7 +81,28 @@ func (r *hotDramaRepository) FindByCategory(category string, page, pageSize int)
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := r.db.Where("category = ?", category).Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&dramas).Error
|
||||
err := r.db.Where("category = ?", category).Order("rank ASC").Offset(offset).Limit(pageSize).Find(&dramas).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return dramas, total, nil
|
||||
}
|
||||
|
||||
// FindByCategoryAndSubType 根据分类和子类型查找热播剧(分页)
|
||||
func (r *hotDramaRepository) FindByCategoryAndSubType(category, subType string, page, pageSize int) ([]entity.HotDrama, int64, error) {
|
||||
var dramas []entity.HotDrama
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// 获取总数
|
||||
if err := r.db.Model(&entity.HotDrama{}).Where("category = ? AND sub_type = ?", category, subType).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
err := r.db.Where("category = ? AND sub_type = ?", category, subType).Order("rank ASC").Offset(offset).Limit(pageSize).Find(&dramas).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
@@ -6,38 +6,42 @@ import (
|
||||
|
||||
// RepositoryManager Repository管理器
|
||||
type RepositoryManager struct {
|
||||
PanRepository PanRepository
|
||||
CksRepository CksRepository
|
||||
ResourceRepository ResourceRepository
|
||||
CategoryRepository CategoryRepository
|
||||
TagRepository TagRepository
|
||||
ReadyResourceRepository ReadyResourceRepository
|
||||
UserRepository UserRepository
|
||||
SearchStatRepository SearchStatRepository
|
||||
SystemConfigRepository SystemConfigRepository
|
||||
HotDramaRepository HotDramaRepository
|
||||
ResourceViewRepository ResourceViewRepository
|
||||
TaskRepository TaskRepository
|
||||
TaskItemRepository TaskItemRepository
|
||||
FileRepository FileRepository
|
||||
PanRepository PanRepository
|
||||
CksRepository CksRepository
|
||||
ResourceRepository ResourceRepository
|
||||
CategoryRepository CategoryRepository
|
||||
TagRepository TagRepository
|
||||
ReadyResourceRepository ReadyResourceRepository
|
||||
UserRepository UserRepository
|
||||
SearchStatRepository SearchStatRepository
|
||||
SystemConfigRepository SystemConfigRepository
|
||||
HotDramaRepository HotDramaRepository
|
||||
ResourceViewRepository ResourceViewRepository
|
||||
TaskRepository TaskRepository
|
||||
TaskItemRepository TaskItemRepository
|
||||
FileRepository FileRepository
|
||||
TelegramChannelRepository TelegramChannelRepository
|
||||
APIAccessLogRepository APIAccessLogRepository
|
||||
}
|
||||
|
||||
// NewRepositoryManager 创建Repository管理器
|
||||
func NewRepositoryManager(db *gorm.DB) *RepositoryManager {
|
||||
return &RepositoryManager{
|
||||
PanRepository: NewPanRepository(db),
|
||||
CksRepository: NewCksRepository(db),
|
||||
ResourceRepository: NewResourceRepository(db),
|
||||
CategoryRepository: NewCategoryRepository(db),
|
||||
TagRepository: NewTagRepository(db),
|
||||
ReadyResourceRepository: NewReadyResourceRepository(db),
|
||||
UserRepository: NewUserRepository(db),
|
||||
SearchStatRepository: NewSearchStatRepository(db),
|
||||
SystemConfigRepository: NewSystemConfigRepository(db),
|
||||
HotDramaRepository: NewHotDramaRepository(db),
|
||||
ResourceViewRepository: NewResourceViewRepository(db),
|
||||
TaskRepository: NewTaskRepository(db),
|
||||
TaskItemRepository: NewTaskItemRepository(db),
|
||||
FileRepository: NewFileRepository(db),
|
||||
PanRepository: NewPanRepository(db),
|
||||
CksRepository: NewCksRepository(db),
|
||||
ResourceRepository: NewResourceRepository(db),
|
||||
CategoryRepository: NewCategoryRepository(db),
|
||||
TagRepository: NewTagRepository(db),
|
||||
ReadyResourceRepository: NewReadyResourceRepository(db),
|
||||
UserRepository: NewUserRepository(db),
|
||||
SearchStatRepository: NewSearchStatRepository(db),
|
||||
SystemConfigRepository: NewSystemConfigRepository(db),
|
||||
HotDramaRepository: NewHotDramaRepository(db),
|
||||
ResourceViewRepository: NewResourceViewRepository(db),
|
||||
TaskRepository: NewTaskRepository(db),
|
||||
TaskItemRepository: NewTaskItemRepository(db),
|
||||
FileRepository: NewFileRepository(db),
|
||||
TelegramChannelRepository: NewTelegramChannelRepository(db),
|
||||
APIAccessLogRepository: NewAPIAccessLogRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
type PanRepository interface {
|
||||
BaseRepository[entity.Pan]
|
||||
FindWithCks() ([]entity.Pan, error)
|
||||
FindIdByServiceType(serviceType string) (int, error)
|
||||
}
|
||||
|
||||
// PanRepositoryImpl Pan的Repository实现
|
||||
@@ -30,3 +33,12 @@ func (r *PanRepositoryImpl) FindWithCks() ([]entity.Pan, error) {
|
||||
err := r.db.Preload("Cks").Find(&pans).Error
|
||||
return pans, err
|
||||
}
|
||||
|
||||
func (r *PanRepositoryImpl) FindIdByServiceType(serviceType string) (int, error) {
|
||||
var pan entity.Pan
|
||||
err := r.db.Where("name = ?", serviceType).Find(&pan).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("获取panId失败: %v", serviceType)
|
||||
}
|
||||
return int(pan.ID), nil
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func (r *ReadyResourceRepositoryImpl) BatchFindByURLs(urls []string) ([]entity.R
|
||||
// FindByKey 根据Key查找
|
||||
func (r *ReadyResourceRepositoryImpl) FindByKey(key string) ([]entity.ReadyResource, error) {
|
||||
var resources []entity.ReadyResource
|
||||
err := r.db.Where("key = ?", key).Find(&resources).Error
|
||||
err := r.db.Unscoped().Where("key = ?", key).Find(&resources).Error
|
||||
return resources, err
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type ResourceRepository interface {
|
||||
MarkAsSyncedToMeilisearch(ids []uint) error
|
||||
MarkAllAsUnsyncedToMeilisearch() error
|
||||
FindAllWithPagination(page, limit int) ([]entity.Resource, int64, error)
|
||||
GetRandomResourceWithFilters(categoryFilter, tagFilter string, isPushSavedInfo bool) (*entity.Resource, error)
|
||||
}
|
||||
|
||||
// ResourceRepositoryImpl Resource的Repository实现
|
||||
@@ -509,6 +510,7 @@ func (r *ResourceRepositoryImpl) FindUnsyncedToMeilisearch(page, limit int) ([]e
|
||||
Where("synced_to_meilisearch = ?", false).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags"). // 添加Tags预加载
|
||||
Order("updated_at DESC")
|
||||
|
||||
// 获取总数
|
||||
@@ -567,6 +569,7 @@ func (r *ResourceRepositoryImpl) FindSyncedToMeilisearch(page, limit int) ([]ent
|
||||
Where("synced_to_meilisearch = ?", true).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags").
|
||||
Order("updated_at DESC")
|
||||
|
||||
// 获取总数
|
||||
@@ -599,6 +602,7 @@ func (r *ResourceRepositoryImpl) FindAllWithPagination(page, limit int) ([]entit
|
||||
db := r.db.Model(&entity.Resource{}).
|
||||
Preload("Category").
|
||||
Preload("Pan").
|
||||
Preload("Tags").
|
||||
Order("updated_at DESC")
|
||||
|
||||
// 获取总数
|
||||
@@ -610,3 +614,47 @@ func (r *ResourceRepositoryImpl) FindAllWithPagination(page, limit int) ([]entit
|
||||
err := db.Offset(offset).Limit(limit).Find(&resources).Error
|
||||
return resources, total, err
|
||||
}
|
||||
|
||||
// GetRandomResourceWithFilters 使用 PostgreSQL RANDOM() 功能随机获取一个符合条件的资源
|
||||
func (r *ResourceRepositoryImpl) GetRandomResourceWithFilters(categoryFilter, tagFilter string, isPushSavedInfo bool) (*entity.Resource, error) {
|
||||
// 构建查询条件
|
||||
query := r.db.Model(&entity.Resource{}).Preload("Category").Preload("Pan").Preload("Tags")
|
||||
|
||||
// 基础条件:有效且公开的资源
|
||||
query = query.Where("is_valid = ? AND is_public = ?", true, true)
|
||||
|
||||
// 根据分类过滤
|
||||
if categoryFilter != "" {
|
||||
// 查找分类ID
|
||||
var categoryEntity entity.Category
|
||||
if err := r.db.Where("name ILIKE ?", "%"+categoryFilter+"%").First(&categoryEntity).Error; err == nil {
|
||||
query = query.Where("category_id = ?", categoryEntity.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据标签过滤
|
||||
if tagFilter != "" {
|
||||
// 查找标签ID
|
||||
var tagEntity entity.Tag
|
||||
if err := r.db.Where("name ILIKE ?", "%"+tagFilter+"%").First(&tagEntity).Error; err == nil {
|
||||
// 通过中间表查找包含该标签的资源
|
||||
query = query.Joins("JOIN resource_tags ON resources.id = resource_tags.resource_id").
|
||||
Where("resource_tags.tag_id = ?", tagEntity.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// // 根据是否只推送已转存资源过滤
|
||||
// if isPushSavedInfo {
|
||||
// query = query.Where("save_url IS NOT NULL AND save_url != '' AND TRIM(save_url) != ''")
|
||||
// }
|
||||
|
||||
// 使用 PostgreSQL 的 RANDOM() 进行随机排序,并限制为1个结果
|
||||
var resource entity.Resource
|
||||
err := query.Order("RANDOM()").Limit(1).First(&resource).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resource, nil
|
||||
}
|
||||
|
||||
@@ -133,6 +133,11 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
{Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyEnableAnnouncements, Value: entity.ConfigDefaultEnableAnnouncements, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyAnnouncements, Value: entity.ConfigDefaultAnnouncements, Type: entity.ConfigTypeJSON},
|
||||
{Key: entity.ConfigKeyEnableFloatButtons, Value: entity.ConfigDefaultEnableFloatButtons, Type: entity.ConfigTypeBool},
|
||||
{Key: entity.ConfigKeyWechatSearchImage, Value: entity.ConfigDefaultWechatSearchImage, Type: entity.ConfigTypeString},
|
||||
{Key: entity.ConfigKeyTelegramQrImage, Value: entity.ConfigDefaultTelegramQrImage, Type: entity.ConfigTypeString},
|
||||
}
|
||||
|
||||
err = r.UpsertConfigs(defaultConfigs)
|
||||
@@ -169,6 +174,11 @@ func (r *SystemConfigRepositoryImpl) GetOrCreateDefault() ([]entity.SystemConfig
|
||||
entity.ConfigKeyMeilisearchPort: {Key: entity.ConfigKeyMeilisearchPort, Value: entity.ConfigDefaultMeilisearchPort, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyMeilisearchMasterKey: {Key: entity.ConfigKeyMeilisearchMasterKey, Value: entity.ConfigDefaultMeilisearchMasterKey, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyMeilisearchIndexName: {Key: entity.ConfigKeyMeilisearchIndexName, Value: entity.ConfigDefaultMeilisearchIndexName, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyEnableAnnouncements: {Key: entity.ConfigKeyEnableAnnouncements, Value: entity.ConfigDefaultEnableAnnouncements, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyAnnouncements: {Key: entity.ConfigKeyAnnouncements, Value: entity.ConfigDefaultAnnouncements, Type: entity.ConfigTypeJSON},
|
||||
entity.ConfigKeyEnableFloatButtons: {Key: entity.ConfigKeyEnableFloatButtons, Value: entity.ConfigDefaultEnableFloatButtons, Type: entity.ConfigTypeBool},
|
||||
entity.ConfigKeyWechatSearchImage: {Key: entity.ConfigKeyWechatSearchImage, Value: entity.ConfigDefaultWechatSearchImage, Type: entity.ConfigTypeString},
|
||||
entity.ConfigKeyTelegramQrImage: {Key: entity.ConfigKeyTelegramQrImage, Value: entity.ConfigDefaultTelegramQrImage, Type: entity.ConfigTypeString},
|
||||
}
|
||||
|
||||
// 检查现有配置中是否有缺失的配置项
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -15,6 +17,8 @@ type TaskRepository interface {
|
||||
UpdateProgress(id uint, progress float64, progressData string) error
|
||||
UpdateStatusAndMessage(id uint, status, message string) error
|
||||
UpdateTaskStats(id uint, processed, success, failed int) error
|
||||
UpdateStartedAt(id uint) error
|
||||
UpdateCompletedAt(id uint) error
|
||||
}
|
||||
|
||||
// TaskRepositoryImpl 任务仓库实现
|
||||
@@ -58,7 +62,7 @@ func (r *TaskRepositoryImpl) GetList(page, pageSize int, taskType, status string
|
||||
|
||||
// 添加过滤条件
|
||||
if taskType != "" {
|
||||
query = query.Where("task_type = ?", taskType)
|
||||
query = query.Where("type = ?", taskType)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
@@ -134,3 +138,15 @@ func (r *TaskRepositoryImpl) UpdateTaskStats(id uint, processed, success, failed
|
||||
"failed_items": failed,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdateStartedAt 更新任务开始时间
|
||||
func (r *TaskRepositoryImpl) UpdateStartedAt(id uint) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("started_at", now).Error
|
||||
}
|
||||
|
||||
// UpdateCompletedAt 更新任务完成时间
|
||||
func (r *TaskRepositoryImpl) UpdateCompletedAt(id uint) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&entity.Task{}).Where("id = ?", id).Update("completed_at", now).Error
|
||||
}
|
||||
|
||||
156
db/repo/telegram_channel_repository.go
Normal file
156
db/repo/telegram_channel_repository.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TelegramChannelRepository interface {
|
||||
BaseRepository[entity.TelegramChannel]
|
||||
FindActiveChannels() ([]entity.TelegramChannel, error)
|
||||
FindByChatID(chatID int64) (*entity.TelegramChannel, error)
|
||||
FindByChatType(chatType string) ([]entity.TelegramChannel, error)
|
||||
UpdateLastPushAt(id uint, lastPushAt time.Time) error
|
||||
FindDueForPush() ([]entity.TelegramChannel, error)
|
||||
CleanupDuplicateChannels() error
|
||||
FindActiveChannelsByTypes(chatTypes []string) ([]entity.TelegramChannel, error)
|
||||
}
|
||||
|
||||
type TelegramChannelRepositoryImpl struct {
|
||||
BaseRepositoryImpl[entity.TelegramChannel]
|
||||
}
|
||||
|
||||
func NewTelegramChannelRepository(db *gorm.DB) TelegramChannelRepository {
|
||||
return &TelegramChannelRepositoryImpl{
|
||||
BaseRepositoryImpl: BaseRepositoryImpl[entity.TelegramChannel]{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
// 实现基类方法
|
||||
func (r *TelegramChannelRepositoryImpl) Create(entity *entity.TelegramChannel) error {
|
||||
return r.db.Create(entity).Error
|
||||
}
|
||||
|
||||
func (r *TelegramChannelRepositoryImpl) Update(entity *entity.TelegramChannel) error {
|
||||
return r.db.Save(entity).Error
|
||||
}
|
||||
|
||||
func (r *TelegramChannelRepositoryImpl) Delete(id uint) error {
|
||||
return r.db.Delete(&entity.TelegramChannel{}, id).Error
|
||||
}
|
||||
|
||||
func (r *TelegramChannelRepositoryImpl) FindByID(id uint) (*entity.TelegramChannel, error) {
|
||||
var channel entity.TelegramChannel
|
||||
err := r.db.First(&channel, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
func (r *TelegramChannelRepositoryImpl) FindAll() ([]entity.TelegramChannel, error) {
|
||||
var channels []entity.TelegramChannel
|
||||
err := r.db.Order("created_at desc").Find(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// FindActiveChannels 查找活跃的频道/群组
|
||||
func (r *TelegramChannelRepositoryImpl) FindActiveChannels() ([]entity.TelegramChannel, error) {
|
||||
var channels []entity.TelegramChannel
|
||||
err := r.db.Where("is_active = ? AND push_enabled = ?", true, true).Order("created_at desc").Find(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// FindByChatID 根据 ChatID 查找频道/群组
|
||||
func (r *TelegramChannelRepositoryImpl) FindByChatID(chatID int64) (*entity.TelegramChannel, error) {
|
||||
var channel entity.TelegramChannel
|
||||
err := r.db.Where("chat_id = ?", chatID).First(&channel).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
// FindByChatType 根据类型查找频道/群组
|
||||
func (r *TelegramChannelRepositoryImpl) FindByChatType(chatType string) ([]entity.TelegramChannel, error) {
|
||||
var channels []entity.TelegramChannel
|
||||
err := r.db.Where("chat_type = ?", chatType).Order("created_at desc").Find(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// FindActiveChannelsByTypes 根据多个类型查找活跃频道/群组
|
||||
func (r *TelegramChannelRepositoryImpl) FindActiveChannelsByTypes(chatTypes []string) ([]entity.TelegramChannel, error) {
|
||||
var channels []entity.TelegramChannel
|
||||
err := r.db.Where("chat_type IN (?) AND is_active = ?", chatTypes, true).Find(&channels).Error
|
||||
return channels, err
|
||||
}
|
||||
|
||||
// UpdateLastPushAt 更新最后推送时间
|
||||
func (r *TelegramChannelRepositoryImpl) UpdateLastPushAt(id uint, lastPushAt time.Time) error {
|
||||
return r.db.Model(&entity.TelegramChannel{}).Where("id = ?", id).Update("last_push_at", lastPushAt).Error
|
||||
}
|
||||
|
||||
// FindDueForPush 查找需要推送的频道/群组
|
||||
func (r *TelegramChannelRepositoryImpl) FindDueForPush() ([]entity.TelegramChannel, error) {
|
||||
var channels []entity.TelegramChannel
|
||||
// 查找活跃、启用推送的频道,且距离上次推送已超过推送频率小时的记录
|
||||
|
||||
// 先获取所有活跃且启用推送的频道
|
||||
err := r.db.Where("is_active = ? AND push_enabled = ?", true, true).Find(&channels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 在内存中过滤出需要推送的频道(更可靠的跨数据库方案)
|
||||
var dueChannels []entity.TelegramChannel
|
||||
now := time.Now()
|
||||
|
||||
// 用于去重的map,以chat_id为键
|
||||
seenChatIDs := make(map[int64]bool)
|
||||
|
||||
for _, channel := range channels {
|
||||
// 检查是否已经处理过这个chat_id(去重)
|
||||
if seenChatIDs[channel.ChatID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果从未推送过,或者距离上次推送已超过推送频率小时
|
||||
isDue := false
|
||||
if channel.LastPushAt == nil {
|
||||
isDue = true
|
||||
} else {
|
||||
// 计算下次推送时间:上次推送时间 + 推送频率分钟
|
||||
nextPushTime := channel.LastPushAt.Add(time.Duration(channel.PushFrequency) * time.Minute)
|
||||
if now.After(nextPushTime) {
|
||||
isDue = true
|
||||
}
|
||||
}
|
||||
|
||||
if isDue {
|
||||
dueChannels = append(dueChannels, channel)
|
||||
seenChatIDs[channel.ChatID] = true // 标记此chat_id已处理
|
||||
}
|
||||
}
|
||||
|
||||
return dueChannels, nil
|
||||
}
|
||||
|
||||
// CleanupDuplicateChannels 清理重复的频道记录,保留ID最小的记录
|
||||
func (r *TelegramChannelRepositoryImpl) CleanupDuplicateChannels() error {
|
||||
// 使用SQL查询找出重复的chat_id,并删除除了ID最小外的所有记录
|
||||
query := `
|
||||
DELETE t1 FROM telegram_channels t1
|
||||
INNER JOIN (
|
||||
SELECT chat_id, MIN(id) as min_id
|
||||
FROM telegram_channels
|
||||
GROUP BY chat_id
|
||||
HAVING COUNT(*) > 1
|
||||
) t2 ON t1.chat_id = t2.chat_id
|
||||
WHERE t1.id > t2.min_id
|
||||
`
|
||||
|
||||
return r.db.Exec(query).Error
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
<?php
|
||||
namespace netdisk\pan;
|
||||
|
||||
class QuarkPan extends BasePan
|
||||
{
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
$this->urlHeader = [
|
||||
'Accept: application/json, text/plain, */*',
|
||||
'Accept-Language: zh-CN,zh;q=0.9',
|
||||
'content-type: application/json;charset=UTF-8',
|
||||
'sec-ch-ua: "Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
||||
'sec-ch-ua-mobile: ?0',
|
||||
'sec-ch-ua-platform: "Windows"',
|
||||
'sec-fetch-dest: empty',
|
||||
'sec-fetch-mode: cors',
|
||||
'sec-fetch-site: same-site',
|
||||
'Referer: https://pan.quark.cn/',
|
||||
'Referrer-Policy: strict-origin-when-cross-origin',
|
||||
'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'cookie: ' . Config('qfshop.quark_cookie')
|
||||
];
|
||||
}
|
||||
|
||||
public function getFiles($pdir_fid=0)
|
||||
{
|
||||
// 原 getFiles 方法内容
|
||||
$urlData = [];
|
||||
$queryParams = [
|
||||
'pr' => 'ucpro',
|
||||
'fr' => 'pc',
|
||||
'uc_param_str' => '',
|
||||
'pdir_fid' => $pdir_fid,
|
||||
'_page' => 1,
|
||||
'_size' => 50,
|
||||
'_fetch_total' => 1,
|
||||
'_fetch_sub_dirs' => 0,
|
||||
'_sort' => 'file_type:asc,updated_at:desc',
|
||||
];
|
||||
|
||||
$res = curlHelper("https://drive-pc.quark.cn/1/clouddrive/file/sort", "GET", json_encode($urlData), $this->urlHeader,$queryParams)['body'];
|
||||
$res = json_decode($res, true);
|
||||
if($res['status'] !== 200){
|
||||
return jerr2($res['message']=='require login [guest]'?'夸克未登录,请检查cookie':$res['message']);
|
||||
}
|
||||
|
||||
return jok2('获取成功',$res['data']['list']);
|
||||
}
|
||||
|
||||
public function transfer($pwd_id)
|
||||
{
|
||||
if(empty($this->stoken)){
|
||||
//获取要转存夸克资源的stoken
|
||||
$res = $this->getStoken($pwd_id);
|
||||
if($res['status'] !== 200) return jerr2($res['message']);
|
||||
$infoData = $res['data'];
|
||||
|
||||
if($this->isType == 1){
|
||||
$urls['title'] = $infoData['title'];
|
||||
$urls['share_url'] = $this->url;
|
||||
$urls['stoken'] = $infoData['stoken'];
|
||||
return jok2('检验成功', $urls);
|
||||
}
|
||||
$stoken = $infoData['stoken'];
|
||||
$stoken = str_replace(' ', '+', $stoken);
|
||||
}else{
|
||||
$stoken = str_replace(' ', '+', $this->stoken);
|
||||
}
|
||||
|
||||
//获取要转存夸克资源的详细内容
|
||||
$res = $this->getShare($pwd_id,$stoken);
|
||||
if($res['status']!== 200) return jerr2($res['message']);
|
||||
$detail = $res['data'];
|
||||
|
||||
$fid_list = [];
|
||||
$fid_token_list = [];
|
||||
$title = $detail['share']['title']; //资源名称
|
||||
foreach ($detail['list'] as $key => $value) {
|
||||
$fid_list[] = $value['fid'];
|
||||
$fid_token_list[] = $value['share_fid_token'];
|
||||
}
|
||||
|
||||
//转存资源到指定文件夹
|
||||
$res = $this->getShareSave($pwd_id,$stoken,$fid_list,$fid_token_list);
|
||||
if($res['status']!== 200) return jerr2($res['message']);
|
||||
$task_id = $res['data']['task_id'];
|
||||
|
||||
//转存后根据task_id获取转存到自己网盘后的信息
|
||||
$retry_index = 0;
|
||||
$myData = '';
|
||||
while ($myData=='' || $myData['status'] != 2) {
|
||||
$res = $this->getShareTask($task_id, $retry_index);
|
||||
if($res['message']== 'capacity limit[{0}]'){
|
||||
return jerr2('容量不足');
|
||||
}
|
||||
if($res['status']!== 200) {
|
||||
return jerr2($res['message']);
|
||||
}
|
||||
$myData = $res['data'];
|
||||
$retry_index++;
|
||||
// 可以添加一个最大重试次数的限制,防止无限循环
|
||||
if ($retry_index > 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
//删除转存后可能有的广告
|
||||
$banned = Config('qfshop.quark_banned')??''; //如果出现这些字样就删除
|
||||
if(!empty($banned)){
|
||||
$bannedList = explode(',', $banned);
|
||||
$pdir_fid = $myData['save_as']['save_as_top_fids'][0];
|
||||
$dellist = [];
|
||||
$plist = $this->getPdirFid($pdir_fid);
|
||||
if(!empty($plist)){
|
||||
foreach ($plist as $key => $value) {
|
||||
// 检查$value['file_name']是否包含$bannedList中的任何一项
|
||||
$contains = false;
|
||||
foreach ($bannedList as $item) {
|
||||
if (strpos($value['file_name'], $item) !== false) {
|
||||
$contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($contains) {
|
||||
$dellist[] = $value['fid'];
|
||||
}
|
||||
}
|
||||
if(count($plist) === count($dellist)){
|
||||
//要删除的资源数如果和原数据资源数一样 就全部删除并终止下面的分享
|
||||
$this->deletepdirFid([$pdir_fid]);
|
||||
return jerr2("资源内容为空");
|
||||
}else{
|
||||
if (!empty($dellist)) {
|
||||
$this->deletepdirFid($dellist);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
}
|
||||
|
||||
$shareFid = $myData['save_as']['save_as_top_fids'];
|
||||
//分享资源并拿到更新后的task_id
|
||||
$res = $this->getShareBtn($myData['save_as']['save_as_top_fids'],$title);
|
||||
if($res['status']!== 200) return jerr2($res['message']);
|
||||
$task_id = $res['data']['task_id'];
|
||||
|
||||
//根据task_id拿到share_id
|
||||
$retry_index = 0;
|
||||
$myData = '';
|
||||
while ($myData=='' || $myData['status'] != 2) {
|
||||
$res = $this->getShareTask($task_id, $retry_index);
|
||||
if($res['status']!== 200) continue;
|
||||
$myData = $res['data'];
|
||||
$retry_index++;
|
||||
// 可以添加一个最大重试次数的限制,防止无限循环
|
||||
if ($retry_index > 50) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//根据share_id 获取到分享链接
|
||||
$res = $this->getSharePassword($myData['share_id']);
|
||||
if($res['status']!== 200) return jerr2($res['message']);
|
||||
$share = $res['data'];
|
||||
// $share['fid'] = $share['first_file']['fid'];
|
||||
$share['fid'] = (is_array($shareFid) && count($shareFid) > 1) ? $shareFid : $share['first_file']['fid'];
|
||||
|
||||
return jok2('转存成功', $share);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取要转存资源的stoken
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getStoken($pwd_id)
|
||||
{
|
||||
$urlData = array(
|
||||
'passcode' => '',
|
||||
'pwd_id' => $pwd_id,
|
||||
);
|
||||
$queryParams = [
|
||||
'pr' => 'ucpro',
|
||||
'fr' => 'pc',
|
||||
'uc_param_str' => '',
|
||||
];
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token",
|
||||
"POST",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取要转存资源的详细内容
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getShare($pwd_id,$stoken)
|
||||
{
|
||||
$urlData = array();
|
||||
$queryParams = [
|
||||
"pr" => "ucpro",
|
||||
"fr" => "pc",
|
||||
"uc_param_str" => "",
|
||||
"pwd_id" => $pwd_id,
|
||||
"stoken" => $stoken,
|
||||
"pdir_fid" => "0",
|
||||
"force" => "0",
|
||||
"_page" => "1",
|
||||
"_size" => "100",
|
||||
"_fetch_banner" => "1",
|
||||
"_fetch_share" => "1",
|
||||
"_fetch_total" => "1",
|
||||
"_sort" => "file_type:asc,updated_at:desc"
|
||||
];
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail",
|
||||
"GET",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 转存资源到指定文件夹
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getShareSave($pwd_id,$stoken,$fid_list,$fid_token_list)
|
||||
{
|
||||
if(!empty($this->to_pdir_fid)){
|
||||
$to_pdir_fid = $this->to_pdir_fid;
|
||||
}else{
|
||||
$to_pdir_fid = Config('qfshop.quark_file'); //默认存储路径
|
||||
if($this->expired_type == 2){
|
||||
$to_pdir_fid = Config('qfshop.quark_file_time'); //临时资源路径
|
||||
}
|
||||
}
|
||||
|
||||
$urlData = array(
|
||||
'fid_list' => $fid_list,
|
||||
'fid_token_list' => $fid_token_list,
|
||||
'to_pdir_fid' => $to_pdir_fid,
|
||||
'pwd_id' => $pwd_id,
|
||||
'stoken' => $stoken,
|
||||
'pdir_fid' => "0",
|
||||
'scene' => "link",
|
||||
);
|
||||
$queryParams = [
|
||||
"entry" => "update_share",
|
||||
"pr" => "ucpro",
|
||||
"fr" => "pc",
|
||||
"uc_param_str" => ""
|
||||
];
|
||||
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/sharepage/save",
|
||||
"POST",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分享资源拿到task_id
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getShareBtn($fid_list,$title)
|
||||
{
|
||||
if(!empty($this->ad_fid)){
|
||||
$fid_list[] = $this->ad_fid;
|
||||
}
|
||||
$urlData = array(
|
||||
'fid_list' => $fid_list,
|
||||
'expired_type' => $this->expired_type,
|
||||
'title' => $title,
|
||||
'url_type' => 1,
|
||||
);
|
||||
$queryParams = [
|
||||
"pr" => "ucpro",
|
||||
"fr" => "pc",
|
||||
"uc_param_str" => ""
|
||||
];
|
||||
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share",
|
||||
"POST",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据task_id拿到自己的资源信息
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getShareTask($task_id,$retry_index)
|
||||
{
|
||||
$urlData = array();
|
||||
$queryParams = [
|
||||
"pr" => "ucpro",
|
||||
"fr" => "pc",
|
||||
"uc_param_str" => "",
|
||||
"task_id" => $task_id,
|
||||
"retry_index" => $retry_index
|
||||
];
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/task",
|
||||
"GET",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据share_id 获取到分享链接
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getSharePassword($share_id)
|
||||
{
|
||||
$urlData = array(
|
||||
'share_id' => $share_id,
|
||||
);
|
||||
$queryParams = [
|
||||
"pr" => "ucpro",
|
||||
"fr" => "pc",
|
||||
"uc_param_str" => ""
|
||||
];
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/share/password",
|
||||
"POST",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除指定资源
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deletepdirFid($filelist)
|
||||
{
|
||||
$urlData = array(
|
||||
'action_type' => 2,
|
||||
'exclude_fids' => [],
|
||||
'filelist' => $filelist,
|
||||
);
|
||||
$queryParams = [
|
||||
"pr" => "ucpro",
|
||||
"fr" => "pc",
|
||||
"uc_param_str" => ""
|
||||
];
|
||||
return $this->executeApiRequest(
|
||||
"https://drive-pc.quark.cn/1/clouddrive/file/delete",
|
||||
"POST",
|
||||
$urlData,
|
||||
$queryParams
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取夸克网盘指定文件夹内容
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getPdirFid($pdir_fid)
|
||||
{
|
||||
$urlData = [];
|
||||
$queryParams = [
|
||||
'pr' => 'ucpro',
|
||||
'fr' => 'pc',
|
||||
'uc_param_str' => '',
|
||||
'pdir_fid' => $pdir_fid,
|
||||
'_page' => 1,
|
||||
'_size' => 200,
|
||||
'_fetch_total' => 1,
|
||||
'_fetch_sub_dirs' => 0,
|
||||
'_sort' => 'file_type:asc,updated_at:desc',
|
||||
];
|
||||
try {
|
||||
$res = curlHelper("https://drive-pc.quark.cn/1/clouddrive/file/sort", "GET", json_encode($urlData), $this->urlHeader,$queryParams)['body'];
|
||||
$res = json_decode($res, true);
|
||||
if($res['status'] !== 200){
|
||||
return [];
|
||||
}
|
||||
return $res['data']['list'];
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行API请求并处理重试逻辑
|
||||
*
|
||||
* @param string $url 请求URL
|
||||
* @param string $method 请求方法(GET/POST)
|
||||
* @param array $data 请求数据
|
||||
* @param array $queryParams 查询参数
|
||||
* @param int $maxRetries 最大重试次数
|
||||
* @param int $retryDelay 重试延迟(秒)
|
||||
* @return array 响应结果
|
||||
*/
|
||||
protected function executeApiRequest($url, $method, $data = [], $queryParams = [], $maxRetries = 3, $retryDelay = 2)
|
||||
{
|
||||
$attempt = 0;
|
||||
while ($attempt < $maxRetries) {
|
||||
$attempt++;
|
||||
try {
|
||||
$res = curlHelper($url, $method, json_encode($data), $this->urlHeader, $queryParams)['body'];
|
||||
return json_decode($res, true);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logApiError($url, $attempt, $e->getMessage());
|
||||
if ($attempt < $maxRetries) {
|
||||
sleep($retryDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['status' => 500, 'message' => '接口请求异常'];
|
||||
}
|
||||
/**
|
||||
* 记录API错误日志
|
||||
*
|
||||
* @param string $prefix 日志前缀
|
||||
* @param int $attempt 尝试次数
|
||||
* @param mixed $error 错误信息
|
||||
*/
|
||||
protected function logApiError($prefix, $attempt, $error)
|
||||
{
|
||||
$errorMsg = is_scalar($error) ? $error : json_encode($error);
|
||||
$logMessage = date('Y-m-d H:i:s') . ' ' . $prefix . '请求失败(尝试次数: ' . $attempt . ') 错误: ' . $errorMsg . "\n";
|
||||
file_put_contents('error.log', $logMessage, FILE_APPEND);
|
||||
}
|
||||
}
|
||||
596
demo/pan/XunleiPan.php
Normal file
596
demo/pan/XunleiPan.php
Normal file
@@ -0,0 +1,596 @@
|
||||
<?php
|
||||
|
||||
namespace netdisk\pan;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
class XunleiPan extends BasePan
|
||||
{
|
||||
private $clientId = 'Xqp0kJBXWhwaTpB6';
|
||||
private $deviceId = '925b7631473a13716b791d7f28289cad';
|
||||
|
||||
public function __construct($config = [])
|
||||
{
|
||||
parent::__construct($config);
|
||||
|
||||
$this->urlHeader = [
|
||||
'Accept: */*',
|
||||
'Accept-Encoding: gzip, deflate',
|
||||
'Accept-Language: zh-CN,zh;q=0.9',
|
||||
'Cache-Control: no-cache',
|
||||
'Content-Type: application/json',
|
||||
'Origin: https://pan.xunlei.com',
|
||||
'Pragma: no-cache',
|
||||
'Priority: u=1,i',
|
||||
'Referer: https://pan.xunlei.com/',
|
||||
'sec-ch-ua: "Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
|
||||
'sec-ch-ua-mobile: ?0',
|
||||
'sec-ch-ua-platform: "Windows"',
|
||||
'sec-fetch-dest: empty',
|
||||
'sec-fetch-mode: cors',
|
||||
'sec-fetch-site: same-site',
|
||||
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
|
||||
'Authorization: ',
|
||||
'x-captcha-token: ',
|
||||
'x-client-id: ' . $this->clientId,
|
||||
'x-device-id: ' . $this->deviceId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 核心方法:获取 Access Token(内部包含缓存判断、刷新、保存)
|
||||
*/
|
||||
private function getAccessToken()
|
||||
{
|
||||
$tokenFile = __DIR__ . '/xunlei_token.json';
|
||||
|
||||
// 1️⃣ 先读取缓存
|
||||
if (file_exists($tokenFile)) {
|
||||
$data = json_decode(file_get_contents($tokenFile), true);
|
||||
if (isset($data['access_token'], $data['expires_at']) && time() < $data['expires_at']) {
|
||||
return $data['access_token']; // 缓存有效
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ 构造请求体
|
||||
$body = [
|
||||
'client_id' => $this->clientId,
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => Config('qfshop.xunlei_cookie')
|
||||
];
|
||||
|
||||
// 3️⃣ 构造请求头(直接传入,不用处理 Authorization/x-captcha-token)
|
||||
$headers = array_filter($this->urlHeader, function ($h) {
|
||||
return strpos($h, 'Authorization') === false && strpos($h, 'x-captcha-token') === false;
|
||||
});
|
||||
|
||||
// 4️⃣ 调用封装请求方法
|
||||
$res = $this->requestXunleiApi(
|
||||
'https://xluser-ssl.xunlei.com/v1/auth/token',
|
||||
'POST',
|
||||
$body,
|
||||
[], // GET 参数为空
|
||||
$headers // headers 直接传入
|
||||
);
|
||||
|
||||
// 5️⃣ 判断返回
|
||||
if ($res['code'] !== 0 || !isset($res['data']['access_token'])) {
|
||||
return ''; // 获取失败
|
||||
}
|
||||
|
||||
$resData = $res['data'];
|
||||
|
||||
// 6️⃣ 计算过期时间(当前时间 + expires_in - 60 秒缓冲)
|
||||
$expiresAt = time() + intval($resData['expires_in']) - 60;
|
||||
|
||||
// 7️⃣ 缓存到文件
|
||||
file_put_contents($tokenFile, json_encode([
|
||||
'access_token' => $resData['access_token'],
|
||||
'refresh_token' => $resData['refresh_token'],
|
||||
'expires_at' => $expiresAt
|
||||
]));
|
||||
|
||||
// 8️⃣ 同步刷新 refresh_token 到数据库
|
||||
Db::name('conf')->where('conf_key', 'xunlei_cookie')->update([
|
||||
'conf_value' => $resData['refresh_token']
|
||||
]);
|
||||
|
||||
// 9️⃣ 返回 token
|
||||
return $resData['access_token'];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ✅ 获取 captcha_token
|
||||
*/
|
||||
private function getCaptchaToken()
|
||||
{
|
||||
$tokenFile = __DIR__ . '/xunlei_captcha.json';
|
||||
|
||||
// 1️⃣ 先读取缓存
|
||||
if (file_exists($tokenFile)) {
|
||||
$data = json_decode(file_get_contents($tokenFile), true);
|
||||
if (isset($data['captcha_token']) && isset($data['expires_at'])) {
|
||||
if (time() < $data['expires_at']) {
|
||||
return $data['captcha_token']; // 缓存有效
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2️⃣ 构造请求体
|
||||
$body = [
|
||||
'client_id' => $this->clientId,
|
||||
'action' => "get:/drive/v1/share",
|
||||
'device_id' => $this->deviceId,
|
||||
'meta' => [
|
||||
'username' => '',
|
||||
'phone_number' => '',
|
||||
'email' => '',
|
||||
'package_name' => 'pan.xunlei.com',
|
||||
'client_version' => '1.45.0',
|
||||
'captcha_sign' => '1.fe2108ad808a74c9ac0243309242726c',
|
||||
'timestamp' => '1645241033384',
|
||||
'user_id' => '0'
|
||||
]
|
||||
];
|
||||
|
||||
// 3️⃣ 构造请求头
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
];
|
||||
|
||||
// 4️⃣ 调用封装请求方法
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://xluser-ssl.xunlei.com/v1/shield/captcha/init",
|
||||
'POST',
|
||||
$body,
|
||||
[], // GET 参数为空
|
||||
$headers // headers 传入即用
|
||||
);
|
||||
|
||||
if ($res['code'] !== 0 || !isset($res['data']['captcha_token'])) {
|
||||
return ''; // 获取失败
|
||||
}
|
||||
|
||||
$data = $res['data'];
|
||||
|
||||
// 5️⃣ 计算过期时间(当前时间 + expires_in - 10 秒缓冲)
|
||||
$expiresAt = time() + intval($data['expires_in']) - 10;
|
||||
|
||||
// 6️⃣ 缓存到文件
|
||||
file_put_contents($tokenFile, json_encode([
|
||||
'captcha_token' => $data['captcha_token'],
|
||||
'expires_at' => $expiresAt
|
||||
]));
|
||||
|
||||
return $data['captcha_token'];
|
||||
}
|
||||
|
||||
|
||||
public function getFiles($pdir_fid = '')
|
||||
{
|
||||
// 1️⃣ 获取 AccessToken
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (empty($accessToken)) {
|
||||
return jerr2('登录状态异常,获取accessToken失败');
|
||||
}
|
||||
|
||||
// 2️⃣ 获取 CaptchaToken
|
||||
$captchaToken = $this->getCaptchaToken();
|
||||
if (empty($captchaToken)) {
|
||||
return jerr2('获取 captchaToken 失败');
|
||||
}
|
||||
|
||||
// 3️⃣ 构造 headers
|
||||
$headers = array_map(function ($h) use ($accessToken, $captchaToken) {
|
||||
if (str_starts_with($h, 'Authorization: ')) {
|
||||
return 'Authorization: Bearer ' . $accessToken;
|
||||
}
|
||||
if (str_starts_with($h, 'x-captcha-token: ')) {
|
||||
return 'x-captcha-token: ' . $captchaToken;
|
||||
}
|
||||
return $h;
|
||||
}, $this->urlHeader);
|
||||
|
||||
// 4️⃣ 构造请求体和 GET 参数
|
||||
$filters = [
|
||||
"phase" => ["eq" => "PHASE_TYPE_COMPLETE"],
|
||||
"trashed" => ["eq" => false],
|
||||
];
|
||||
|
||||
$filtersStr = urlencode(json_encode($filters));
|
||||
$urlData = [];
|
||||
$queryParams = [
|
||||
'parent_id' => $pdir_fid ?: '',
|
||||
'filters' => '{"phase":{"eq":"PHASE_TYPE_COMPLETE"},"trashed":{"eq":false}}',
|
||||
'with_audit' => true,
|
||||
'thumbnail_size' => 'SIZE_SMALL',
|
||||
'limit' => 50,
|
||||
];
|
||||
|
||||
// 5️⃣ 调用封装方法请求
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://api-pan.xunlei.com/drive/v1/files",
|
||||
'GET',
|
||||
$urlData,
|
||||
$queryParams,
|
||||
$headers
|
||||
);
|
||||
|
||||
// 6️⃣ 检查结果
|
||||
if ($res['code'] !== 0 || !isset($res['data']['files'])) {
|
||||
return jerr2($res['msg'] ?? '获取文件列表失败');
|
||||
}
|
||||
return jok2('获取成功', $res['data']['files']);
|
||||
}
|
||||
|
||||
|
||||
public function transfer($pwd_id)
|
||||
{
|
||||
// 1️⃣ 获取 AccessToken
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (empty($accessToken)) {
|
||||
return jerr2('登录状态异常');
|
||||
}
|
||||
|
||||
// 2️⃣ 获取 CaptchaToken
|
||||
$captchaToken = $this->getCaptchaToken();
|
||||
if (empty($captchaToken)) {
|
||||
return jerr2('登录异常');
|
||||
}
|
||||
|
||||
// 3️⃣ 构造 headers
|
||||
$this->urlHeader = array_map(function ($h) use ($accessToken, $captchaToken) {
|
||||
if (str_starts_with($h, 'Authorization: ')) {
|
||||
return 'Authorization: Bearer ' . $accessToken;
|
||||
}
|
||||
if (str_starts_with($h, 'x-captcha-token: ')) {
|
||||
return 'x-captcha-token: ' . $captchaToken;
|
||||
}
|
||||
return $h;
|
||||
}, $this->urlHeader);
|
||||
|
||||
$pwd_id = strtok($pwd_id, '?');
|
||||
$this->code = str_replace('#', '', $this->code);
|
||||
|
||||
$res = $this->getShare($pwd_id, $this->code);
|
||||
if ($res['code'] !== 200) return jerr2($res['message']);
|
||||
$infoData = $res['data'];
|
||||
|
||||
if ($this->isType == 1) {
|
||||
$urls['title'] = $infoData['title'];
|
||||
$urls['share_url'] = $this->url;
|
||||
$urls['stoken'] = '';
|
||||
return jok2('检验成功', $urls);
|
||||
}
|
||||
|
||||
//转存到网盘
|
||||
$res = $this->getRestore($pwd_id, $infoData);
|
||||
if ($res['code'] !== 200) return jerr2($res['message']);
|
||||
|
||||
|
||||
//获取转存后的文件信息
|
||||
$tasData = $res['data'];
|
||||
$retry_index = 0;
|
||||
$myData = '';
|
||||
while ($myData == '' || $myData['progress'] != 100) {
|
||||
$res = $this->getTasks($tasData);
|
||||
if ($res['code'] !== 200) return jerr2($res['message']);
|
||||
$myData = $res['data'];
|
||||
$retry_index++;
|
||||
// 可以添加一个最大重试次数的限制,防止无限循环
|
||||
if ($retry_index > 20) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($myData['progress'] != 100) {
|
||||
return jerr2($myData['message'] ?? '转存失败');
|
||||
}
|
||||
|
||||
$result = [];
|
||||
if (isset($myData['params']['trace_file_ids']) && !empty($myData['params']['trace_file_ids'])) {
|
||||
$traceData = json_decode($myData['params']['trace_file_ids'], true);
|
||||
if (is_array($traceData)) {
|
||||
$result = array_values($traceData);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
//删除转存后可能有的广告
|
||||
$banned = Config('qfshop.quark_banned') ?? ''; //如果出现这些字样就删除
|
||||
if (!empty($banned)) {
|
||||
$bannedList = explode(',', $banned);
|
||||
$pdir_fid = $result[0];
|
||||
$dellist = [];
|
||||
$plists = $this->getFiles($pdir_fid);
|
||||
$plist = $plists['data'];
|
||||
if (!empty($plist)) {
|
||||
foreach ($plist as $key => $value) {
|
||||
// 检查$value['name']是否包含$bannedList中的任何一项
|
||||
$contains = false;
|
||||
foreach ($bannedList as $item) {
|
||||
if (strpos($value['name'], $item) !== false) {
|
||||
$contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($contains) {
|
||||
$dellist[] = $value['id'];
|
||||
}
|
||||
}
|
||||
if (count($plist) === count($dellist)) {
|
||||
//要删除的资源数如果和原数据资源数一样 就全部删除并终止下面的分享
|
||||
$this->deletepdirFid([$pdir_fid]);
|
||||
return jerr2("资源内容为空");
|
||||
} else {
|
||||
if (!empty($dellist)) {
|
||||
$this->deletepdirFid($dellist);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
//根据share_id 获取到分享链接
|
||||
$res = $this->getSharePassword($result);
|
||||
if ($res['code'] !== 200) return jerr2($res['message']);
|
||||
|
||||
|
||||
$title = $infoData['files'][0]['name'] ?? '';
|
||||
$share = [
|
||||
'title' => $title,
|
||||
'share_url' => $res['data']['share_url'] . '?pwd=' . $res['data']['pass_code'],
|
||||
'code' => $res['data']['pass_code'],
|
||||
'fid' => $result,
|
||||
];
|
||||
|
||||
return jok2('转存成功', $share);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源分享信息
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getShare($pwd_id, $pass_code)
|
||||
{
|
||||
$urlData = [];
|
||||
$queryParams = [
|
||||
'share_id' => $pwd_id,
|
||||
'pass_code' => $pass_code,
|
||||
'limit' => 100,
|
||||
'pass_code_token' => '',
|
||||
'page_token' => '',
|
||||
'thumbnail_size' => 'SIZE_SMALL',
|
||||
];
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://api-pan.xunlei.com/drive/v1/share",
|
||||
'GET',
|
||||
$urlData,
|
||||
$queryParams,
|
||||
$this->urlHeader
|
||||
);
|
||||
if (!empty($res['data']['error_code'])) {
|
||||
return jerr2($res['data']['error_description'] ?? 'getShare失败');
|
||||
}
|
||||
if (isset($res['data']['share_status']) && $res['data']['share_status'] !== 'OK') {
|
||||
if (!empty($res['data']['share_status_text'])) {
|
||||
return jerr2($res['data']['share_status_text']);
|
||||
}
|
||||
|
||||
if ($res['data']['share_status'] === 'SENSITIVE_RESOURCE') {
|
||||
return jerr2('该分享内容可能因为涉及侵权、色情、反动、低俗等信息,无法访问!');
|
||||
}
|
||||
|
||||
return jerr2('资源已失效');
|
||||
}
|
||||
|
||||
return jok2('ok', $res['data']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 转存到网盘
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getRestore($pwd_id, $infoData)
|
||||
{
|
||||
$parent_id = Config('qfshop.xunlei_file'); //默认存储路径
|
||||
if ($this->expired_type == 2) {
|
||||
$parent_id = Config('qfshop.xunlei_file_time'); //临时资源路径
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
if (isset($infoData['files']) && is_array($infoData['files']) && !empty($infoData['files'])) {
|
||||
$ids = array_column($infoData['files'], 'id');
|
||||
}
|
||||
|
||||
$urlData = [
|
||||
'parent_id' => $parent_id,
|
||||
'share_id' => $pwd_id,
|
||||
"pass_code_token" => $infoData['pass_code_token'],
|
||||
'ancestor_ids' => [],
|
||||
'specify_parent_id' => true,
|
||||
'file_ids' => $ids,
|
||||
];
|
||||
$queryParams = [];
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://api-pan.xunlei.com/drive/v1/share/restore",
|
||||
'POST',
|
||||
$urlData,
|
||||
$queryParams,
|
||||
$this->urlHeader
|
||||
);
|
||||
if (!empty($res['data']['error_code'])) {
|
||||
return jerr2($res['data']['error_description'] ?? 'getRestore失败');
|
||||
}
|
||||
return jok2('ok', $res['data']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取转存后的文件信息
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getTasks($infoData)
|
||||
{
|
||||
$urlData = [];
|
||||
$queryParams = [];
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://api-pan.xunlei.com/drive/v1/tasks/" . $infoData['restore_task_id'],
|
||||
'GET',
|
||||
$urlData,
|
||||
$queryParams,
|
||||
$this->urlHeader
|
||||
);
|
||||
if (!empty($res['data']['error_code'])) {
|
||||
return jerr2($res['data']['error_description'] ?? 'getTasks失败');
|
||||
}
|
||||
return jok2('ok', $res['data']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取分享链接
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function getSharePassword($result)
|
||||
{
|
||||
// $result[] = '';
|
||||
$expiration_days = '-1';
|
||||
if ($this->expired_type == 2) {
|
||||
$expiration_days = '2';
|
||||
}
|
||||
$urlData = [
|
||||
'file_ids' => $result,
|
||||
'share_to' => 'copy',
|
||||
'params' => [
|
||||
'subscribe_push' => 'false',
|
||||
'WithPassCodeInLink' => 'true'
|
||||
],
|
||||
'title' => '云盘资源分享',
|
||||
'restore_limit' => '-1',
|
||||
'expiration_days' => $expiration_days
|
||||
];
|
||||
|
||||
$queryParams = [];
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://api-pan.xunlei.com/drive/v1/share",
|
||||
'POST',
|
||||
$urlData,
|
||||
$queryParams,
|
||||
$this->urlHeader
|
||||
);
|
||||
if (!empty($res['data']['error_code'])) {
|
||||
return jerr2($res['data']['error_description'] ?? 'getSharePassword失败');
|
||||
}
|
||||
return jok2('ok', $res['data']);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除指定资源
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deletepdirFid($filelist)
|
||||
{
|
||||
// 1️⃣ 获取 AccessToken
|
||||
$accessToken = $this->getAccessToken();
|
||||
if (empty($accessToken)) {
|
||||
return jerr2('登录状态异常,获取accessToken失败');
|
||||
}
|
||||
|
||||
// 2️⃣ 获取 CaptchaToken
|
||||
$captchaToken = $this->getCaptchaToken();
|
||||
if (empty($captchaToken)) {
|
||||
return jerr2('获取 captchaToken 失败');
|
||||
}
|
||||
|
||||
// 3️⃣ 构造 headers
|
||||
$this->urlHeader = array_map(function ($h) use ($accessToken, $captchaToken) {
|
||||
if (str_starts_with($h, 'Authorization: ')) {
|
||||
return 'Authorization: Bearer ' . $accessToken;
|
||||
}
|
||||
if (str_starts_with($h, 'x-captcha-token: ')) {
|
||||
return 'x-captcha-token: ' . $captchaToken;
|
||||
}
|
||||
return $h;
|
||||
}, $this->urlHeader);
|
||||
|
||||
$urlData = [
|
||||
'ids' => $filelist,
|
||||
'space' => ''
|
||||
];
|
||||
|
||||
$queryParams = [];
|
||||
$res = $this->requestXunleiApi(
|
||||
"https://api-pan.xunlei.com/drive/v1/files:batchDelete",
|
||||
'POST',
|
||||
$urlData,
|
||||
$queryParams,
|
||||
$this->urlHeader
|
||||
);
|
||||
|
||||
return ['status' => 200];
|
||||
}
|
||||
|
||||
/**
|
||||
* Xunlei API 通用请求方法
|
||||
*
|
||||
* @param string $url 接口地址
|
||||
* @param string $method GET 或 POST
|
||||
* @param array $data POST 数据
|
||||
* @param array $query GET 查询参数
|
||||
* @param array $headers 请求头,传啥用啥
|
||||
* @return array 返回解析后的 JSON 或错误信息
|
||||
*/
|
||||
private function requestXunleiApi(
|
||||
string $url,
|
||||
string $method = 'GET',
|
||||
array $data = [],
|
||||
array $query = [],
|
||||
array $headers = []
|
||||
): array {
|
||||
// 拼接 GET 参数
|
||||
if (!empty($query)) {
|
||||
$url .= '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
|
||||
curl_setopt($ch, CURLOPT_ENCODING, "gzip, deflate"); // 明确只使用gzip和deflate编码
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 不验证证书
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 不验证域名
|
||||
|
||||
if (strtoupper($method) === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
} elseif (strtoupper($method) === 'PATCH') {
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
}
|
||||
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) return ['code' => 1, 'msg' => "请求失败: $error"];
|
||||
|
||||
$json = json_decode($body, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return ['code' => 1, 'msg' => '返回 JSON 解析失败', 'raw' => $body];
|
||||
}
|
||||
|
||||
return ['code' => 0, 'data' => $json];
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
- app-network
|
||||
|
||||
backend:
|
||||
image: ctwj/urldb-backend:1.2.3
|
||||
image: ctwj/urldb-backend:1.3.0
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
- app-network
|
||||
|
||||
frontend:
|
||||
image: ctwj/urldb-frontend:1.2.3
|
||||
image: ctwj/urldb-frontend:1.3.0
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NUXT_PUBLIC_API_SERVER: http://backend:8080/api
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
doc.l9.lc
|
||||
@@ -1,51 +0,0 @@
|
||||
# 🚀 urlDB - 老九网盘资源数据库
|
||||
|
||||
> 一个现代化的老九网盘资源数据库,支持多网盘自动化转存分享,支持百度网盘,阿里云盘,夸克网盘, 天翼云盘,迅雷云盘,123云盘,115网盘,UC网盘
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## 🎯 支持的网盘平台
|
||||
|
||||
| 平台 | 录入 | 转存 | 分享 |
|
||||
|------|-------|-----|------|
|
||||
| 百度网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 阿里云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 夸克网盘 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
|
||||
| 天翼云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 迅雷云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| UC网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 123云盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
| 115网盘 | ✅ 支持 | 🚧 开发中 | 🚧 开发中 |
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 🎯 核心功能
|
||||
- **📁 多平台网盘支持** - 支持多种主流网盘平台
|
||||
- **🔍 公开API** - 支持API数据录入,资源搜索
|
||||
- **🏷️ 自动预处理** - 系统自动处理资源,对数据进行有效性判断
|
||||
- **📊 自动转存分享** - 有效资源,如果属于支持类型将自动转存分享
|
||||
- **📱 多账号管理** - 同平台支持多账号管理
|
||||
|
||||
## 📞 联系我们
|
||||
|
||||
- **项目地址**: [https://github.com/ctwj/urldb](https://github.com/ctwj/urldb)
|
||||
- **问题反馈**: [Issues](https://github.com/ctwj/urldb/issues)
|
||||
- **邮箱**: 510199617@qq.com
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果这个项目对您有帮助,请给我们一个 ⭐ Star!**
|
||||
|
||||
Made with ❤️ by [老九]
|
||||
|
||||
</div>
|
||||
@@ -1,177 +0,0 @@
|
||||
# 文档使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本项目使用 [docsify](https://docsify.js.org/) 生成文档网站。docsify 是一个轻量级的文档生成器,无需构建静态文件,只需要一个 `index.html` 文件即可。
|
||||
|
||||
## 文档结构
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.html # 文档主页
|
||||
├── docsify.config.js # docsify 配置文件
|
||||
├── README.md # 首页内容
|
||||
├── _sidebar.md # 侧边栏导航
|
||||
├── start-docs.sh # 启动脚本
|
||||
├── guide/ # 使用指南
|
||||
│ ├── quick-start.md # 快速开始
|
||||
│ ├── local-development.md # 本地开发
|
||||
│ └── docker-deployment.md # Docker 部署
|
||||
├── api/ # API 文档
|
||||
│ └── overview.md # API 概览
|
||||
├── architecture/ # 架构文档
|
||||
│ └── overview.md # 架构概览
|
||||
├── faq.md # 常见问题
|
||||
├── changelog.md # 更新日志
|
||||
└── license.md # 许可证
|
||||
```
|
||||
|
||||
## 快速启动
|
||||
|
||||
### 方法一:使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 进入文档目录
|
||||
cd docs
|
||||
|
||||
# 运行启动脚本
|
||||
./start-docs.sh
|
||||
```
|
||||
|
||||
脚本会自动:
|
||||
- 检查是否安装了 docsify-cli
|
||||
- 如果没有安装,会自动安装
|
||||
- 启动文档服务
|
||||
- 在浏览器中打开文档
|
||||
|
||||
### 方法二:手动启动
|
||||
|
||||
```bash
|
||||
# 安装 docsify-cli(如果未安装)
|
||||
npm install -g docsify-cli
|
||||
|
||||
# 进入文档目录
|
||||
cd docs
|
||||
|
||||
# 启动服务
|
||||
docsify serve . --port 3000 --open
|
||||
```
|
||||
|
||||
## 访问文档
|
||||
|
||||
启动成功后,文档将在以下地址可用:
|
||||
- 本地访问:http://localhost:3000
|
||||
- 局域网访问:http://[你的IP]:3000
|
||||
|
||||
## 文档特性
|
||||
|
||||
### 1. 搜索功能
|
||||
- 支持全文搜索
|
||||
- 搜索结果高亮显示
|
||||
- 支持中文搜索
|
||||
|
||||
### 2. 代码高亮
|
||||
支持多种编程语言的语法高亮:
|
||||
- Go
|
||||
- JavaScript/TypeScript
|
||||
- SQL
|
||||
- YAML
|
||||
- JSON
|
||||
- Bash
|
||||
|
||||
### 3. 代码复制
|
||||
- 一键复制代码块
|
||||
- 复制成功提示
|
||||
|
||||
### 4. 页面导航
|
||||
- 侧边栏导航
|
||||
- 页面间导航
|
||||
- 自动回到顶部
|
||||
|
||||
### 5. 响应式设计
|
||||
- 支持移动端访问
|
||||
- 自适应屏幕尺寸
|
||||
|
||||
## 自定义配置
|
||||
|
||||
### 修改主题
|
||||
在 `docsify.config.js` 中修改配置:
|
||||
|
||||
```javascript
|
||||
window.$docsify = {
|
||||
name: '你的项目名称',
|
||||
repo: '你的仓库地址',
|
||||
// 其他配置...
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新页面
|
||||
1. 在相应目录下创建 `.md` 文件
|
||||
2. 在 `_sidebar.md` 中添加导航链接
|
||||
3. 刷新页面即可看到新页面
|
||||
|
||||
### 修改样式
|
||||
可以通过添加自定义 CSS 来修改样式:
|
||||
|
||||
```html
|
||||
<!-- 在 index.html 中添加 -->
|
||||
<link rel="stylesheet" href="./custom.css">
|
||||
```
|
||||
|
||||
## 部署到生产环境
|
||||
|
||||
### 静态部署
|
||||
docsify 生成的文档可以部署到任何静态文件服务器:
|
||||
|
||||
```bash
|
||||
# 构建静态文件(可选)
|
||||
docsify generate docs docs/_site
|
||||
|
||||
# 部署到 GitHub Pages
|
||||
git subtree push --prefix docs origin gh-pages
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
```bash
|
||||
# 使用 nginx 镜像
|
||||
docker run -d -p 80:80 -v $(pwd)/docs:/usr/share/nginx/html nginx
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 启动时提示端口被占用
|
||||
A: 可以指定其他端口:
|
||||
```bash
|
||||
docsify serve . --port 3001
|
||||
```
|
||||
|
||||
### Q: 搜索功能不工作
|
||||
A: 确保在 `index.html` 中引入了搜索插件:
|
||||
```html
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
|
||||
```
|
||||
|
||||
### Q: 代码高亮不显示
|
||||
A: 确保引入了相应的 Prism.js 组件:
|
||||
```html
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
|
||||
```
|
||||
|
||||
## 维护说明
|
||||
|
||||
### 更新文档
|
||||
1. 修改相应的 `.md` 文件
|
||||
2. 刷新浏览器即可看到更新
|
||||
|
||||
### 添加新功能
|
||||
1. 在 `docsify.config.js` 中添加插件配置
|
||||
2. 在 `index.html` 中引入相应的插件文件
|
||||
|
||||
### 版本控制
|
||||
建议将文档与代码一起进行版本控制,确保文档与代码版本同步。
|
||||
|
||||
## 相关链接
|
||||
|
||||
- [docsify 官方文档](https://docsify.js.org/)
|
||||
- [docsify 插件市场](https://docsify.js.org/#/plugins)
|
||||
- [Markdown 语法指南](https://docsify.js.org/#/zh-cn/markdown)
|
||||
@@ -1,15 +0,0 @@
|
||||
<!-- docs/_sidebar.md -->
|
||||
|
||||
* [🏠 首页](/)
|
||||
* [🚀 快速开始](guide/quick-start.md)
|
||||
* [🐳 Docker部署](guide/docker-deployment.md)
|
||||
* [💻 本地开发](guide/local-development.md)
|
||||
|
||||
* 📚 API 文档
|
||||
* [公开API](api/overview.md)
|
||||
|
||||
* 📄 其他
|
||||
* [常见问题](faq.md)
|
||||
* [更新日志](changelog.md)
|
||||
* [许可证](license.md)
|
||||
* [版本管理](github-version-management.md)
|
||||
@@ -1,418 +0,0 @@
|
||||
# API 文档概览
|
||||
|
||||
## 概述
|
||||
|
||||
老九网盘资源数据库提供了一套完整的 RESTful API 接口,支持资源管理、搜索、热门剧获取等功能。所有 API 都需要进行认证,使用 API Token 进行身份验证。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **基础URL**: `http://localhost:8080/api`
|
||||
- **认证方式**: API Token
|
||||
- **数据格式**: JSON
|
||||
- **字符编码**: UTF-8
|
||||
|
||||
## 认证说明
|
||||
|
||||
### 认证方式
|
||||
|
||||
所有 API 都需要提供 API Token 进行认证,支持两种方式:
|
||||
|
||||
1. **请求头方式**(推荐)
|
||||
```
|
||||
X-API-Token: your_token_here
|
||||
```
|
||||
|
||||
2. **查询参数方式**
|
||||
```
|
||||
?api_token=your_token_here
|
||||
```
|
||||
|
||||
### 获取 Token
|
||||
|
||||
请联系管理员在系统配置中设置 API Token。
|
||||
|
||||
## API 接口列表
|
||||
|
||||
### 1. 单个添加资源
|
||||
|
||||
**接口描述**: 添加单个资源到待处理列表
|
||||
|
||||
**请求信息**:
|
||||
- **方法**: `POST`
|
||||
- **路径**: `/api/public/resources/add`
|
||||
- **认证**: 必需
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"title": "资源标题",
|
||||
"description": "资源描述",
|
||||
"url": "资源链接",
|
||||
"category": "分类名称",
|
||||
"tags": "标签1,标签2",
|
||||
"img": "封面图片链接",
|
||||
"source": "数据来源",
|
||||
"extra": "额外信息"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "资源添加成功,已进入待处理列表",
|
||||
"data": {
|
||||
"id": 123
|
||||
},
|
||||
"code": 200
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 批量添加资源
|
||||
|
||||
**接口描述**: 批量添加多个资源到待处理列表
|
||||
|
||||
**请求信息**:
|
||||
- **方法**: `POST`
|
||||
- **路径**: `/api/public/resources/batch-add`
|
||||
- **认证**: 必需
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"resources": [
|
||||
{
|
||||
"title": "资源1",
|
||||
"url": "链接1",
|
||||
"description": "描述1"
|
||||
},
|
||||
{
|
||||
"title": "资源2",
|
||||
"url": "链接2",
|
||||
"description": "描述2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "批量添加成功,共添加 2 个资源",
|
||||
"data": {
|
||||
"created_count": 2,
|
||||
"created_ids": [123, 124]
|
||||
},
|
||||
"code": 200
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 资源搜索
|
||||
|
||||
**接口描述**: 搜索资源,支持关键词、标签、分类过滤
|
||||
|
||||
**请求信息**:
|
||||
- **方法**: `GET`
|
||||
- **路径**: `/api/public/resources/search`
|
||||
- **认证**: 必需
|
||||
|
||||
**查询参数**:
|
||||
- `keyword` - 搜索关键词
|
||||
- `tag` - 标签过滤
|
||||
- `category` - 分类过滤
|
||||
- `page` - 页码(默认1)
|
||||
- `page_size` - 每页数量(默认20,最大100)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
"resources": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "资源标题",
|
||||
"url": "资源链接",
|
||||
"description": "资源描述",
|
||||
"view_count": 100,
|
||||
"created_at": "2024-12-19 10:00:00",
|
||||
"updated_at": "2024-12-19 10:00:00"
|
||||
}
|
||||
],
|
||||
"total": 50,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
},
|
||||
"code": 200
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 热门剧列表
|
||||
|
||||
**接口描述**: 获取热门剧列表,支持分页
|
||||
|
||||
**请求信息**:
|
||||
- **方法**: `GET`
|
||||
- **路径**: `/api/public/hot-dramas`
|
||||
- **认证**: 必需
|
||||
|
||||
**查询参数**:
|
||||
- `page` - 页码(默认1)
|
||||
- `page_size` - 每页数量(默认20,最大100)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "获取热门剧成功",
|
||||
"data": {
|
||||
"hot_dramas": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "剧名",
|
||||
"description": "剧集描述",
|
||||
"img": "封面图片",
|
||||
"url": "详情链接",
|
||||
"rating": 8.5,
|
||||
"year": "2024",
|
||||
"region": "中国大陆",
|
||||
"genres": "剧情,悬疑",
|
||||
"category": "电视剧",
|
||||
"created_at": "2024-12-19 10:00:00",
|
||||
"updated_at": "2024-12-19 10:00:00"
|
||||
}
|
||||
],
|
||||
"total": 20,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
},
|
||||
"code": 200
|
||||
}
|
||||
```
|
||||
|
||||
## 错误码说明
|
||||
|
||||
### HTTP 状态码
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 请求成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 认证失败(Token无效或缺失) |
|
||||
| 500 | 服务器内部错误 |
|
||||
| 503 | 系统维护中或API Token未配置 |
|
||||
|
||||
### 响应格式
|
||||
|
||||
所有 API 响应都遵循统一的格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true/false,
|
||||
"message": "响应消息",
|
||||
"data": {}, // 响应数据
|
||||
"code": 200 // 状态码
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL 示例
|
||||
|
||||
```bash
|
||||
# 设置API Token
|
||||
API_TOKEN="your_api_token_here"
|
||||
|
||||
# 单个添加资源
|
||||
curl -X POST "http://localhost:8080/api/public/resources/add" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Token: $API_TOKEN" \
|
||||
-d '{
|
||||
"title": "测试资源",
|
||||
"url": "https://example.com/resource",
|
||||
"description": "测试描述"
|
||||
}'
|
||||
|
||||
# 搜索资源
|
||||
curl -X GET "http://localhost:8080/api/public/resources/search?keyword=测试" \
|
||||
-H "X-API-Token: $API_TOKEN"
|
||||
|
||||
# 获取热门剧
|
||||
curl -X GET "http://localhost:8080/api/public/hot-dramas?page=1&page_size=5" \
|
||||
-H "X-API-Token: $API_TOKEN"
|
||||
```
|
||||
|
||||
### JavaScript 示例
|
||||
|
||||
```javascript
|
||||
const API_TOKEN = 'your_api_token_here';
|
||||
const BASE_URL = 'http://localhost:8080/api';
|
||||
|
||||
// 添加资源
|
||||
async function addResource(resourceData) {
|
||||
const response = await fetch(`${BASE_URL}/public/resources/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Token': API_TOKEN
|
||||
},
|
||||
body: JSON.stringify(resourceData)
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
async function searchResources(keyword, page = 1) {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/public/resources/search?keyword=${encodeURIComponent(keyword)}&page=${page}`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Token': API_TOKEN
|
||||
}
|
||||
}
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// 获取热门剧
|
||||
async function getHotDramas(page = 1, pageSize = 20) {
|
||||
const response = await fetch(
|
||||
`${BASE_URL}/public/hot-dramas?page=${page}&page_size=${pageSize}`,
|
||||
{
|
||||
headers: {
|
||||
'X-API-Token': API_TOKEN
|
||||
}
|
||||
}
|
||||
);
|
||||
return await response.json();
|
||||
}
|
||||
```
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_TOKEN = 'your_api_token_here'
|
||||
BASE_URL = 'http://localhost:8080/api'
|
||||
|
||||
headers = {
|
||||
'X-API-Token': API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# 添加资源
|
||||
def add_resource(resource_data):
|
||||
response = requests.post(
|
||||
f'{BASE_URL}/public/resources/add',
|
||||
headers=headers,
|
||||
json=resource_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# 搜索资源
|
||||
def search_resources(keyword, page=1):
|
||||
params = {
|
||||
'keyword': keyword,
|
||||
'page': page
|
||||
}
|
||||
response = requests.get(
|
||||
f'{BASE_URL}/public/resources/search',
|
||||
headers={'X-API-Token': API_TOKEN},
|
||||
params=params
|
||||
)
|
||||
return response.json()
|
||||
|
||||
# 获取热门剧
|
||||
def get_hot_dramas(page=1, page_size=20):
|
||||
params = {
|
||||
'page': page,
|
||||
'page_size': page_size
|
||||
}
|
||||
response = requests.get(
|
||||
f'{BASE_URL}/public/hot-dramas',
|
||||
headers={'X-API-Token': API_TOKEN},
|
||||
params=params
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 错误处理
|
||||
|
||||
始终检查响应的 `success` 字段和 HTTP 状态码:
|
||||
|
||||
```javascript
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
console.error('API调用失败:', data.message);
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 分页处理
|
||||
|
||||
对于支持分页的接口,建议实现分页逻辑:
|
||||
|
||||
```javascript
|
||||
async function getAllResources(keyword) {
|
||||
let allResources = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await searchResources(keyword, page);
|
||||
if (response.success) {
|
||||
allResources.push(...response.data.resources);
|
||||
hasMore = response.data.resources.length > 0;
|
||||
page++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allResources;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 请求频率限制
|
||||
|
||||
避免过于频繁的 API 调用,建议实现请求间隔:
|
||||
|
||||
```javascript
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function searchWithDelay(keyword) {
|
||||
const result = await searchResources(keyword);
|
||||
await delay(1000); // 等待1秒
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Token 安全**: 请妥善保管您的 API Token,不要泄露给他人
|
||||
2. **请求限制**: 避免过于频繁的请求,以免影响系统性能
|
||||
3. **数据格式**: 确保请求数据格式正确,特别是 JSON 格式
|
||||
4. **错误处理**: 始终实现适当的错误处理机制
|
||||
5. **版本兼容**: API 可能会进行版本更新,请关注更新通知
|
||||
|
||||
## 技术支持
|
||||
|
||||
如果您在使用 API 过程中遇到问题,请:
|
||||
|
||||
1. 检查 API Token 是否正确
|
||||
2. 确认请求格式是否符合要求
|
||||
3. 查看错误响应中的详细信息
|
||||
4. 联系技术支持团队
|
||||
|
||||
---
|
||||
|
||||
**注意**: 本站内容由网络爬虫自动抓取。本站不储存、复制、传播任何文件,仅作个人公益学习,请在获取后24小时内删除!
|
||||
@@ -1,100 +0,0 @@
|
||||
# 📝 更新日志
|
||||
|
||||
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。
|
||||
|
||||
## [未发布]
|
||||
|
||||
### 新增
|
||||
- 自动转存调度功能
|
||||
- 支持更多网盘平台
|
||||
- 性能优化和监控
|
||||
|
||||
### 修复
|
||||
- 修复已知问题
|
||||
- 改进用户体验
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### 新增
|
||||
- 🎉 首次发布
|
||||
- ✨ 完整的网盘资源管理系统
|
||||
- 🔐 JWT 用户认证系统
|
||||
- 📁 多平台网盘支持
|
||||
- 🔍 资源搜索和管理
|
||||
- 🏷️ 分类和标签系统
|
||||
- 📊 统计和监控功能
|
||||
- 🐳 Docker 容器化部署
|
||||
- 📱 响应式前端界面
|
||||
- 🌙 深色模式支持
|
||||
|
||||
### 支持的网盘平台
|
||||
- 百度网盘
|
||||
- 阿里云盘
|
||||
- 夸克网盘
|
||||
- 天翼云盘
|
||||
- 迅雷云盘
|
||||
- UC网盘
|
||||
- 123云盘
|
||||
- 115网盘
|
||||
|
||||
### 技术特性
|
||||
- **后端**: Go + Gin + GORM + PostgreSQL
|
||||
- **前端**: Nuxt.js 3 + Vue 3 + TypeScript + Tailwind CSS
|
||||
- **部署**: Docker + Docker Compose
|
||||
- **认证**: JWT Token
|
||||
- **架构**: 前后端分离
|
||||
|
||||
## [0.9.0] - 2024-12-15
|
||||
|
||||
### 新增
|
||||
- 🚀 项目初始化
|
||||
- 📋 基础功能开发
|
||||
- 🏗️ 架构设计完成
|
||||
- 🔧 开发环境搭建
|
||||
|
||||
### 技术栈确定
|
||||
- 后端技术栈选型
|
||||
- 前端技术栈选型
|
||||
- 数据库设计
|
||||
- API 接口设计
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 版本号格式
|
||||
- **主版本号**: 不兼容的 API 修改
|
||||
- **次版本号**: 向下兼容的功能性新增
|
||||
- **修订号**: 向下兼容的问题修正
|
||||
|
||||
### 更新类型
|
||||
- 🎉 **重大更新**: 新版本发布
|
||||
- ✨ **新增功能**: 新功能添加
|
||||
- 🔧 **功能改进**: 现有功能优化
|
||||
- 🐛 **问题修复**: Bug 修复
|
||||
- 📝 **文档更新**: 文档改进
|
||||
- 🚀 **性能优化**: 性能提升
|
||||
- 🔒 **安全更新**: 安全相关更新
|
||||
- 🎨 **界面优化**: UI/UX 改进
|
||||
|
||||
## 贡献指南
|
||||
|
||||
如果您想为项目做出贡献,请:
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 反馈
|
||||
|
||||
如果您发现任何问题或有建议,请:
|
||||
|
||||
- 提交 [Issue](https://github.com/ctwj/urldb/issues)
|
||||
- 发送邮件到 510199617@qq.com
|
||||
- 在 [讨论区](https://github.com/ctwj/urldb/discussions) 交流
|
||||
|
||||
---
|
||||
|
||||
**注意**: 此更新日志记录了项目的重要变更。对于详细的开发日志,请查看 Git 提交历史。
|
||||
@@ -1,53 +0,0 @@
|
||||
// docsify 配置文件
|
||||
window.$docsify = {
|
||||
name: '老九网盘链接数据库',
|
||||
repo: 'https://github.com/ctwj/urldb',
|
||||
loadSidebar: '_sidebar.md',
|
||||
subMaxLevel: 3,
|
||||
auto2top: true,
|
||||
// 添加侧边栏配置
|
||||
sidebarDisplayLevel: 1,
|
||||
// 添加错误处理
|
||||
notFoundPage: true,
|
||||
search: {
|
||||
maxAge: 86400000,
|
||||
paths: 'auto',
|
||||
placeholder: '搜索文档...',
|
||||
noData: '找不到结果',
|
||||
depth: 6
|
||||
},
|
||||
copyCode: {
|
||||
buttonText: '复制',
|
||||
errorText: '错误',
|
||||
successText: '已复制'
|
||||
},
|
||||
pagination: {
|
||||
previousText: '上一页',
|
||||
nextText: '下一页',
|
||||
crossChapter: true,
|
||||
crossChapterText: true,
|
||||
},
|
||||
plugins: [
|
||||
function(hook, vm) {
|
||||
hook.beforeEach(function (html) {
|
||||
// 添加页面标题
|
||||
var url = '#' + vm.route.path;
|
||||
var title = vm.route.path === '/' ? '首页' : vm.route.path.replace('/', '');
|
||||
return html + '\n\n---\n\n' +
|
||||
'<div style="text-align: center; color: #666; font-size: 14px;">' +
|
||||
'最后更新: ' + new Date().toLocaleDateString('zh-CN') +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
// 添加侧边栏加载调试
|
||||
hook.doneEach(function() {
|
||||
console.log('Docsify loaded, sidebar should be visible');
|
||||
if (document.querySelector('.sidebar-nav')) {
|
||||
console.log('Sidebar element found');
|
||||
} else {
|
||||
console.log('Sidebar element not found');
|
||||
}
|
||||
});
|
||||
}
|
||||
]
|
||||
};
|
||||
26
docs/faq.md
26
docs/faq.md
@@ -1,26 +0,0 @@
|
||||
# ❓ 常见问题
|
||||
|
||||
## 部署相关
|
||||
|
||||
### Q: 默认账号密码是多少?
|
||||
|
||||
**A:** 可以通过以下方式解决:
|
||||
|
||||
1. admin/password
|
||||
|
||||
### Q: 批量添加了资源,但是系统里面没有出现,也搜索不到?
|
||||
|
||||
**A:** 可以通过以下方式解决:
|
||||
|
||||
1. 需要先开启自动处理待处理任务的开关
|
||||
2. 定时任务每5分钟执行一次,可能需要等待
|
||||
3. 如果添加的链接地址无效, 会被程序过滤
|
||||
|
||||
### Q: 没有自动转存?
|
||||
|
||||
**A:** 可以通过以下方式解决:
|
||||
|
||||
1. 需要先添加账号
|
||||
2. 开启定时任务
|
||||
3. 等待任务完成
|
||||
4. 只要支持的网盘地址才会被自动转存并分享
|
||||
@@ -1,253 +0,0 @@
|
||||
# GitHub版本管理指南
|
||||
|
||||
本项目使用GitHub进行版本管理,支持自动创建Release和标签。
|
||||
|
||||
## 版本管理流程
|
||||
|
||||
### 1. 版本号规范
|
||||
|
||||
遵循[语义化版本](https://semver.org/lang/zh-CN/)规范:
|
||||
|
||||
- **主版本号** (Major): 不兼容的API修改
|
||||
- **次版本号** (Minor): 向下兼容的功能性新增
|
||||
- **修订号** (Patch): 向下兼容的问题修正
|
||||
|
||||
### 2. 版本管理命令
|
||||
|
||||
#### 显示版本信息
|
||||
```bash
|
||||
./scripts/version.sh show
|
||||
```
|
||||
|
||||
#### 更新版本号
|
||||
```bash
|
||||
# 修订版本 (1.0.0 -> 1.0.1)
|
||||
./scripts/version.sh patch
|
||||
|
||||
# 次版本 (1.0.0 -> 1.1.0)
|
||||
./scripts/version.sh minor
|
||||
|
||||
# 主版本 (1.0.0 -> 2.0.0)
|
||||
./scripts/version.sh major
|
||||
```
|
||||
|
||||
#### 发布版本到GitHub
|
||||
```bash
|
||||
./scripts/version.sh release
|
||||
```
|
||||
|
||||
### 3. 自动发布流程
|
||||
|
||||
当执行版本更新命令时,脚本会:
|
||||
|
||||
1. **更新版本号**: 修改 `VERSION` 文件
|
||||
2. **同步文件**: 更新 `package.json`、`docker-compose.yml`、`README.md`
|
||||
3. **创建Git标签**: 自动创建版本标签
|
||||
4. **推送代码**: 推送代码和标签到GitHub
|
||||
5. **创建Release**: 自动创建GitHub Release
|
||||
|
||||
### 4. 手动发布流程
|
||||
|
||||
如果自动发布失败,可以手动发布:
|
||||
|
||||
#### 步骤1: 更新版本号
|
||||
```bash
|
||||
./scripts/version.sh patch # 或 minor, major
|
||||
```
|
||||
|
||||
#### 步骤2: 提交更改
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: bump version to v1.0.1"
|
||||
```
|
||||
|
||||
#### 步骤3: 创建标签
|
||||
```bash
|
||||
git tag v1.0.1
|
||||
```
|
||||
|
||||
#### 步骤4: 推送到GitHub
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin v1.0.1
|
||||
```
|
||||
|
||||
#### 步骤5: 创建Release
|
||||
在GitHub网页上:
|
||||
1. 进入项目页面
|
||||
2. 点击 "Releases"
|
||||
3. 点击 "Create a new release"
|
||||
4. 选择标签 `v1.0.1`
|
||||
5. 填写Release说明
|
||||
6. 发布
|
||||
|
||||
### 5. GitHub CLI工具
|
||||
|
||||
#### 安装GitHub CLI
|
||||
```bash
|
||||
# macOS
|
||||
brew install gh
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install gh
|
||||
|
||||
# Windows
|
||||
winget install GitHub.cli
|
||||
```
|
||||
|
||||
#### 登录GitHub
|
||||
```bash
|
||||
gh auth login
|
||||
```
|
||||
|
||||
#### 创建Release
|
||||
```bash
|
||||
gh release create v1.0.1 \
|
||||
--title "Release v1.0.1" \
|
||||
--notes "修复了一些bug" \
|
||||
--draft=false \
|
||||
--prerelease=false
|
||||
```
|
||||
|
||||
### 6. 版本检查
|
||||
|
||||
#### API接口
|
||||
- `GET /api/version/check-update` - 检查GitHub上的最新版本
|
||||
|
||||
#### 前端页面
|
||||
- 访问 `/version` 页面查看版本信息和更新状态
|
||||
|
||||
### 7. 版本历史
|
||||
|
||||
#### 查看所有标签
|
||||
```bash
|
||||
git tag -l
|
||||
```
|
||||
|
||||
#### 查看标签详情
|
||||
```bash
|
||||
git show v1.0.1
|
||||
```
|
||||
|
||||
#### 查看版本历史
|
||||
```bash
|
||||
git log --oneline --decorate
|
||||
```
|
||||
|
||||
### 8. 回滚版本
|
||||
|
||||
如果需要回滚到之前的版本:
|
||||
|
||||
#### 删除本地标签
|
||||
```bash
|
||||
git tag -d v1.0.1
|
||||
```
|
||||
|
||||
#### 删除远程标签
|
||||
```bash
|
||||
git push origin :refs/tags/v1.0.1
|
||||
```
|
||||
|
||||
#### 回滚代码
|
||||
```bash
|
||||
git reset --hard v1.0.0
|
||||
git push --force origin main
|
||||
```
|
||||
|
||||
### 9. 最佳实践
|
||||
|
||||
#### 提交信息规范
|
||||
```bash
|
||||
# 功能开发
|
||||
git commit -m "feat: 添加新功能"
|
||||
|
||||
# Bug修复
|
||||
git commit -m "fix: 修复某个bug"
|
||||
|
||||
# 文档更新
|
||||
git commit -m "docs: 更新文档"
|
||||
|
||||
# 版本更新
|
||||
git commit -m "chore: bump version to v1.0.1"
|
||||
```
|
||||
|
||||
#### 分支管理
|
||||
- `main`: 主分支,用于发布
|
||||
- `develop`: 开发分支
|
||||
- `feature/*`: 功能分支
|
||||
- `hotfix/*`: 热修复分支
|
||||
|
||||
#### Release说明模板
|
||||
```markdown
|
||||
## Release v1.0.1
|
||||
|
||||
**发布日期**: 2024-01-15
|
||||
|
||||
### 更新内容
|
||||
|
||||
- 修复了某个bug
|
||||
- 添加了新功能
|
||||
- 优化了性能
|
||||
|
||||
### 下载
|
||||
|
||||
- [源码 (ZIP)](https://github.com/ctwj/urldb/archive/v1.0.1.zip)
|
||||
- [源码 (TAR.GZ)](https://github.com/ctwj/urldb/archive/v1.0.1.tar.gz)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/ctwj/urldb.git
|
||||
cd urldb
|
||||
|
||||
# 切换到指定版本
|
||||
git checkout v1.0.1
|
||||
|
||||
# 使用Docker部署
|
||||
docker-compose up --build -d
|
||||
```
|
||||
|
||||
### 更新日志
|
||||
|
||||
详细更新日志请查看 [CHANGELOG.md](https://github.com/ctwj/urldb/blob/v1.0.1/CHANGELOG.md)
|
||||
```
|
||||
|
||||
### 10. 故障排除
|
||||
|
||||
#### 常见问题
|
||||
|
||||
1. **GitHub CLI未安装**
|
||||
```bash
|
||||
# 安装GitHub CLI
|
||||
brew install gh # macOS
|
||||
```
|
||||
|
||||
2. **GitHub CLI未登录**
|
||||
```bash
|
||||
# 登录GitHub
|
||||
gh auth login
|
||||
```
|
||||
|
||||
3. **标签已存在**
|
||||
```bash
|
||||
# 删除本地标签
|
||||
git tag -d v1.0.1
|
||||
|
||||
# 删除远程标签
|
||||
git push origin :refs/tags/v1.0.1
|
||||
```
|
||||
|
||||
4. **推送失败**
|
||||
```bash
|
||||
# 检查远程仓库
|
||||
git remote -v
|
||||
|
||||
# 重新设置远程仓库
|
||||
git remote set-url origin https://github.com/ctwj/urldb.git
|
||||
```
|
||||
|
||||
#### 获取帮助
|
||||
```bash
|
||||
./scripts/version.sh help
|
||||
```
|
||||
@@ -1,352 +0,0 @@
|
||||
# 🐳 Docker 部署
|
||||
|
||||
## 概述
|
||||
|
||||
urlDB 支持使用 Docker 进行容器化部署,提供了完整的前后端分离架构。
|
||||
|
||||
## 系统架构
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| frontend | 3000 | Nuxt.js 前端应用 |
|
||||
| backend | 8080 | Go API 后端服务 |
|
||||
| postgres | 5432 | PostgreSQL 数据库 |
|
||||
|
||||
## 快速部署
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ctwj/urldb.git
|
||||
cd urldb
|
||||
```
|
||||
|
||||
### 2. 使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 给脚本执行权限
|
||||
chmod +x docker-start.sh
|
||||
|
||||
# 启动服务
|
||||
./docker-start.sh
|
||||
```
|
||||
|
||||
### 3. 手动启动
|
||||
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
docker compose up --build -d
|
||||
|
||||
# 查看服务状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
可以通过修改 `docker-compose.yml` 文件中的环境变量来配置服务:
|
||||
|
||||
后端 backend
|
||||
```yaml
|
||||
environment:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: password
|
||||
DB_NAME: url_db
|
||||
PORT: 8080
|
||||
```
|
||||
|
||||
前端 frontend
|
||||
```yaml
|
||||
environment:
|
||||
API_BASE: /api
|
||||
```
|
||||
|
||||
### 端口映射
|
||||
|
||||
如果需要修改端口映射,可以编辑 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "3001:3000" # 前端端口
|
||||
- "8081:8080" # API端口
|
||||
- "5433:5432" # 数据库端口
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 服务管理
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
docker compose up -d
|
||||
|
||||
# 停止服务
|
||||
docker compose down
|
||||
|
||||
# 重启服务
|
||||
docker compose restart
|
||||
|
||||
# 查看服务状态
|
||||
docker compose ps
|
||||
|
||||
# 查看日志
|
||||
docker compose logs -f [service_name]
|
||||
```
|
||||
|
||||
### 数据管理
|
||||
|
||||
```bash
|
||||
# 备份数据库
|
||||
docker compose exec postgres pg_dump -U postgres url_db > backup.sql
|
||||
|
||||
# 恢复数据库
|
||||
docker compose exec -T postgres psql -U postgres url_db < backup.sql
|
||||
|
||||
# 进入数据库
|
||||
docker compose exec postgres psql -U postgres url_db
|
||||
```
|
||||
|
||||
### 容器管理
|
||||
|
||||
```bash
|
||||
# 进入容器
|
||||
docker compose exec [service_name] sh
|
||||
|
||||
# 查看容器资源使用
|
||||
docker stats
|
||||
|
||||
# 清理未使用的资源
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
## 生产环境部署
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# 安装 Docker 和 Docker Compose
|
||||
# 确保服务器有足够资源(建议 4GB+ 内存)
|
||||
|
||||
# 创建部署目录
|
||||
mkdir -p /opt/urldb
|
||||
cd /opt/urldb
|
||||
```
|
||||
|
||||
### 2. 配置文件
|
||||
|
||||
创建生产环境配置文件:
|
||||
|
||||
```bash
|
||||
# 复制项目文件
|
||||
git clone https://github.com/ctwj/urldb.git .
|
||||
|
||||
# 创建环境变量文件
|
||||
cp env.example .env.prod
|
||||
|
||||
# 编辑生产环境配置
|
||||
vim .env.prod
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 使用生产环境配置启动
|
||||
docker compose -f docker-compose.yml --env-file .env.prod up -d
|
||||
|
||||
# 检查服务状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 4. 配置反向代理
|
||||
|
||||
#### Nginx 配置示例
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端代理
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. SSL 配置
|
||||
|
||||
```bash
|
||||
# 使用 Let's Encrypt 获取证书
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
|
||||
# 或使用自签名证书
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout /etc/ssl/private/urldb.key \
|
||||
-out /etc/ssl/certs/urldb.crt
|
||||
```
|
||||
|
||||
## 监控和维护
|
||||
|
||||
### 1. 日志管理
|
||||
|
||||
```bash
|
||||
# 查看所有服务日志
|
||||
docker compose logs -f
|
||||
|
||||
# 查看特定服务日志
|
||||
docker compose logs -f backend
|
||||
|
||||
# 导出日志
|
||||
docker compose logs > urldb.log
|
||||
```
|
||||
|
||||
### 2. 性能监控
|
||||
|
||||
```bash
|
||||
# 查看容器资源使用
|
||||
docker stats
|
||||
|
||||
# 查看系统资源
|
||||
htop
|
||||
df -h
|
||||
free -h
|
||||
```
|
||||
|
||||
### 3. 备份策略
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# 创建备份脚本 backup.sh
|
||||
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/backup/urldb"
|
||||
|
||||
# 创建备份目录
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# 备份数据库
|
||||
docker compose exec -T postgres pg_dump -U postgres url_db > $BACKUP_DIR/db_$DATE.sql
|
||||
|
||||
# 备份上传文件
|
||||
tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz uploads/
|
||||
|
||||
# 删除7天前的备份
|
||||
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete
|
||||
find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete
|
||||
```
|
||||
|
||||
### 4. 自动更新
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# 创建更新脚本 update.sh
|
||||
|
||||
cd /opt/urldb
|
||||
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 重新构建并启动
|
||||
docker compose down
|
||||
docker compose up --build -d
|
||||
|
||||
# 检查服务状态
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 1. 服务启动失败
|
||||
|
||||
```bash
|
||||
# 查看详细错误信息
|
||||
docker compose logs [service_name]
|
||||
|
||||
# 检查端口占用
|
||||
netstat -tulpn | grep :3000
|
||||
netstat -tulpn | grep :8080
|
||||
|
||||
# 检查磁盘空间
|
||||
df -h
|
||||
```
|
||||
|
||||
### 2. 数据库连接问题
|
||||
|
||||
```bash
|
||||
# 检查数据库状态
|
||||
docker compose exec postgres pg_isready -U postgres
|
||||
|
||||
# 检查数据库日志
|
||||
docker compose logs postgres
|
||||
|
||||
# 重启数据库服务
|
||||
docker compose restart postgres
|
||||
```
|
||||
|
||||
### 3. 前端无法访问后端
|
||||
|
||||
```bash
|
||||
# 检查网络连接
|
||||
docker compose exec frontend ping backend
|
||||
|
||||
# 检查 API 配置
|
||||
docker compose exec frontend env | grep API_BASE
|
||||
|
||||
# 测试 API 连接
|
||||
curl http://localhost:8080/api/health
|
||||
```
|
||||
|
||||
### 4. 内存不足
|
||||
|
||||
```bash
|
||||
# 查看内存使用
|
||||
free -h
|
||||
|
||||
# 增加 swap 空间
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
### 1. 网络安全
|
||||
|
||||
- 使用防火墙限制端口访问
|
||||
- 配置 SSL/TLS 加密
|
||||
- 定期更新系统和 Docker 版本
|
||||
|
||||
### 2. 数据安全
|
||||
|
||||
- 定期备份数据库
|
||||
- 使用强密码
|
||||
- 限制数据库访问权限
|
||||
|
||||
### 3. 容器安全
|
||||
|
||||
- 使用非 root 用户运行容器
|
||||
- 定期更新镜像
|
||||
- 扫描镜像漏洞
|
||||
|
||||
## 下一步
|
||||
|
||||
- [了解系统配置](../guide/configuration.md)
|
||||
- [查看 API 文档](../api/overview.md)
|
||||
- [学习监控和维护](../development/deployment.md)
|
||||
@@ -1,302 +0,0 @@
|
||||
# 💻 本地开发
|
||||
|
||||
## 环境准备
|
||||
|
||||
### 1. 安装必需软件
|
||||
|
||||
#### Go 环境
|
||||
```bash
|
||||
# 下载并安装 Go 1.23+
|
||||
# 访问 https://golang.org/dl/
|
||||
# 或使用包管理器安装
|
||||
|
||||
# 验证安装
|
||||
go version
|
||||
```
|
||||
|
||||
#### Node.js 环境
|
||||
```bash
|
||||
# 下载并安装 Node.js 18+
|
||||
# 访问 https://nodejs.org/
|
||||
# 或使用 nvm 安装
|
||||
|
||||
# 验证安装
|
||||
node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
#### PostgreSQL 数据库
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
|
||||
# macOS (使用 Homebrew)
|
||||
brew install postgresql
|
||||
|
||||
# 启动服务
|
||||
sudo systemctl start postgresql # Linux
|
||||
brew services start postgresql # macOS
|
||||
```
|
||||
|
||||
#### pnpm (推荐)
|
||||
```bash
|
||||
# 安装 pnpm
|
||||
npm install -g pnpm
|
||||
|
||||
# 验证安装
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
### 2. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ctwj/urldb.git
|
||||
cd urldb
|
||||
```
|
||||
|
||||
## 后端开发
|
||||
|
||||
### 1. 环境配置
|
||||
|
||||
```bash
|
||||
# 复制环境变量文件
|
||||
cp env.example .env
|
||||
|
||||
# 编辑环境变量
|
||||
vim .env
|
||||
```
|
||||
|
||||
### 2. 数据库设置
|
||||
|
||||
```sql
|
||||
-- 登录 PostgreSQL
|
||||
sudo -u postgres psql
|
||||
|
||||
-- 创建数据库
|
||||
CREATE DATABASE url_db;
|
||||
|
||||
-- 创建用户(可选)
|
||||
CREATE USER url_user WITH PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE url_db TO url_user;
|
||||
|
||||
-- 退出
|
||||
\q
|
||||
```
|
||||
|
||||
### 3. 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装 Go 依赖
|
||||
go mod tidy
|
||||
|
||||
# 验证依赖
|
||||
go mod verify
|
||||
```
|
||||
|
||||
### 4. 启动后端服务
|
||||
|
||||
```bash
|
||||
# 开发模式启动
|
||||
go run main.go
|
||||
|
||||
# 或使用 air 热重载(推荐)
|
||||
go install github.com/cosmtrek/air@latest
|
||||
air
|
||||
```
|
||||
|
||||
## 前端开发
|
||||
|
||||
### 1. 进入前端目录
|
||||
|
||||
```bash
|
||||
cd web
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 pnpm (推荐)
|
||||
pnpm install
|
||||
|
||||
# 或使用 npm
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 启动开发服务器
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
pnpm dev
|
||||
|
||||
# 或使用 npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. 访问前端
|
||||
|
||||
前端服务启动后,访问 http://localhost:3000
|
||||
|
||||
## 开发工具
|
||||
|
||||
### 推荐的 IDE 和插件
|
||||
|
||||
#### VS Code
|
||||
- **Go** - Go 语言支持
|
||||
- **Vetur** 或 **Volar** - Vue.js 支持
|
||||
- **PostgreSQL** - 数据库支持
|
||||
- **Docker** - Docker 支持
|
||||
- **GitLens** - Git 增强
|
||||
|
||||
#### GoLand / IntelliJ IDEA
|
||||
- 内置 Go 和 Vue.js 支持
|
||||
- 数据库工具
|
||||
- Docker 集成
|
||||
|
||||
### 代码格式化
|
||||
|
||||
```bash
|
||||
# Go 代码格式化
|
||||
go fmt ./...
|
||||
|
||||
# 前端代码格式化
|
||||
cd web
|
||||
pnpm format
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
# Go 代码检查
|
||||
go vet ./...
|
||||
|
||||
# 前端代码检查
|
||||
cd web
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 后端调试
|
||||
|
||||
```bash
|
||||
# 使用 delve 调试器
|
||||
go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
dlv debug main.go
|
||||
|
||||
# 或使用 VS Code 调试配置
|
||||
```
|
||||
|
||||
### 前端调试
|
||||
|
||||
```bash
|
||||
# 启动开发服务器时开启调试
|
||||
cd web
|
||||
pnpm dev --inspect
|
||||
```
|
||||
|
||||
### 数据库调试
|
||||
|
||||
```bash
|
||||
# 连接数据库
|
||||
psql -h localhost -U postgres -d url_db
|
||||
|
||||
# 查看表结构
|
||||
\dt
|
||||
|
||||
# 查看数据
|
||||
SELECT * FROM users LIMIT 5;
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
### 后端测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
go test ./...
|
||||
|
||||
# 运行特定测试
|
||||
go test ./handlers
|
||||
|
||||
# 生成测试覆盖率报告
|
||||
go test -cover ./...
|
||||
```
|
||||
|
||||
### 前端测试
|
||||
|
||||
```bash
|
||||
cd web
|
||||
|
||||
# 运行单元测试
|
||||
pnpm test
|
||||
|
||||
# 运行 E2E 测试
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
### 后端构建
|
||||
|
||||
```bash
|
||||
# 构建二进制文件
|
||||
go build -o urlDB main.go
|
||||
|
||||
# 交叉编译
|
||||
GOOS=linux GOARCH=amd64 go build -o urlDB-linux main.go
|
||||
```
|
||||
|
||||
### 前端构建
|
||||
|
||||
```bash
|
||||
cd web
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 预览构建结果
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 端口冲突
|
||||
|
||||
如果遇到端口被占用的问题:
|
||||
|
||||
```bash
|
||||
# 查看端口占用
|
||||
lsof -i :8080
|
||||
lsof -i :3000
|
||||
|
||||
# 杀死进程
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### 2. 数据库连接失败
|
||||
|
||||
检查 `.env` 文件中的数据库配置:
|
||||
|
||||
```bash
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=url_db
|
||||
```
|
||||
|
||||
### 3. 前端依赖安装失败
|
||||
|
||||
```bash
|
||||
# 清除缓存
|
||||
pnpm store prune
|
||||
rm -rf node_modules
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [了解项目架构](../architecture/overview.md)
|
||||
- [查看 API 文档](../api/overview.md)
|
||||
- [学习代码规范](../development/coding-standards.md)
|
||||
@@ -1,36 +0,0 @@
|
||||
# 🚀 快速开始
|
||||
|
||||
## 环境要求
|
||||
|
||||
在开始使用 urlDB 之前,请确保您的系统满足以下要求:
|
||||
|
||||
### 推荐配置
|
||||
- **CPU**: 2核
|
||||
- **内存**: 2GB+
|
||||
- **存储**: 20GB+ 可用空间
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ctwj/urldb.git
|
||||
cd urldb
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
### 2. 访问应用
|
||||
|
||||
启动成功后,您可以通过以下地址访问:
|
||||
|
||||
- **前端界面**: http://localhost:3030
|
||||
默认用户密码: admin/password
|
||||
|
||||
|
||||
## 🆘 遇到问题?
|
||||
|
||||
如果您在部署过程中遇到问题,请:
|
||||
|
||||
1. 查看 [常见问题](../faq.md)
|
||||
2. 检查 [更新日志](../changelog.md)
|
||||
3. 提交 [Issue](https://github.com/ctwj/urldb/issues)
|
||||
@@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>urlDB - 老九网盘资源数据库</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta name="description" content="一个现代化的网盘资源数据库,支持多网盘自动化转存分享">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/dark.css" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" href="https://img.icons8.com/color/48/000000/database.png" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
|
||||
<script src="docsify.config.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/search.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/copy-code.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify@4/lib/plugins/pagination.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-bash.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-go.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-javascript.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-typescript.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-sql.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-yaml.min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/prismjs@1/components/prism-json.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,84 +0,0 @@
|
||||
# 许可证
|
||||
|
||||
## GNU General Public License v3.0
|
||||
|
||||
本项目采用 GNU General Public License v3.0 (GPL-3.0) 许可证。
|
||||
|
||||
### 许可证概述
|
||||
|
||||
GPL-3.0 是一个自由软件许可证,它确保软件保持自由和开放。该许可证的主要特点包括:
|
||||
|
||||
- **自由使用**: 您可以自由地运行、研究、修改和分发软件
|
||||
- **源代码开放**: 修改后的代码必须同样开源
|
||||
- **专利保护**: 包含专利授权条款
|
||||
- **兼容性**: 与大多数开源许可证兼容
|
||||
|
||||
### 主要条款
|
||||
|
||||
1. **自由使用和分发**
|
||||
- 您可以自由地使用、复制、分发和修改本软件
|
||||
- 您可以商业使用本软件
|
||||
|
||||
2. **源代码要求**
|
||||
- 如果您分发修改后的版本,必须同时提供源代码
|
||||
- 源代码必须采用相同的许可证
|
||||
|
||||
3. **专利授权**
|
||||
- 贡献者自动授予用户专利使用权
|
||||
- 保护用户免受专利诉讼
|
||||
|
||||
4. **免责声明**
|
||||
- 软件按"原样"提供,不提供任何保证
|
||||
- 作者不承担任何责任
|
||||
|
||||
### 完整许可证文本
|
||||
|
||||
```
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
[... 完整许可证文本请访问 https://www.gnu.org/licenses/gpl-3.0.html ...]
|
||||
```
|
||||
|
||||
### 如何遵守许可证
|
||||
|
||||
如果您使用或修改本项目:
|
||||
|
||||
1. **保留许可证信息**: 不要删除或修改许可证文件
|
||||
2. **注明修改**: 在修改的代码中添加适当的注释
|
||||
3. **分发源代码**: 如果分发修改版本,必须提供源代码
|
||||
4. **使用相同许可证**: 修改版本必须使用相同的GPL-3.0许可证
|
||||
|
||||
### 贡献代码
|
||||
|
||||
当您向本项目贡献代码时,您同意:
|
||||
|
||||
- 您的贡献将采用GPL-3.0许可证
|
||||
- 您拥有或有权许可您贡献的代码
|
||||
- 您授予项目维护者使用您贡献代码的权利
|
||||
|
||||
### 联系方式
|
||||
|
||||
如果您对许可证有任何疑问,请联系项目维护者。
|
||||
|
||||
---
|
||||
|
||||
**注意**: 本许可证信息仅供参考,完整和权威的许可证文本请参考 [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html)。
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 启动 docsify 文档服务脚本
|
||||
|
||||
echo "🚀 启动 docsify 文档服务..."
|
||||
|
||||
# 检查是否安装了 docsify-cli
|
||||
if ! command -v docsify &> /dev/null; then
|
||||
echo "❌ 未检测到 docsify-cli,正在安装..."
|
||||
npm install -g docsify-cli
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ docsify-cli 安装失败,请手动安装:"
|
||||
echo " npm install -g docsify-cli"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 获取当前脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "📖 文档目录: $SCRIPT_DIR"
|
||||
echo "🌐 启动文档服务..."
|
||||
|
||||
# 启动 docsify 服务
|
||||
docsify serve "$SCRIPT_DIR" --port 3000 --open
|
||||
|
||||
echo "✅ 文档服务已启动!"
|
||||
echo "📱 访问地址: http://localhost:3000"
|
||||
echo "🛑 按 Ctrl+C 停止服务"
|
||||
4
go.mod
4
go.mod
@@ -8,9 +8,11 @@ require (
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-resty/resty/v2 v2.16.5
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/meilisearch/meilisearch-go v0.33.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.40.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.0
|
||||
@@ -50,7 +52,7 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
golang.org/x/arch v0.19.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -36,6 +36,8 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
@@ -98,6 +100,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
|
||||
100
handlers/api_access_log_handler.go
Normal file
100
handlers/api_access_log_handler.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAPIAccessLogs 获取API访问日志
|
||||
func GetAPIAccessLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
startDateStr := c.Query("start_date")
|
||||
endDateStr := c.Query("end_date")
|
||||
endpoint := c.Query("endpoint")
|
||||
ip := c.Query("ip")
|
||||
|
||||
var startDate, endDate *time.Time
|
||||
|
||||
if startDateStr != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||
startDate = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
if endDateStr != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", endDateStr); err == nil {
|
||||
// 设置为当天结束时间
|
||||
endOfDay := parsed.Add(24*time.Hour - time.Second)
|
||||
endDate = &endOfDay
|
||||
}
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
logs, total, err := repoManager.APIAccessLogRepository.FindWithFilters(page, pageSize, startDate, endDate, endpoint, ip)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取API访问日志失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ToAPIAccessLogResponseList(logs)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": response,
|
||||
"total": int(total),
|
||||
"page": page,
|
||||
"limit": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAPIAccessLogSummary 获取API访问日志汇总
|
||||
func GetAPIAccessLogSummary(c *gin.Context) {
|
||||
summary, err := repoManager.APIAccessLogRepository.GetSummary()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取API访问日志汇总失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ToAPIAccessLogSummaryResponse(summary)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetAPIAccessLogStats 获取API访问日志统计
|
||||
func GetAPIAccessLogStats(c *gin.Context) {
|
||||
stats, err := repoManager.APIAccessLogRepository.GetStatsByEndpoint()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取API访问日志统计失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.ToAPIAccessLogStatsResponseList(stats)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// ClearAPIAccessLogs 清理API访问日志
|
||||
func ClearAPIAccessLogs(c *gin.Context) {
|
||||
daysStr := c.Query("days")
|
||||
if daysStr == "" {
|
||||
ErrorResponse(c, "请提供要清理的天数参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
days, err := strconv.Atoi(daysStr)
|
||||
if err != nil || days < 1 {
|
||||
ErrorResponse(c, "无效的天数参数", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = repoManager.APIAccessLogRepository.ClearOldLogs(days)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "清理API访问日志失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"message": "API访问日志清理成功"})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -51,6 +52,8 @@ func CreateCks(c *gin.Context) {
|
||||
serviceType = panutils.BaiduPan
|
||||
case "uc":
|
||||
serviceType = panutils.UC
|
||||
case "xunlei":
|
||||
serviceType = panutils.Xunlei
|
||||
default:
|
||||
ErrorResponse(c, "不支持的平台类型", http.StatusBadRequest)
|
||||
return
|
||||
@@ -64,28 +67,61 @@ func CreateCks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
userInfo, err := service.GetUserInfo(req.Ck)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无法获取用户信息,账号创建失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var cks *entity.Cks
|
||||
// 迅雷网盘,添加的时候 只获取token就好, 然后刷新的时候, 再补充用户信息等
|
||||
if serviceType == panutils.Xunlei {
|
||||
xunleiService := service.(*panutils.XunleiPanService)
|
||||
tokenData, err := xunleiService.GetAccessTokenByRefreshToken(req.Ck)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无法获取有效token: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
extra := panutils.XunleiExtraData{
|
||||
Token: &tokenData,
|
||||
Captcha: &panutils.CaptchaData{},
|
||||
}
|
||||
extraStr, _ := json.Marshal(extra)
|
||||
|
||||
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
|
||||
// 创建Cks实体
|
||||
cks = &entity.Cks{
|
||||
PanID: req.PanID,
|
||||
Idx: req.Idx,
|
||||
Ck: tokenData.RefreshToken,
|
||||
IsValid: true, // 根据VIP状态设置有效性
|
||||
Space: 0,
|
||||
LeftSpace: 0,
|
||||
UsedSpace: 0,
|
||||
Username: "-",
|
||||
VipStatus: false,
|
||||
ServiceType: "xunlei",
|
||||
Extra: string(extraStr),
|
||||
Remark: req.Remark,
|
||||
}
|
||||
} else {
|
||||
// 获取用户信息
|
||||
userInfo, err := service.GetUserInfo(&req.Ck)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无法获取用户信息,账号创建失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建Cks实体
|
||||
cks := &entity.Cks{
|
||||
PanID: req.PanID,
|
||||
Idx: req.Idx,
|
||||
Ck: req.Ck,
|
||||
IsValid: userInfo.VIPStatus, // 根据VIP状态设置有效性
|
||||
Space: userInfo.TotalSpace,
|
||||
LeftSpace: leftSpaceBytes,
|
||||
UsedSpace: userInfo.UsedSpace,
|
||||
Username: userInfo.Username,
|
||||
VipStatus: userInfo.VIPStatus,
|
||||
ServiceType: userInfo.ServiceType,
|
||||
Remark: req.Remark,
|
||||
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
|
||||
|
||||
// 创建Cks实体
|
||||
cks = &entity.Cks{
|
||||
PanID: req.PanID,
|
||||
Idx: req.Idx,
|
||||
Ck: req.Ck,
|
||||
IsValid: userInfo.VIPStatus, // 根据VIP状态设置有效性
|
||||
Space: userInfo.TotalSpace,
|
||||
LeftSpace: leftSpaceBytes,
|
||||
UsedSpace: userInfo.UsedSpace,
|
||||
Username: userInfo.Username,
|
||||
VipStatus: userInfo.VIPStatus,
|
||||
ServiceType: userInfo.ServiceType,
|
||||
Extra: userInfo.ExtraData,
|
||||
Remark: req.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
err = repoManager.CksRepository.Create(cks)
|
||||
@@ -293,6 +329,8 @@ func RefreshCapacity(c *gin.Context) {
|
||||
serviceType = panutils.BaiduPan
|
||||
case "uc":
|
||||
serviceType = panutils.UC
|
||||
case "xunlei":
|
||||
serviceType = panutils.Xunlei
|
||||
default:
|
||||
ErrorResponse(c, "不支持的平台类型", http.StatusBadRequest)
|
||||
return
|
||||
@@ -306,13 +344,20 @@ func RefreshCapacity(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取最新的用户信息
|
||||
userInfo, err := service.GetUserInfo(cks.Ck)
|
||||
var userInfo *panutils.UserInfo
|
||||
service.SetCKSRepository(repoManager.CksRepository, *cks) // 迅雷需要初始化 token 后才能获取,
|
||||
userInfo, err = service.GetUserInfo(&cks.Ck)
|
||||
// switch s := service.(type) {
|
||||
// case *panutils.XunleiPanService:
|
||||
|
||||
// userInfo, err = s.GetUserInfo(nil)
|
||||
// default:
|
||||
// userInfo, err = service.GetUserInfo(&cks.Ck)
|
||||
// }
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无法获取用户信息,刷新失败: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
leftSpaceBytes := userInfo.TotalSpace - userInfo.UsedSpace
|
||||
|
||||
// 更新账号信息
|
||||
@@ -322,7 +367,7 @@ func RefreshCapacity(c *gin.Context) {
|
||||
cks.Space = userInfo.TotalSpace
|
||||
cks.LeftSpace = leftSpaceBytes
|
||||
cks.UsedSpace = userInfo.UsedSpace
|
||||
cks.IsValid = userInfo.VIPStatus // 根据VIP状态更新有效性
|
||||
// cks.IsValid = userInfo.VIPStatus // 根据VIP状态更新有效性
|
||||
|
||||
err = repoManager.CksRepository.UpdateWithAllFields(cks)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/go-resty/resty/v2"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -94,6 +98,87 @@ func CreateHotDrama(c *gin.Context) {
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// GetPosterImage 获取海报图片代理
|
||||
func GetPosterImage(c *gin.Context) {
|
||||
url := c.Query("url")
|
||||
if url == "" {
|
||||
ErrorResponse(c, "图片URL不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 简单的URL验证
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
ErrorResponse(c, "无效的图片URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查If-Modified-Since头,实现条件请求
|
||||
ifModifiedSince := c.GetHeader("If-Modified-Since")
|
||||
if ifModifiedSince != "" {
|
||||
// 如果存在,说明浏览器有缓存,检查是否过期
|
||||
ifLastModified, err := time.Parse("Mon, 02 Jan 2006 15:04:05 GMT", ifModifiedSince)
|
||||
if err == nil && time.Since(ifLastModified) < 86400*time.Second { // 24小时内
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查ETag头 - 基于URL生成,保证相同URL有相同ETag
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
if ifNoneMatch != "" {
|
||||
etag := fmt.Sprintf(`"%x"`, len(url)) // 简单的基于URL长度的ETag
|
||||
if ifNoneMatch == etag {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
client := resty.New().
|
||||
SetTimeout(30 * time.Second).
|
||||
SetRetryCount(2).
|
||||
SetRetryWaitTime(1 * time.Second)
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeaders(map[string]string{
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Referer": "https://m.douban.com/",
|
||||
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
}).
|
||||
Get(url)
|
||||
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取图片失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.StatusCode() != 200 {
|
||||
ErrorResponse(c, fmt.Sprintf("获取图片失败,状态码: %d", resp.StatusCode()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
contentType := resp.Header().Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "image/jpeg"
|
||||
}
|
||||
c.Header("Content-Type", contentType)
|
||||
|
||||
// 增强缓存策略
|
||||
c.Header("Cache-Control", "public, max-age=604800, s-maxage=86400") // 客户端7天,代理1天
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
|
||||
|
||||
// 设置缓存验证头(基于URL长度生成的简单ETag)
|
||||
etag := fmt.Sprintf(`"%x"`, len(url))
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Last-Modified", time.Now().Add(-86400*time.Second).Format("Mon, 02 Jan 2006 15:04:05 GMT")) // 设为1天前,避免立即过期
|
||||
|
||||
// 返回图片数据
|
||||
c.Data(resp.StatusCode(), contentType, resp.Body())
|
||||
}
|
||||
|
||||
// UpdateHotDrama 更新热播剧记录
|
||||
func UpdateHotDrama(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
@@ -149,6 +234,7 @@ func GetHotDramaList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
category := c.Query("category")
|
||||
subType := c.Query("sub_type")
|
||||
|
||||
var dramas []entity.HotDrama
|
||||
var total int64
|
||||
@@ -156,13 +242,17 @@ func GetHotDramaList(c *gin.Context) {
|
||||
|
||||
// 如果page_size很大(比如>=1000),则获取所有数据
|
||||
if pageSize >= 1000 {
|
||||
if category != "" {
|
||||
if category != "" && subType != "" {
|
||||
dramas, total, err = repoManager.HotDramaRepository.FindByCategoryAndSubType(category, subType, 1, 10000)
|
||||
} else if category != "" {
|
||||
dramas, total, err = repoManager.HotDramaRepository.FindByCategory(category, 1, 10000)
|
||||
} else {
|
||||
dramas, total, err = repoManager.HotDramaRepository.FindAll(1, 10000)
|
||||
}
|
||||
} else {
|
||||
if category != "" {
|
||||
if category != "" && subType != "" {
|
||||
dramas, total, err = repoManager.HotDramaRepository.FindByCategoryAndSubType(category, subType, page, pageSize)
|
||||
} else if category != "" {
|
||||
dramas, total, err = repoManager.HotDramaRepository.FindByCategory(category, page, pageSize)
|
||||
} else {
|
||||
dramas, total, err = repoManager.HotDramaRepository.FindAll(page, pageSize)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
@@ -69,6 +70,53 @@ func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]
|
||||
return filteredResources, uniqueForbiddenWords
|
||||
}
|
||||
|
||||
// logAPIAccess 记录API访问日志
|
||||
func (h *PublicAPIHandler) logAPIAccess(c *gin.Context, startTime time.Time, processCount int, responseData interface{}, errorMessage string) {
|
||||
endpoint := c.Request.URL.Path
|
||||
method := c.Request.Method
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
// 计算处理时间
|
||||
processingTime := time.Since(startTime).Milliseconds()
|
||||
|
||||
// 获取查询参数
|
||||
var requestParams interface{}
|
||||
if method == "GET" {
|
||||
requestParams = c.Request.URL.Query()
|
||||
} else {
|
||||
// 对于POST请求,尝试从上下文中获取请求体(如果之前已解析)
|
||||
if req, exists := c.Get("request_body"); exists {
|
||||
requestParams = req
|
||||
}
|
||||
}
|
||||
|
||||
// 异步记录日志,避免影响API响应时间
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("记录API访问日志时发生panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := repoManager.APIAccessLogRepository.RecordAccess(
|
||||
ip,
|
||||
userAgent,
|
||||
endpoint,
|
||||
method,
|
||||
requestParams,
|
||||
c.Writer.Status(),
|
||||
responseData,
|
||||
processCount,
|
||||
errorMessage,
|
||||
processingTime,
|
||||
)
|
||||
if err != nil {
|
||||
utils.Error("记录API访问日志失败: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AddBatchResources godoc
|
||||
// @Summary 批量添加资源
|
||||
// @Description 通过公开API批量添加多个资源到待处理列表
|
||||
@@ -83,12 +131,18 @@ func (h *PublicAPIHandler) filterForbiddenWords(resources []entity.Resource) ([]
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/resources/batch-add [post]
|
||||
func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
var req dto.BatchReadyResourceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logAPIAccess(c, startTime, 0, nil, "请求参数错误: "+err.Error())
|
||||
ErrorResponse(c, "请求参数错误: "+err.Error(), 400)
|
||||
return
|
||||
}
|
||||
|
||||
// 存储请求体用于日志记录
|
||||
c.Set("request_body", req)
|
||||
|
||||
if len(req.Resources) == 0 {
|
||||
ErrorResponse(c, "资源列表不能为空", 400)
|
||||
return
|
||||
@@ -125,6 +179,7 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
// 生成 key(每组同一个 key)
|
||||
key, err := repoManager.ReadyResourceRepository.GenerateUniqueKey()
|
||||
if err != nil {
|
||||
h.logAPIAccess(c, startTime, len(createdResources), nil, "生成资源组标识失败: "+err.Error())
|
||||
ErrorResponse(c, "生成资源组标识失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
@@ -156,10 +211,12 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
responseData := gin.H{
|
||||
"created_count": len(createdResources),
|
||||
"created_ids": createdResources,
|
||||
})
|
||||
}
|
||||
h.logAPIAccess(c, startTime, len(createdResources), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// SearchResources godoc
|
||||
@@ -179,6 +236,8 @@ func (h *PublicAPIHandler) AddBatchResources(c *gin.Context) {
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/resources/search [get]
|
||||
func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
// 获取查询参数
|
||||
keyword := c.Query("keyword")
|
||||
tag := c.Query("tag")
|
||||
@@ -236,6 +295,7 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
FileSize: doc.FileSize,
|
||||
Key: doc.Key,
|
||||
PanID: doc.PanID,
|
||||
Cover: doc.Cover,
|
||||
CreatedAt: doc.CreatedAt,
|
||||
UpdatedAt: doc.UpdatedAt,
|
||||
}
|
||||
@@ -276,47 +336,53 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
// 执行数据库搜索
|
||||
resources, total, err = repoManager.ResourceRepository.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
h.logAPIAccess(c, startTime, 0, nil, "搜索失败: "+err.Error())
|
||||
ErrorResponse(c, "搜索失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤违禁词
|
||||
filteredResources, foundForbiddenWords := h.filterForbiddenWords(resources)
|
||||
// 获取违禁词配置(只获取一次)
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||
}
|
||||
|
||||
// 计算过滤后的总数
|
||||
filteredTotal := len(filteredResources)
|
||||
|
||||
// 转换为响应格式
|
||||
// 转换为响应格式并添加违禁词标记
|
||||
var resourceResponses []gin.H
|
||||
for _, resource := range filteredResources {
|
||||
resourceResponses = append(resourceResponses, gin.H{
|
||||
"id": resource.ID,
|
||||
"title": resource.Title,
|
||||
"url": resource.URL,
|
||||
"description": resource.Description,
|
||||
"view_count": resource.ViewCount,
|
||||
"created_at": resource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": resource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
for i, processedResource := range resources {
|
||||
originalResource := resources[i]
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": processedResource.ID,
|
||||
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
|
||||
"url": processedResource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
|
||||
"view_count": processedResource.ViewCount,
|
||||
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
"cover": processedResource.Cover, // 添加封面字段
|
||||
}
|
||||
|
||||
// 添加违禁词标记
|
||||
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
// 构建响应数据
|
||||
responseData := gin.H{
|
||||
"data": resourceResponses,
|
||||
"total": filteredTotal,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
}
|
||||
|
||||
// 如果存在违禁词过滤,添加提醒字段
|
||||
if len(foundForbiddenWords) > 0 {
|
||||
responseData["forbidden_words_filtered"] = true
|
||||
responseData["filtered_forbidden_words"] = foundForbiddenWords
|
||||
responseData["original_total"] = total
|
||||
responseData["filtered_count"] = total - int64(filteredTotal)
|
||||
"list": resourceResponses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": pageSize,
|
||||
}
|
||||
|
||||
h.logAPIAccess(c, startTime, len(resourceResponses), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
@@ -334,6 +400,8 @@ func (h *PublicAPIHandler) SearchResources(c *gin.Context) {
|
||||
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
|
||||
// @Router /api/public/hot-dramas [get]
|
||||
func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
startTime := time.Now()
|
||||
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "20")
|
||||
|
||||
@@ -350,6 +418,7 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
// 获取热门剧
|
||||
hotDramas, total, err := repoManager.HotDramaRepository.FindAll(page, pageSize)
|
||||
if err != nil {
|
||||
h.logAPIAccess(c, startTime, 0, nil, "获取热门剧失败: "+err.Error())
|
||||
ErrorResponse(c, "获取热门剧失败: "+err.Error(), 500)
|
||||
return
|
||||
}
|
||||
@@ -373,10 +442,12 @@ func (h *PublicAPIHandler) GetHotDramas(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
responseData := gin.H{
|
||||
"hot_dramas": hotDramaResponses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
h.logAPIAccess(c, startTime, len(hotDramaResponses), responseData, "")
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
@@ -64,9 +64,17 @@ func GetResources(c *gin.Context) {
|
||||
params["pan_name"] = panName
|
||||
}
|
||||
|
||||
// 获取违禁词配置(只获取一次)
|
||||
cleanWords, err := utils.GetForbiddenWordsFromConfig(func() (string, error) {
|
||||
return repoManager.SystemConfigRepository.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("获取违禁词配置失败: %v", err)
|
||||
cleanWords = []string{} // 如果获取失败,使用空列表
|
||||
}
|
||||
|
||||
var resources []entity.Resource
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
// 如果有搜索关键词且启用了Meilisearch,优先使用Meilisearch搜索
|
||||
if search := c.Query("search"); search != "" && meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||
@@ -84,10 +92,25 @@ func GetResources(c *gin.Context) {
|
||||
if service != nil {
|
||||
docs, docTotal, err := service.Search(search, filters, page, pageSize)
|
||||
if err == nil {
|
||||
// 将Meilisearch文档转换为ResourceResponse(包含高亮信息)
|
||||
|
||||
// 将Meilisearch文档转换为ResourceResponse(包含高亮信息)并处理违禁词
|
||||
var resourceResponses []dto.ResourceResponse
|
||||
for _, doc := range docs {
|
||||
resourceResponse := converter.ToResourceResponseFromMeilisearch(doc)
|
||||
|
||||
// 处理违禁词(Meilisearch场景,需要处理高亮标记)
|
||||
if len(cleanWords) > 0 {
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(resourceResponse.Title, resourceResponse.Description, cleanWords)
|
||||
if forbiddenInfo.HasForbiddenWords {
|
||||
resourceResponse.Title = forbiddenInfo.ProcessedTitle
|
||||
resourceResponse.Description = forbiddenInfo.ProcessedDesc
|
||||
resourceResponse.TitleHighlight = forbiddenInfo.ProcessedTitle
|
||||
resourceResponse.DescriptionHighlight = forbiddenInfo.ProcessedDesc
|
||||
}
|
||||
resourceResponse.HasForbiddenWords = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse.ForbiddenWords = forbiddenInfo.ForbiddenWords
|
||||
}
|
||||
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
@@ -116,12 +139,69 @@ func GetResources(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"data": converter.ToResourceResponseList(resources),
|
||||
// 处理违禁词替换和标记
|
||||
var processedResources []entity.Resource
|
||||
if len(cleanWords) > 0 {
|
||||
processedResources = utils.ProcessResourcesForbiddenWords(resources, cleanWords)
|
||||
// 复制标签数据到处理后的资源
|
||||
for i := range processedResources {
|
||||
if i < len(resources) {
|
||||
processedResources[i].Tags = resources[i].Tags
|
||||
}
|
||||
}
|
||||
} else {
|
||||
processedResources = resources
|
||||
}
|
||||
|
||||
// 转换为响应格式并添加违禁词标记
|
||||
var resourceResponses []gin.H
|
||||
for i, processedResource := range processedResources {
|
||||
// 使用原始资源进行检查违禁词(数据库搜索场景,使用普通处理)
|
||||
originalResource := resources[i]
|
||||
forbiddenInfo := utils.CheckResourceForbiddenWords(originalResource.Title, originalResource.Description, cleanWords)
|
||||
|
||||
resourceResponse := gin.H{
|
||||
"id": processedResource.ID,
|
||||
"title": forbiddenInfo.ProcessedTitle, // 使用处理后的标题
|
||||
"url": processedResource.URL,
|
||||
"description": forbiddenInfo.ProcessedDesc, // 使用处理后的描述
|
||||
"pan_id": processedResource.PanID,
|
||||
"view_count": processedResource.ViewCount,
|
||||
"created_at": processedResource.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"updated_at": processedResource.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
// 添加违禁词标记
|
||||
resourceResponse["has_forbidden_words"] = forbiddenInfo.HasForbiddenWords
|
||||
resourceResponse["forbidden_words"] = forbiddenInfo.ForbiddenWords
|
||||
|
||||
// 添加标签信息(需要预加载)
|
||||
var tagResponses []gin.H
|
||||
if len(processedResource.Tags) > 0 {
|
||||
for _, tag := range processedResource.Tags {
|
||||
tagResponse := gin.H{
|
||||
"id": tag.ID,
|
||||
"name": tag.Name,
|
||||
"description": tag.Description,
|
||||
}
|
||||
tagResponses = append(tagResponses, tagResponse)
|
||||
}
|
||||
}
|
||||
resourceResponse["tags"] = tagResponses
|
||||
resourceResponse["cover"] = originalResource.Cover
|
||||
|
||||
resourceResponses = append(resourceResponses, resourceResponse)
|
||||
}
|
||||
|
||||
// 构建响应数据
|
||||
responseData := gin.H{
|
||||
"data": resourceResponses,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
SuccessResponse(c, responseData)
|
||||
}
|
||||
|
||||
// GetResourceByID 根据ID获取资源
|
||||
@@ -210,7 +290,7 @@ func CreateResource(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 同步到Meilisearch
|
||||
if meilisearchManager != nil {
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||
go func() {
|
||||
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
|
||||
utils.Error("同步资源到Meilisearch失败: %v", err)
|
||||
@@ -295,7 +375,7 @@ func UpdateResource(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 同步到Meilisearch
|
||||
if meilisearchManager != nil {
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||
go func() {
|
||||
if err := meilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
|
||||
utils.Error("同步资源到Meilisearch失败: %v", err)
|
||||
@@ -315,12 +395,29 @@ func DeleteResource(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 先从数据库中删除资源
|
||||
err = repoManager.ResourceRepository.Delete(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果启用了Meilisearch,尝试从Meilisearch中删除对应数据
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() {
|
||||
go func() {
|
||||
service := meilisearchManager.GetService()
|
||||
if service != nil {
|
||||
if err := service.DeleteDocument(uint(id)); err != nil {
|
||||
utils.Error("从Meilisearch删除资源失败 (ID: %d): %v", uint(id), err)
|
||||
} else {
|
||||
utils.Info("成功从Meilisearch删除资源 (ID: %d)", uint(id))
|
||||
}
|
||||
} else {
|
||||
utils.Error("无法获取Meilisearch服务进行资源删除 (ID: %d)", uint(id))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"message": "资源删除成功"})
|
||||
}
|
||||
|
||||
@@ -433,12 +530,39 @@ func BatchDeleteResources(c *gin.Context) {
|
||||
ErrorResponse(c, "参数错误", 400)
|
||||
return
|
||||
}
|
||||
|
||||
count := 0
|
||||
var deletedIDs []uint
|
||||
|
||||
// 先删除数据库中的资源
|
||||
for _, id := range req.IDs {
|
||||
if err := repoManager.ResourceRepository.Delete(id); err == nil {
|
||||
count++
|
||||
deletedIDs = append(deletedIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了Meilisearch,异步删除对应的搜索数据
|
||||
if meilisearchManager != nil && meilisearchManager.IsEnabled() && len(deletedIDs) > 0 {
|
||||
go func() {
|
||||
service := meilisearchManager.GetService()
|
||||
if service != nil {
|
||||
deletedCount := 0
|
||||
for _, id := range deletedIDs {
|
||||
if err := service.DeleteDocument(id); err != nil {
|
||||
utils.Error("从Meilisearch批量删除资源失败 (ID: %d): %v", id, err)
|
||||
} else {
|
||||
deletedCount++
|
||||
utils.Info("成功从Meilisearch批量删除资源 (ID: %d)", id)
|
||||
}
|
||||
}
|
||||
utils.Info("批量删除完成:成功删除 %d 个资源,Meilisearch删除 %d 个资源", count, deletedCount)
|
||||
} else {
|
||||
utils.Error("批量删除时无法获取Meilisearch服务")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{"deleted": count, "message": "批量删除成功"})
|
||||
}
|
||||
|
||||
@@ -490,8 +614,8 @@ func GetResourceLink(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 如果不是夸克网盘,直接返回原链接
|
||||
if panInfo.Name != "quark" {
|
||||
utils.Info("非夸克资源,直接返回原链接")
|
||||
if panInfo.Name != "quark" && panInfo.Name != "xunlei" {
|
||||
utils.Info("非夸克和迅雷资源,直接返回原链接")
|
||||
SuccessResponse(c, gin.H{
|
||||
"url": resource.URL,
|
||||
"type": "original",
|
||||
@@ -501,9 +625,6 @@ func GetResourceLink(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 夸克资源处理逻辑
|
||||
utils.Info("夸克资源处理开始")
|
||||
|
||||
// 如果已存在转存链接,直接返回
|
||||
if resource.SaveURL != "" {
|
||||
utils.Info("已存在转存链接,直接返回: %s", resource.SaveURL)
|
||||
@@ -571,6 +692,7 @@ func GetResourceLink(c *gin.Context) {
|
||||
// TransferResult 转存结果
|
||||
type TransferResult struct {
|
||||
Success bool `json:"success"`
|
||||
Fid string `json:"fid"`
|
||||
SaveURL string `json:"save_url"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
@@ -579,18 +701,11 @@ type TransferResult struct {
|
||||
func performAutoTransfer(resource *entity.Resource) TransferResult {
|
||||
utils.Info("开始执行资源转存 - ID: %d, URL: %s", resource.ID, resource.URL)
|
||||
|
||||
// 获取夸克平台ID
|
||||
quarkPanID, err := getQuarkPanID()
|
||||
if err != nil {
|
||||
utils.Error("获取夸克平台ID失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("获取夸克平台ID失败: %v", err),
|
||||
}
|
||||
}
|
||||
// 平台ID
|
||||
panID := resource.PanID
|
||||
|
||||
// 获取可用的夸克账号
|
||||
accounts, err := repoManager.CksRepository.FindAll()
|
||||
accounts, err := repoManager.CksRepository.FindByPanID(*panID)
|
||||
if err != nil {
|
||||
utils.Error("获取网盘账号失败: %v", err)
|
||||
return TransferResult{
|
||||
@@ -599,6 +714,7 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试阶段,移除最小限制
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := repoManager.SystemConfigRepository.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
@@ -610,23 +726,24 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
var validAccounts []entity.Cks
|
||||
for _, acc := range accounts {
|
||||
if acc.IsValid && acc.PanID == quarkPanID && acc.LeftSpace >= minSpaceBytes {
|
||||
if acc.IsValid && acc.PanID == *panID && acc.LeftSpace >= minSpaceBytes {
|
||||
validAccounts = append(validAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
utils.Info("没有可用的夸克网盘账号")
|
||||
utils.Info("没有可用的网盘账号")
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "没有可用的夸克网盘账号",
|
||||
ErrorMsg: "没有可用的网盘账号",
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("找到 %d 个可用夸克网盘账号,开始转存处理...", len(validAccounts))
|
||||
utils.Info("找到 %d 个可用网盘账号,开始转存处理...", len(validAccounts))
|
||||
|
||||
// 使用第一个可用账号进行转存
|
||||
account := validAccounts[0]
|
||||
// account := accounts[0]
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
@@ -637,6 +754,8 @@ func performAutoTransfer(resource *entity.Resource) TransferResult {
|
||||
if result.Success {
|
||||
// 更新资源的转存信息
|
||||
resource.SaveURL = result.SaveURL
|
||||
resource.Fid = result.Fid
|
||||
resource.CkID = &account.ID
|
||||
resource.ErrorMsg = ""
|
||||
if err := repoManager.ResourceRepository.Update(resource); err != nil {
|
||||
utils.Error("更新资源转存信息失败: %v", err)
|
||||
@@ -670,6 +789,9 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
|
||||
}
|
||||
}
|
||||
|
||||
// 设置账号信息
|
||||
service.SetCKSRepository(repoManager.CksRepository, account)
|
||||
|
||||
// 提取分享ID
|
||||
shareID, _ := commonutils.ExtractShareIdString(resource.URL)
|
||||
if shareID == "" {
|
||||
@@ -680,7 +802,7 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
|
||||
}
|
||||
|
||||
// 执行转存
|
||||
transferResult, err := service.Transfer(shareID)
|
||||
transferResult, err := service.Transfer(shareID) // 有些链接还需要其他信息从 url 中自行解析
|
||||
if err != nil {
|
||||
utils.Error("转存失败: %v", err)
|
||||
return TransferResult{
|
||||
@@ -703,10 +825,15 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
|
||||
|
||||
// 提取转存链接
|
||||
var saveURL string
|
||||
var fid string
|
||||
|
||||
if data, ok := transferResult.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
if v, ok := data["fid"]; ok {
|
||||
fid, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = transferResult.ShareURL
|
||||
@@ -724,6 +851,7 @@ func transferSingleResource(resource *entity.Resource, account entity.Cks, facto
|
||||
return TransferResult{
|
||||
Success: true,
|
||||
SaveURL: saveURL,
|
||||
Fid: fid,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ func GetSchedulerStatus(c *gin.Context) {
|
||||
status := gin.H{
|
||||
"hot_drama_scheduler_running": scheduler.IsHotDramaSchedulerRunning(),
|
||||
"ready_resource_scheduler_running": scheduler.IsReadyResourceRunning(),
|
||||
"auto_transfer_scheduler_running": scheduler.IsAutoTransferRunning(),
|
||||
}
|
||||
|
||||
SuccessResponse(c, status)
|
||||
@@ -160,59 +159,3 @@ func TriggerReadyResourceScheduler(c *gin.Context) {
|
||||
scheduler.StartReadyResourceScheduler() // 直接启动一次
|
||||
SuccessResponse(c, gin.H{"message": "手动触发待处理资源自动处理任务成功"})
|
||||
}
|
||||
|
||||
// 启动自动转存定时任务
|
||||
func StartAutoTransferScheduler(c *gin.Context) {
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
if scheduler.IsAutoTransferRunning() {
|
||||
ErrorResponse(c, "自动转存定时任务已在运行中", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
scheduler.StartAutoTransferScheduler()
|
||||
SuccessResponse(c, gin.H{"message": "自动转存定时任务已启动"})
|
||||
}
|
||||
|
||||
// 停止自动转存定时任务
|
||||
func StopAutoTransferScheduler(c *gin.Context) {
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
if !scheduler.IsAutoTransferRunning() {
|
||||
ErrorResponse(c, "自动转存定时任务未在运行", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
scheduler.StopAutoTransferScheduler()
|
||||
SuccessResponse(c, gin.H{"message": "自动转存定时任务已停止"})
|
||||
}
|
||||
|
||||
// 手动触发自动转存定时任务
|
||||
func TriggerAutoTransferScheduler(c *gin.Context) {
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
scheduler.StartAutoTransferScheduler() // 直接启动一次
|
||||
SuccessResponse(c, gin.H{"message": "手动触发自动转存定时任务成功"})
|
||||
}
|
||||
|
||||
@@ -22,16 +22,20 @@ func GetStats(c *gin.Context) {
|
||||
db.DB.Model(&entity.Tag{}).Count(&totalTags)
|
||||
db.DB.Model(&entity.Resource{}).Select("COALESCE(SUM(view_count), 0)").Scan(&totalViews)
|
||||
|
||||
// 获取今日数据
|
||||
today := utils.GetTodayString()
|
||||
// 获取今日数据(在UTC+8时区的0点开始统计)
|
||||
now := utils.GetCurrentTime()
|
||||
// 使用UTC+8时区的今天0点
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai") // UTC+8
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
endOfToday := startOfToday.Add(24 * time.Hour)
|
||||
|
||||
// 今日新增资源数量
|
||||
// 今日新增资源数量(从0点开始)
|
||||
var todayResources int64
|
||||
db.DB.Model(&entity.Resource{}).Where("DATE(created_at) = ?", today).Count(&todayResources)
|
||||
db.DB.Model(&entity.Resource{}).Where("created_at >= ? AND created_at < ?", startOfToday, endOfToday).Count(&todayResources)
|
||||
|
||||
// 今日更新资源数量(包括新增和修改)
|
||||
// 今日更新资源数量(包括新增和修改,从0点开始)
|
||||
var todayUpdates int64
|
||||
db.DB.Model(&entity.Resource{}).Where("DATE(updated_at) = ?", today).Count(&todayUpdates)
|
||||
db.DB.Model(&entity.Resource{}).Where("updated_at >= ? AND updated_at < ?", startOfToday, endOfToday).Count(&todayUpdates)
|
||||
|
||||
// 今日浏览量 - 使用访问记录表统计今日访问量
|
||||
var todayViews int64
|
||||
@@ -41,9 +45,9 @@ func GetStats(c *gin.Context) {
|
||||
todayViews = 0
|
||||
}
|
||||
|
||||
// 今日搜索量
|
||||
// 今日搜索量(从0点开始)
|
||||
var todaySearches int64
|
||||
db.DB.Model(&entity.SearchStat{}).Where("DATE(date) = ?", today).Count(&todaySearches)
|
||||
db.DB.Model(&entity.SearchStat{}).Where("date >= ? AND date < ?", startOfToday.Format(utils.TimeFormatDate), endOfToday.Format(utils.TimeFormatDate)).Count(&todaySearches)
|
||||
|
||||
// 添加调试日志
|
||||
utils.Info("统计数据 - 总资源: %d, 总分类: %d, 总标签: %d, 总浏览量: %d",
|
||||
|
||||
@@ -125,6 +125,7 @@ func GetSystemConfig(c *gin.Context) {
|
||||
func UpdateSystemConfig(c *gin.Context) {
|
||||
var req dto.SystemConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Error("JSON绑定失败: %v", err)
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -141,30 +142,52 @@ func UpdateSystemConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 验证参数 - 只验证提交的字段
|
||||
if req.SiteTitle != nil && (len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100) {
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
utils.Info("开始验证参数")
|
||||
if req.SiteTitle != nil {
|
||||
utils.Info("验证SiteTitle: '%s', 长度: %d", *req.SiteTitle, len(*req.SiteTitle))
|
||||
if len(*req.SiteTitle) < 1 || len(*req.SiteTitle) > 100 {
|
||||
ErrorResponse(c, "网站标题长度必须在1-100字符之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.AutoProcessInterval != nil && (*req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440) {
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
if req.AutoProcessInterval != nil {
|
||||
utils.Info("验证AutoProcessInterval: %d", *req.AutoProcessInterval)
|
||||
if *req.AutoProcessInterval < 1 || *req.AutoProcessInterval > 1440 {
|
||||
ErrorResponse(c, "自动处理间隔必须在1-1440分钟之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.PageSize != nil && (*req.PageSize < 10 || *req.PageSize > 500) {
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
if req.PageSize != nil {
|
||||
utils.Info("验证PageSize: %d", *req.PageSize)
|
||||
if *req.PageSize < 10 || *req.PageSize > 500 {
|
||||
ErrorResponse(c, "每页显示数量必须在10-500之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证自动转存配置
|
||||
if req.AutoTransferLimitDays != nil && (*req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365) {
|
||||
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||
return
|
||||
if req.AutoTransferLimitDays != nil {
|
||||
utils.Info("验证AutoTransferLimitDays: %d", *req.AutoTransferLimitDays)
|
||||
if *req.AutoTransferLimitDays < 0 || *req.AutoTransferLimitDays > 365 {
|
||||
ErrorResponse(c, "自动转存限制天数必须在0-365之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if req.AutoTransferMinSpace != nil && (*req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024) {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
if req.AutoTransferMinSpace != nil {
|
||||
utils.Info("验证AutoTransferMinSpace: %d", *req.AutoTransferMinSpace)
|
||||
if *req.AutoTransferMinSpace < 100 || *req.AutoTransferMinSpace > 1024 {
|
||||
ErrorResponse(c, "最小存储空间必须在100-1024GB之间", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 验证公告相关字段
|
||||
if req.Announcements != nil {
|
||||
utils.Info("验证Announcements: '%s'", *req.Announcements)
|
||||
// 可以在这里添加更详细的验证逻辑
|
||||
}
|
||||
|
||||
// 转换为实体
|
||||
@@ -322,6 +345,12 @@ func ToggleAutoProcess(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确保配置缓存已刷新
|
||||
if err := repoManager.SystemConfigRepository.SafeRefreshConfigCache(); err != nil {
|
||||
utils.Error("刷新配置缓存失败: %v", err)
|
||||
// 不返回错误,因为配置已经保存成功
|
||||
}
|
||||
|
||||
// 更新定时任务状态
|
||||
scheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -50,7 +51,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("创建批量转存任务: %s,资源数量: %d,选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
|
||||
utils.Debug("创建批量转存任务: %s,资源数量: %d,选择账号数量: %d", req.Title, len(req.Resources), len(req.SelectedAccounts))
|
||||
|
||||
// 构建任务配置
|
||||
taskConfig := map[string]interface{}{
|
||||
@@ -105,7 +106,7 @@ func (h *TaskHandler) CreateBatchTransferTask(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
|
||||
utils.Debug("批量转存任务创建完成: %d, 共 %d 项", newTask.ID, len(req.Resources))
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"task_id": newTask.ID,
|
||||
@@ -123,8 +124,6 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("启动任务: %d", taskID)
|
||||
|
||||
err = h.taskManager.StartTask(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("启动任务失败: %v", err)
|
||||
@@ -132,6 +131,8 @@ func (h *TaskHandler) StartTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Debug("启动任务: %d", taskID)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务启动成功",
|
||||
})
|
||||
@@ -146,8 +147,6 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("停止任务: %d", taskID)
|
||||
|
||||
err = h.taskManager.StopTask(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("停止任务失败: %v", err)
|
||||
@@ -155,6 +154,8 @@ func (h *TaskHandler) StopTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Debug("停止任务: %d", taskID)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务停止成功",
|
||||
})
|
||||
@@ -169,8 +170,6 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("暂停任务: %d", taskID)
|
||||
|
||||
err = h.taskManager.PauseTask(uint(taskID))
|
||||
if err != nil {
|
||||
utils.Error("暂停任务失败: %v", err)
|
||||
@@ -178,6 +177,8 @@ func (h *TaskHandler) PauseTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Debug("暂停任务: %d", taskID)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务暂停成功",
|
||||
})
|
||||
@@ -234,13 +235,25 @@ func (h *TaskHandler) GetTaskStatus(c *gin.Context) {
|
||||
|
||||
// GetTasks 获取任务列表
|
||||
func (h *TaskHandler) GetTasks(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
taskType := c.Query("task_type")
|
||||
// 获取查询参数
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("pageSize", "10")
|
||||
taskType := c.Query("taskType")
|
||||
status := c.Query("status")
|
||||
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
pageSize, err := strconv.Atoi(pageSizeStr)
|
||||
if err != nil || pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
utils.Debug("GetTasks: 获取任务列表 page=%d, pageSize=%d, taskType=%s, status=%s", page, pageSize, taskType, status)
|
||||
|
||||
// 获取任务列表
|
||||
tasks, total, err := h.repoMgr.TaskRepository.GetList(page, pageSize, taskType, status)
|
||||
if err != nil {
|
||||
utils.Error("获取任务列表失败: %v", err)
|
||||
@@ -250,17 +263,17 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
|
||||
|
||||
utils.Debug("GetTasks: 从数据库获取到 %d 个任务", len(tasks))
|
||||
|
||||
// 为每个任务添加运行状态
|
||||
var result []gin.H
|
||||
// 获取任务运行状态
|
||||
var taskList []gin.H
|
||||
for _, task := range tasks {
|
||||
isRunning := h.taskManager.IsTaskRunning(task.ID)
|
||||
utils.Debug("GetTasks: 任务 %d (%s) 数据库状态: %s, TaskManager运行状态: %v", task.ID, task.Title, task.Status, isRunning)
|
||||
|
||||
result = append(result, gin.H{
|
||||
taskList = append(taskList, gin.H{
|
||||
"id": task.ID,
|
||||
"title": task.Title,
|
||||
"description": task.Description,
|
||||
"task_type": task.Type,
|
||||
"type": task.Type,
|
||||
"status": task.Status,
|
||||
"total_items": task.TotalItems,
|
||||
"processed_items": task.ProcessedItems,
|
||||
@@ -273,10 +286,11 @@ func (h *TaskHandler) GetTasks(c *gin.Context) {
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": pageSize,
|
||||
"tasks": taskList,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -348,7 +362,7 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
|
||||
|
||||
// 检查任务是否在运行
|
||||
if h.taskManager.IsTaskRunning(uint(taskID)) {
|
||||
ErrorResponse(c, "任务正在运行,请先停止任务", http.StatusBadRequest)
|
||||
ErrorResponse(c, "任务正在运行中,无法删除", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -368,8 +382,238 @@ func (h *TaskHandler) DeleteTask(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("任务删除成功: %d", taskID)
|
||||
utils.Debug("任务删除成功: %d", taskID)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"message": "任务删除成功",
|
||||
})
|
||||
}
|
||||
|
||||
// CreateExpansionTask 创建扩容任务
|
||||
func (h *TaskHandler) CreateExpansionTask(c *gin.Context) {
|
||||
var req struct {
|
||||
PanAccountID uint `json:"pan_account_id" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
DataSource map[string]interface{} `json:"dataSource"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "参数错误: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Debug("创建扩容任务: 账号ID %d", req.PanAccountID)
|
||||
|
||||
// 获取账号信息,用于构建任务标题
|
||||
cks, err := h.repoMgr.CksRepository.FindByID(req.PanAccountID)
|
||||
if err != nil {
|
||||
utils.Error("获取账号信息失败: %v", err)
|
||||
ErrorResponse(c, "获取账号信息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建账号名称
|
||||
accountName := cks.Username
|
||||
if accountName == "" {
|
||||
accountName = cks.Remark
|
||||
}
|
||||
if accountName == "" {
|
||||
accountName = fmt.Sprintf("账号%d", cks.ID)
|
||||
}
|
||||
|
||||
// 构建任务配置(存储账号ID和数据源)
|
||||
taskConfig := map[string]interface{}{
|
||||
"pan_account_id": req.PanAccountID,
|
||||
}
|
||||
// 如果有数据源配置,添加到taskConfig中
|
||||
if req.DataSource != nil && len(req.DataSource) > 0 {
|
||||
taskConfig["data_source"] = req.DataSource
|
||||
}
|
||||
configJSON, _ := json.Marshal(taskConfig)
|
||||
|
||||
// 创建任务标题,包含账号名称
|
||||
taskTitle := fmt.Sprintf("账号扩容 - %s", accountName)
|
||||
|
||||
// 创建任务
|
||||
newTask := &entity.Task{
|
||||
Title: taskTitle,
|
||||
Description: req.Description,
|
||||
Type: "expansion",
|
||||
Status: "pending",
|
||||
TotalItems: 1, // 扩容任务只有一个项目
|
||||
Config: string(configJSON),
|
||||
CreatedAt: utils.GetCurrentTime(),
|
||||
UpdatedAt: utils.GetCurrentTime(),
|
||||
}
|
||||
|
||||
if err := h.repoMgr.TaskRepository.Create(newTask); err != nil {
|
||||
utils.Error("创建扩容任务失败: %v", err)
|
||||
ErrorResponse(c, "创建任务失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建任务项
|
||||
expansionInput := task.ExpansionInput{
|
||||
PanAccountID: req.PanAccountID,
|
||||
}
|
||||
// 如果有数据源配置,添加到输入数据中
|
||||
if req.DataSource != nil && len(req.DataSource) > 0 {
|
||||
expansionInput.DataSource = req.DataSource
|
||||
}
|
||||
|
||||
inputJSON, _ := json.Marshal(expansionInput)
|
||||
|
||||
taskItem := &entity.TaskItem{
|
||||
TaskID: newTask.ID,
|
||||
Status: "pending",
|
||||
InputData: string(inputJSON),
|
||||
CreatedAt: utils.GetCurrentTime(),
|
||||
UpdatedAt: utils.GetCurrentTime(),
|
||||
}
|
||||
|
||||
err = h.repoMgr.TaskItemRepository.Create(taskItem)
|
||||
if err != nil {
|
||||
utils.Error("创建扩容任务项失败: %v", err)
|
||||
// 继续处理,不返回错误
|
||||
}
|
||||
|
||||
utils.Debug("扩容任务创建完成: %d", newTask.ID)
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"task_id": newTask.ID,
|
||||
"total_items": 1,
|
||||
"message": "扩容任务创建成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetExpansionAccounts 获取支持扩容的账号列表
|
||||
func (h *TaskHandler) GetExpansionAccounts(c *gin.Context) {
|
||||
// 获取所有有效的账号
|
||||
cksList, err := h.repoMgr.CksRepository.FindByIsValid(false)
|
||||
if err != nil {
|
||||
utils.Error("获取账号列表失败: %v", err)
|
||||
ErrorResponse(c, "获取账号列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤出 quark 账号
|
||||
var expansionAccounts []gin.H
|
||||
tasks, _, _ := h.repoMgr.TaskRepository.GetList(1, 1000, "expansion", "completed")
|
||||
for _, ck := range cksList {
|
||||
if ck.ServiceType == "quark" {
|
||||
// 使用 Username 作为账号名称,如果为空则使用 Remark
|
||||
accountName := ck.Username
|
||||
if accountName == "" {
|
||||
accountName = ck.Remark
|
||||
}
|
||||
if accountName == "" {
|
||||
accountName = "账号 " + fmt.Sprintf("%d", ck.ID)
|
||||
}
|
||||
|
||||
// 检查是否已经扩容过
|
||||
expanded := false
|
||||
for _, task := range tasks {
|
||||
if task.Config != "" {
|
||||
var taskConfig map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(task.Config), &taskConfig); err == nil {
|
||||
if configAccountID, ok := taskConfig["pan_account_id"].(float64); ok {
|
||||
if uint(configAccountID) == ck.ID {
|
||||
expanded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expansionAccounts = append(expansionAccounts, gin.H{
|
||||
"id": ck.ID,
|
||||
"name": accountName,
|
||||
"service_type": ck.ServiceType,
|
||||
"expanded": expanded,
|
||||
"total_space": ck.Space,
|
||||
"used_space": ck.UsedSpace,
|
||||
"created_at": ck.CreatedAt,
|
||||
"updated_at": ck.UpdatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"accounts": expansionAccounts,
|
||||
"total": len(expansionAccounts),
|
||||
"message": "获取支持扩容账号列表成功",
|
||||
})
|
||||
}
|
||||
|
||||
// GetExpansionOutput 获取账号扩容输出数据
|
||||
func (h *TaskHandler) GetExpansionOutput(c *gin.Context) {
|
||||
accountIDStr := c.Param("accountId")
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的账号ID: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Debug("获取账号扩容输出数据: 账号ID %d", accountID)
|
||||
|
||||
// 获取该账号的所有扩容任务
|
||||
tasks, _, err := h.repoMgr.TaskRepository.GetList(1, 1000, "expansion", "completed")
|
||||
if err != nil {
|
||||
utils.Error("获取扩容任务列表失败: %v", err)
|
||||
ErrorResponse(c, "获取扩容任务列表失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 查找该账号的扩容任务
|
||||
var targetTask *entity.Task
|
||||
for _, task := range tasks {
|
||||
if task.Config != "" {
|
||||
var taskConfig map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(task.Config), &taskConfig); err == nil {
|
||||
if configAccountID, ok := taskConfig["pan_account_id"].(float64); ok {
|
||||
if uint(configAccountID) == uint(accountID) {
|
||||
targetTask = task
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetTask == nil {
|
||||
ErrorResponse(c, "该账号没有完成扩容任务", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取任务项,获取输出数据
|
||||
items, _, err := h.repoMgr.TaskItemRepository.GetListByTaskID(targetTask.ID, 1, 10, "completed")
|
||||
if err != nil {
|
||||
utils.Error("获取任务项失败: %v", err)
|
||||
ErrorResponse(c, "获取任务输出数据失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
ErrorResponse(c, "任务项不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 返回第一个完成的任务项的输出数据
|
||||
taskItem := items[0]
|
||||
var outputData map[string]interface{}
|
||||
if taskItem.OutputData != "" {
|
||||
if err := json.Unmarshal([]byte(taskItem.OutputData), &outputData); err != nil {
|
||||
utils.Error("解析输出数据失败: %v", err)
|
||||
ErrorResponse(c, "解析输出数据失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
SuccessResponse(c, gin.H{
|
||||
"task_id": targetTask.ID,
|
||||
"account_id": accountID,
|
||||
"output_data": outputData,
|
||||
"message": "获取扩容输出数据成功",
|
||||
})
|
||||
}
|
||||
|
||||
516
handlers/telegram_handler.go
Normal file
516
handlers/telegram_handler.go
Normal file
@@ -0,0 +1,516 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ctwj/urldb/db/converter"
|
||||
"github.com/ctwj/urldb/db/dto"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/services"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TelegramHandler Telegram 处理器
|
||||
type TelegramHandler struct {
|
||||
telegramChannelRepo repo.TelegramChannelRepository
|
||||
systemConfigRepo repo.SystemConfigRepository
|
||||
telegramBotService services.TelegramBotService
|
||||
}
|
||||
|
||||
// NewTelegramHandler 创建 Telegram 处理器
|
||||
func NewTelegramHandler(
|
||||
telegramChannelRepo repo.TelegramChannelRepository,
|
||||
systemConfigRepo repo.SystemConfigRepository,
|
||||
telegramBotService services.TelegramBotService,
|
||||
) *TelegramHandler {
|
||||
return &TelegramHandler{
|
||||
telegramChannelRepo: telegramChannelRepo,
|
||||
systemConfigRepo: systemConfigRepo,
|
||||
telegramBotService: telegramBotService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetBotConfig 获取机器人配置
|
||||
func (h *TelegramHandler) GetBotConfig(c *gin.Context) {
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
botConfig := converter.SystemConfigToTelegramBotConfig(configs)
|
||||
SuccessResponse(c, botConfig)
|
||||
}
|
||||
|
||||
// UpdateBotConfig 更新机器人配置
|
||||
func (h *TelegramHandler) UpdateBotConfig(c *gin.Context) {
|
||||
var req dto.TelegramBotConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为系统配置实体
|
||||
configs := converter.TelegramBotConfigRequestToSystemConfigs(req)
|
||||
|
||||
// 保存配置
|
||||
if len(configs) > 0 {
|
||||
err := h.systemConfigRepo.UpsertConfigs(configs)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "保存配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载配置缓存
|
||||
if err := h.systemConfigRepo.SafeRefreshConfigCache(); err != nil {
|
||||
ErrorResponse(c, "刷新配置缓存失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 重新加载机器人服务配置
|
||||
if err := h.telegramBotService.ReloadConfig(); err != nil {
|
||||
ErrorResponse(c, "重新加载机器人配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 配置更新完成后,尝试启动机器人(如果未运行且配置有效)
|
||||
if startErr := h.telegramBotService.Start(); startErr != nil {
|
||||
utils.Warn("[TELEGRAM:HANDLER] 配置更新后尝试启动机器人失败: %v", startErr)
|
||||
// 启动失败不影响配置保存,只记录警告
|
||||
}
|
||||
|
||||
// 返回成功
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "配置更新成功,机器人已尝试启动",
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateApiKey 校验 API Key
|
||||
func (h *TelegramHandler) ValidateApiKey(c *gin.Context) {
|
||||
var req dto.ValidateTelegramApiKeyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果请求中包含代理配置,临时更新服务配置进行校验
|
||||
if req.ProxyEnabled {
|
||||
// 这里只是为了校验,我们不应该修改全局配置
|
||||
// 传递代理配置给服务进行校验
|
||||
valid, botInfo, err := h.telegramBotService.ValidateApiKeyWithProxy(
|
||||
req.ApiKey,
|
||||
req.ProxyEnabled,
|
||||
req.ProxyType,
|
||||
req.ProxyHost,
|
||||
req.ProxyPort,
|
||||
req.ProxyUsername,
|
||||
req.ProxyPassword,
|
||||
)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "校验失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.ValidateTelegramApiKeyResponse{
|
||||
Valid: valid,
|
||||
BotInfo: botInfo,
|
||||
}
|
||||
|
||||
if !valid {
|
||||
response.Error = "无效的 API Key"
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
} else {
|
||||
// 使用默认配置校验
|
||||
valid, botInfo, err := h.telegramBotService.ValidateApiKey(req.ApiKey)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "校验失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.ValidateTelegramApiKeyResponse{
|
||||
Valid: valid,
|
||||
BotInfo: botInfo,
|
||||
}
|
||||
|
||||
if !valid {
|
||||
response.Error = "无效的 API Key"
|
||||
}
|
||||
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
}
|
||||
|
||||
// GetChannels 获取频道列表
|
||||
func (h *TelegramHandler) GetChannels(c *gin.Context) {
|
||||
channels, err := h.telegramChannelRepo.FindAll()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取频道列表失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
channelResponses := converter.TelegramChannelsToResponse(channels)
|
||||
SuccessResponse(c, channelResponses)
|
||||
}
|
||||
|
||||
// CreateChannel 创建频道
|
||||
func (h *TelegramHandler) CreateChannel(c *gin.Context) {
|
||||
var req dto.TelegramChannelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查频道是否已存在
|
||||
existing, err := h.telegramChannelRepo.FindByChatID(req.ChatID)
|
||||
if err == nil && existing != nil {
|
||||
ErrorResponse(c, "该频道/群组已注册", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前用户信息作为注册者
|
||||
username := getCurrentUsername(c) // 需要实现获取用户信息的方法
|
||||
|
||||
channel := converter.RequestToTelegramChannel(req, username)
|
||||
|
||||
if err := h.telegramChannelRepo.Create(&channel); err != nil {
|
||||
ErrorResponse(c, "创建频道失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := converter.TelegramChannelToResponse(channel)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// UpdateChannel 更新频道
|
||||
func (h *TelegramHandler) UpdateChannel(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.TelegramChannelUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("[TELEGRAM:HANDLER] 接收到频道更新请求: ID=%s, ChatName=%s, PushStartTime=%s, PushEndTime=%s, ResourceStrategy=%s, TimeLimit=%s",
|
||||
idStr, req.ChatName, req.PushStartTime, req.PushEndTime, req.ResourceStrategy, req.TimeLimit)
|
||||
|
||||
// 查找现有频道
|
||||
channel, err := h.telegramChannelRepo.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "频道不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 保存前的日志
|
||||
utils.Info("[TELEGRAM:HANDLER] 更新前频道状态: PushStartTime=%s, PushEndTime=%s, ResourceStrategy=%s, TimeLimit=%s",
|
||||
channel.PushStartTime, channel.PushEndTime, channel.ResourceStrategy, channel.TimeLimit)
|
||||
|
||||
// 如果前端传递了ChatID,验证它是否与现有频道匹配
|
||||
if req.ChatID != 0 && req.ChatID != channel.ChatID {
|
||||
ErrorResponse(c, "ChatID不匹配,无法更新此频道", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新频道信息
|
||||
channel.ChatName = req.ChatName
|
||||
channel.ChatType = req.ChatType
|
||||
channel.PushEnabled = req.PushEnabled
|
||||
channel.PushFrequency = req.PushFrequency
|
||||
channel.PushStartTime = req.PushStartTime
|
||||
channel.PushEndTime = req.PushEndTime
|
||||
channel.ContentCategories = req.ContentCategories
|
||||
channel.ContentTags = req.ContentTags
|
||||
channel.IsActive = req.IsActive
|
||||
channel.ResourceStrategy = req.ResourceStrategy
|
||||
channel.TimeLimit = req.TimeLimit
|
||||
|
||||
if err := h.telegramChannelRepo.Update(channel); err != nil {
|
||||
ErrorResponse(c, "更新频道失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 保存后的日志
|
||||
utils.Info("[TELEGRAM:HANDLER] 更新后频道状态: PushStartTime=%s, PushEndTime=%s, ResourceStrategy=%s, TimeLimit=%s",
|
||||
channel.PushStartTime, channel.PushEndTime, channel.ResourceStrategy, channel.TimeLimit)
|
||||
|
||||
response := converter.TelegramChannelToResponse(*channel)
|
||||
SuccessResponse(c, response)
|
||||
}
|
||||
|
||||
// DeleteChannel 删除频道
|
||||
func (h *TelegramHandler) DeleteChannel(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "无效的ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查频道是否存在
|
||||
channel, err := h.telegramChannelRepo.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ErrorResponse(c, "频道不存在", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 删除频道
|
||||
if err := h.telegramChannelRepo.Delete(uint(id)); err != nil {
|
||||
ErrorResponse(c, "删除频道失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "频道 " + channel.ChatName + " 已成功移除",
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterChannelByCommand 通过命令注册频道(供内部调用)
|
||||
func (h *TelegramHandler) RegisterChannelByCommand(chatID int64, chatName, chatType string) error {
|
||||
// 检查是否已注册
|
||||
existing, err := h.telegramChannelRepo.FindByChatID(chatID)
|
||||
if err == nil && existing != nil {
|
||||
// 已存在,返回成功
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建新的频道记录
|
||||
channel := entity.TelegramChannel{
|
||||
ChatID: chatID,
|
||||
ChatName: chatName,
|
||||
ChatType: chatType,
|
||||
PushEnabled: true,
|
||||
PushFrequency: 15, // 默认15分钟
|
||||
PushStartTime: "08:30", // 默认开始时间8:30
|
||||
PushEndTime: "11:30", // 默认结束时间11:30
|
||||
IsActive: true,
|
||||
RegisteredBy: "bot_command",
|
||||
RegisteredAt: time.Now(),
|
||||
ResourceStrategy: "random", // 默认纯随机
|
||||
TimeLimit: "none", // 默认无限制
|
||||
}
|
||||
|
||||
return h.telegramChannelRepo.Create(&channel)
|
||||
}
|
||||
|
||||
// HandleWebhook 处理 Telegram Webhook
|
||||
func (h *TelegramHandler) HandleWebhook(c *gin.Context) {
|
||||
// 将消息交给 bot 服务处理
|
||||
// 这里可以根据需要添加身份验证
|
||||
h.telegramBotService.HandleWebhookUpdate(c)
|
||||
}
|
||||
|
||||
// GetBotStatus 获取机器人状态
|
||||
func (h *TelegramHandler) GetBotStatus(c *gin.Context) {
|
||||
// 获取机器人运行时状态
|
||||
runtimeStatus := h.telegramBotService.GetRuntimeStatus()
|
||||
|
||||
// 获取配置状态
|
||||
configs, err := h.systemConfigRepo.GetOrCreateDefault()
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取配置失败", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析配置状态
|
||||
configStatus := map[string]interface{}{
|
||||
"enabled": false,
|
||||
"auto_reply_enabled": false,
|
||||
"api_key_configured": false,
|
||||
}
|
||||
|
||||
for _, config := range configs {
|
||||
switch config.Key {
|
||||
case "telegram_bot_enabled":
|
||||
configStatus["enabled"] = config.Value == "true"
|
||||
case "telegram_auto_reply_enabled":
|
||||
configStatus["auto_reply_enabled"] = config.Value == "true"
|
||||
case "telegram_bot_api_key":
|
||||
configStatus["api_key_configured"] = config.Value != ""
|
||||
}
|
||||
}
|
||||
|
||||
// 合并状态信息
|
||||
status := map[string]interface{}{
|
||||
"config": configStatus,
|
||||
"runtime": runtimeStatus,
|
||||
"overall_status": runtimeStatus["is_running"].(bool),
|
||||
"status_text": func() string {
|
||||
if runtimeStatus["is_running"].(bool) {
|
||||
return "运行中"
|
||||
} else if configStatus["enabled"].(bool) {
|
||||
return "已启用但未运行"
|
||||
} else {
|
||||
return "已停止"
|
||||
}
|
||||
}(),
|
||||
}
|
||||
|
||||
SuccessResponse(c, status)
|
||||
}
|
||||
|
||||
// TestBotMessage 测试机器人消息发送
|
||||
func (h *TelegramHandler) TestBotMessage(c *gin.Context) {
|
||||
var req struct {
|
||||
ChatID int64 `json:"chat_id" binding:"required"`
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
ErrorResponse(c, "请求参数错误", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.telegramBotService.SendMessage(req.ChatID, req.Text, "")
|
||||
if err != nil {
|
||||
ErrorResponse(c, "发送消息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "测试消息已发送",
|
||||
})
|
||||
}
|
||||
|
||||
// ReloadBotConfig 重新加载机器人配置
|
||||
func (h *TelegramHandler) ReloadBotConfig(c *gin.Context) {
|
||||
// 这里可以实现重新加载配置的逻辑
|
||||
// 目前通过重启服务来实现配置重新加载
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "请重启服务器以重新加载配置",
|
||||
"note": "当前版本需要重启服务器才能重新加载机器人配置",
|
||||
})
|
||||
}
|
||||
|
||||
// DebugBotConnection 调试机器人连接
|
||||
func (h *TelegramHandler) DebugBotConnection(c *gin.Context) {
|
||||
// 获取机器人状态信息用于调试
|
||||
botUsername := h.telegramBotService.GetBotUsername()
|
||||
|
||||
debugInfo := map[string]interface{}{
|
||||
"bot_username": botUsername,
|
||||
"is_running": botUsername != "",
|
||||
"timestamp": "2024-01-01T12:00:00Z", // 当前时间
|
||||
"debugging_enabled": true,
|
||||
"expected_logs": []string{
|
||||
"[TELEGRAM:SERVICE] Telegram Bot (@username) 已启动",
|
||||
"[TELEGRAM:MESSAGE] 开始监听 Telegram 消息更新...",
|
||||
"[TELEGRAM:MESSAGE] 消息监听循环已启动,等待消息...",
|
||||
"[TELEGRAM:MESSAGE] 收到消息: ChatID=xxx, Text='/register'",
|
||||
"[TELEGRAM:MESSAGE] 处理 /register 命令 from ChatID=xxx",
|
||||
},
|
||||
"troubleshooting_steps": []string{
|
||||
"1. 检查服务器日志中是否有 TELEGRAM 相关日志",
|
||||
"2. 确认机器人已添加到群组并设为管理员",
|
||||
"3. 验证 API Key 是否正确",
|
||||
"4. 检查自动回复是否已启用",
|
||||
"5. 重启服务器重新加载配置",
|
||||
},
|
||||
}
|
||||
|
||||
SuccessResponse(c, debugInfo)
|
||||
}
|
||||
|
||||
// GetTelegramLogs 获取Telegram相关的日志
|
||||
func (h *TelegramHandler) GetTelegramLogs(c *gin.Context) {
|
||||
// 解析查询参数
|
||||
hoursStr := c.DefaultQuery("hours", "24")
|
||||
limitStr := c.DefaultQuery("limit", "100")
|
||||
|
||||
hours, err := strconv.Atoi(hoursStr)
|
||||
if err != nil || hours <= 0 || hours > 720 { // 最多30天
|
||||
hours = 24
|
||||
}
|
||||
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// 计算时间范围
|
||||
endTime := time.Now()
|
||||
startTime := endTime.Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
// 获取日志
|
||||
logs, err := utils.GetTelegramLogs(&startTime, &endTime, limit)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取日志失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"logs": logs,
|
||||
"count": len(logs),
|
||||
"hours": hours,
|
||||
"limit": limit,
|
||||
"start": startTime.Format("2006-01-02 15:04:05"),
|
||||
"end": endTime.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTelegramLogStats 获取Telegram日志统计信息
|
||||
func (h *TelegramHandler) GetTelegramLogStats(c *gin.Context) {
|
||||
hoursStr := c.DefaultQuery("hours", "24")
|
||||
|
||||
hours, err := strconv.Atoi(hoursStr)
|
||||
if err != nil || hours <= 0 || hours > 720 {
|
||||
hours = 24
|
||||
}
|
||||
|
||||
stats, err := utils.GetTelegramLogStats(hours)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "获取统计信息失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"stats": stats,
|
||||
"hours": hours,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearTelegramLogs 清理旧的Telegram日志
|
||||
func (h *TelegramHandler) ClearTelegramLogs(c *gin.Context) {
|
||||
daysStr := c.DefaultQuery("days", "30")
|
||||
|
||||
days, err := strconv.Atoi(daysStr)
|
||||
if err != nil || days <= 0 || days > 365 {
|
||||
days = 30
|
||||
}
|
||||
|
||||
err = utils.ClearOldTelegramLogs(days)
|
||||
if err != nil {
|
||||
ErrorResponse(c, "清理日志失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
SuccessResponse(c, map[string]interface{}{
|
||||
"message": fmt.Sprintf("已清理 %d 天前的日志文件", days),
|
||||
"days": days,
|
||||
})
|
||||
}
|
||||
|
||||
// getCurrentUsername 获取当前用户名(临时实现)
|
||||
func getCurrentUsername(c *gin.Context) string {
|
||||
// 这里应该从中间件中获取用户信息
|
||||
// 暂时返回默认值
|
||||
return "admin"
|
||||
}
|
||||
78
main.go
78
main.go
@@ -7,9 +7,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/db"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/handlers"
|
||||
"github.com/ctwj/urldb/middleware"
|
||||
"github.com/ctwj/urldb/scheduler"
|
||||
"github.com/ctwj/urldb/services"
|
||||
"github.com/ctwj/urldb/task"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
@@ -90,6 +92,10 @@ func main() {
|
||||
transferProcessor := task.NewTransferProcessor(repoManager)
|
||||
taskManager.RegisterProcessor(transferProcessor)
|
||||
|
||||
// 注册扩容任务处理器
|
||||
expansionProcessor := task.NewExpansionProcessor(repoManager)
|
||||
taskManager.RegisterProcessor(expansionProcessor)
|
||||
|
||||
// 初始化Meilisearch管理器
|
||||
meilisearchManager := services.NewMeilisearchManager(repoManager)
|
||||
if err := meilisearchManager.Initialize(); err != nil {
|
||||
@@ -121,6 +127,34 @@ func main() {
|
||||
// 设置Meilisearch管理器到handlers中
|
||||
handlers.SetMeilisearchManager(meilisearchManager)
|
||||
|
||||
// 设置全局调度器的Meilisearch管理器
|
||||
scheduler.SetGlobalMeilisearchManager(meilisearchManager)
|
||||
|
||||
// 初始化并启动调度器
|
||||
globalScheduler := scheduler.GetGlobalScheduler(
|
||||
repoManager.HotDramaRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.PanRepository,
|
||||
repoManager.CksRepository,
|
||||
repoManager.TagRepository,
|
||||
repoManager.CategoryRepository,
|
||||
)
|
||||
|
||||
// 根据系统配置启动相应的调度任务
|
||||
autoFetchHotDrama, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoFetchHotDramaEnabled)
|
||||
autoProcessReadyResources, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||
autoTransferEnabled, _ := repoManager.SystemConfigRepository.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
|
||||
globalScheduler.UpdateSchedulerStatusWithAutoTransfer(
|
||||
autoFetchHotDrama,
|
||||
autoProcessReadyResources,
|
||||
autoTransferEnabled,
|
||||
)
|
||||
|
||||
utils.Info("调度器初始化完成")
|
||||
|
||||
// 设置公开API中间件的Repository管理器
|
||||
middleware.SetRepositoryManager(repoManager)
|
||||
|
||||
@@ -238,6 +272,12 @@ func main() {
|
||||
api.POST("/search-stats/record", handlers.RecordSearch)
|
||||
api.GET("/search-stats/summary", handlers.GetSearchStatsSummary)
|
||||
|
||||
// API访问日志路由
|
||||
api.GET("/api-access-logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetAPIAccessLogs)
|
||||
api.GET("/api-access-logs/summary", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetAPIAccessLogSummary)
|
||||
api.GET("/api-access-logs/stats", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetAPIAccessLogStats)
|
||||
api.DELETE("/api-access-logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.ClearAPIAccessLogs)
|
||||
|
||||
// 系统配置路由
|
||||
api.GET("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.GetSystemConfig)
|
||||
api.POST("/system/config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateSystemConfig)
|
||||
@@ -251,9 +291,12 @@ func main() {
|
||||
api.POST("/hot-dramas", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.CreateHotDrama)
|
||||
api.PUT("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.UpdateHotDrama)
|
||||
api.DELETE("/hot-dramas/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), handlers.DeleteHotDrama)
|
||||
api.GET("/hot-dramas/poster", handlers.GetPosterImage)
|
||||
|
||||
// 任务管理路由
|
||||
api.POST("/tasks/transfer", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.CreateBatchTransferTask)
|
||||
api.POST("/tasks/expansion", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.CreateExpansionTask)
|
||||
api.GET("/tasks/expansion/accounts", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetExpansionAccounts)
|
||||
api.GET("/tasks", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTasks)
|
||||
api.GET("/tasks/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.GetTaskStatus)
|
||||
api.POST("/tasks/:id/start", middleware.AuthMiddleware(), middleware.AdminMiddleware(), taskHandler.StartTask)
|
||||
@@ -286,6 +329,41 @@ func main() {
|
||||
api.GET("/files", middleware.AuthMiddleware(), fileHandler.GetFileList)
|
||||
api.DELETE("/files", middleware.AuthMiddleware(), fileHandler.DeleteFiles)
|
||||
api.PUT("/files", middleware.AuthMiddleware(), fileHandler.UpdateFile)
|
||||
|
||||
// 创建Telegram Bot服务
|
||||
telegramBotService := services.NewTelegramBotService(
|
||||
repoManager.SystemConfigRepository,
|
||||
repoManager.TelegramChannelRepository,
|
||||
repoManager.ResourceRepository,
|
||||
repoManager.ReadyResourceRepository,
|
||||
)
|
||||
|
||||
// 启动Telegram Bot服务
|
||||
if err := telegramBotService.Start(); err != nil {
|
||||
utils.Error("启动Telegram Bot服务失败: %v", err)
|
||||
}
|
||||
|
||||
// Telegram相关路由
|
||||
telegramHandler := handlers.NewTelegramHandler(
|
||||
repoManager.TelegramChannelRepository,
|
||||
repoManager.SystemConfigRepository,
|
||||
telegramBotService,
|
||||
)
|
||||
api.GET("/telegram/bot-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetBotConfig)
|
||||
api.PUT("/telegram/bot-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.UpdateBotConfig)
|
||||
api.POST("/telegram/validate-api-key", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ValidateApiKey)
|
||||
api.GET("/telegram/bot-status", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetBotStatus)
|
||||
api.POST("/telegram/reload-config", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ReloadBotConfig)
|
||||
api.POST("/telegram/test-message", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.TestBotMessage)
|
||||
api.GET("/telegram/debug-connection", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.DebugBotConnection)
|
||||
api.GET("/telegram/channels", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetChannels)
|
||||
api.POST("/telegram/channels", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.CreateChannel)
|
||||
api.PUT("/telegram/channels/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.UpdateChannel)
|
||||
api.DELETE("/telegram/channels/:id", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.DeleteChannel)
|
||||
api.GET("/telegram/logs", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetTelegramLogs)
|
||||
api.GET("/telegram/logs/stats", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.GetTelegramLogStats)
|
||||
api.POST("/telegram/logs/clear", middleware.AuthMiddleware(), middleware.AdminMiddleware(), telegramHandler.ClearTelegramLogs)
|
||||
api.POST("/telegram/webhook", telegramHandler.HandleWebhook)
|
||||
}
|
||||
|
||||
// 静态文件服务
|
||||
|
||||
@@ -27,8 +27,8 @@ type Claims struct {
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path)
|
||||
utils.Info("AuthMiddleware - Authorization头: %s", authHeader)
|
||||
// utils.Info("AuthMiddleware - 收到请求: %s %s", c.Request.Method, c.Request.URL.Path)
|
||||
// utils.Info("AuthMiddleware - Authorization头: %s", authHeader)
|
||||
|
||||
if authHeader == "" {
|
||||
utils.Error("AuthMiddleware - 未提供认证令牌")
|
||||
@@ -39,24 +39,24 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
|
||||
// 检查Bearer前缀
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
|
||||
// utils.Error("AuthMiddleware - 无效的认证格式: %s", authHeader)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
|
||||
// utils.Info("AuthMiddleware - 解析令牌: %s", tokenString[:10]+"...")
|
||||
|
||||
claims, err := parseToken(tokenString)
|
||||
if err != nil {
|
||||
utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
|
||||
// utils.Error("AuthMiddleware - 令牌解析失败: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的令牌"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
|
||||
// utils.Info("AuthMiddleware - 令牌验证成功,用户: %s, 角色: %s", claims.Username, claims.Role)
|
||||
|
||||
// 将用户信息存储到上下文中
|
||||
c.Set("user_id", claims.UserID)
|
||||
@@ -72,13 +72,13 @@ func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get("role")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
// c.JSON(http.StatusUnauthorized, gin.H{"error": "未认证"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
// c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -106,23 +106,23 @@ func GenerateToken(user *entity.User) (string, error) {
|
||||
|
||||
// parseToken 解析JWT令牌
|
||||
func parseToken(tokenString string) (*Claims, error) {
|
||||
utils.Info("parseToken - 开始解析令牌")
|
||||
// utils.Info("parseToken - 开始解析令牌")
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
utils.Error("parseToken - JWT解析失败: %v", err)
|
||||
// utils.Error("parseToken - JWT解析失败: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
utils.Info("parseToken - 令牌解析成功,用户ID: %d", claims.UserID)
|
||||
// utils.Info("parseToken - 令牌解析成功,用户ID: %d", claims.UserID)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
utils.Error("parseToken - 令牌无效或签名错误")
|
||||
// utils.Error("parseToken - 令牌无效或签名错误")
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
|
||||
47
migrations/telegram_channels.sql
Normal file
47
migrations/telegram_channels.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- 创建 Telegram 频道/群组表
|
||||
CREATE TABLE telegram_channels (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
|
||||
-- Telegram 频道/群组信息
|
||||
chat_id BIGINT NOT NULL COMMENT 'Telegram 聊天ID',
|
||||
chat_name VARCHAR(255) NOT NULL COMMENT '聊天名称',
|
||||
chat_type VARCHAR(50) NOT NULL COMMENT '类型:channel/group',
|
||||
|
||||
-- 推送配置
|
||||
push_enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用推送',
|
||||
push_frequency INT DEFAULT 24 COMMENT '推送频率(小时)',
|
||||
content_categories TEXT COMMENT '推送的内容分类,用逗号分隔',
|
||||
content_tags TEXT COMMENT '推送的标签,用逗号分隔',
|
||||
|
||||
-- 频道状态
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否活跃',
|
||||
last_push_at TIMESTAMP NULL COMMENT '最后推送时间',
|
||||
|
||||
-- 注册信息
|
||||
registered_by VARCHAR(100) COMMENT '注册者用户名',
|
||||
registered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
|
||||
|
||||
-- API配置
|
||||
api VARCHAR(255) COMMENT 'API地址',
|
||||
token VARCHAR(255) COMMENT '访问令牌',
|
||||
api_type VARCHAR(50) COMMENT 'API类型',
|
||||
is_push_saved_info BOOLEAN DEFAULT FALSE COMMENT '是否只推送已转存资源',
|
||||
|
||||
-- 资源策略和时间限制配置
|
||||
resource_strategy VARCHAR(20) DEFAULT 'random' COMMENT '资源策略:latest-最新优先,transferred-已转存优先,random-纯随机',
|
||||
time_limit VARCHAR(20) DEFAULT 'none' COMMENT '时间限制:none-无限制,week-一周内,month-一月内',
|
||||
push_start_time VARCHAR(10) COMMENT '推送开始时间,格式HH:mm',
|
||||
push_end_time VARCHAR(10) COMMENT '推送结束时间,格式HH:mm',
|
||||
|
||||
-- 索引
|
||||
INDEX idx_chat_id (chat_id),
|
||||
INDEX idx_chat_type (chat_type),
|
||||
INDEX idx_is_active (is_active),
|
||||
INDEX idx_push_enabled (push_enabled),
|
||||
INDEX idx_registered_at (registered_at),
|
||||
INDEX idx_last_push_at (last_push_at),
|
||||
|
||||
UNIQUE KEY uk_chat_id (chat_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Telegram 频道/群组表';
|
||||
@@ -1,444 +0,0 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
panutils "github.com/ctwj/urldb/common"
|
||||
commonutils "github.com/ctwj/urldb/common/utils"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AutoTransferScheduler 自动转存调度器
|
||||
type AutoTransferScheduler struct {
|
||||
*BaseScheduler
|
||||
autoTransferRunning bool
|
||||
autoTransferMutex sync.Mutex // 防止自动转存任务重叠执行
|
||||
}
|
||||
|
||||
// NewAutoTransferScheduler 创建自动转存调度器
|
||||
func NewAutoTransferScheduler(base *BaseScheduler) *AutoTransferScheduler {
|
||||
return &AutoTransferScheduler{
|
||||
BaseScheduler: base,
|
||||
autoTransferRunning: false,
|
||||
autoTransferMutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
// Start 启动自动转存定时任务
|
||||
func (a *AutoTransferScheduler) Start() {
|
||||
|
||||
// 自动转存已经放弃,不再自动缓存
|
||||
return
|
||||
|
||||
if a.autoTransferRunning {
|
||||
utils.Info("自动转存定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
a.autoTransferRunning = true
|
||||
utils.Info("启动自动转存定时任务")
|
||||
|
||||
go func() {
|
||||
// 获取系统配置中的间隔时间
|
||||
interval := 5 * time.Minute // 默认5分钟
|
||||
if autoProcessInterval, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
|
||||
interval = time.Duration(autoProcessInterval) * time.Minute
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
utils.Info(fmt.Sprintf("自动转存定时任务已启动,间隔时间: %v", interval))
|
||||
|
||||
// 立即执行一次
|
||||
a.processAutoTransfer()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
// 使用TryLock防止任务重叠执行
|
||||
if a.autoTransferMutex.TryLock() {
|
||||
go func() {
|
||||
defer a.autoTransferMutex.Unlock()
|
||||
a.processAutoTransfer()
|
||||
}()
|
||||
} else {
|
||||
utils.Info("上一次自动转存任务还在执行中,跳过本次执行")
|
||||
}
|
||||
case <-a.GetStopChan():
|
||||
utils.Info("停止自动转存定时任务")
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止自动转存定时任务
|
||||
func (a *AutoTransferScheduler) Stop() {
|
||||
if !a.autoTransferRunning {
|
||||
utils.Info("自动转存定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
a.GetStopChan() <- true
|
||||
a.autoTransferRunning = false
|
||||
utils.Info("已发送停止信号给自动转存定时任务")
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存任务是否正在运行
|
||||
func (a *AutoTransferScheduler) IsAutoTransferRunning() bool {
|
||||
return a.autoTransferRunning
|
||||
}
|
||||
|
||||
// processAutoTransfer 处理自动转存
|
||||
func (a *AutoTransferScheduler) processAutoTransfer() {
|
||||
utils.Info("开始处理自动转存...")
|
||||
|
||||
// 检查系统配置,确认是否启用自动转存
|
||||
autoTransferEnabled, err := a.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoTransferEnabled)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取系统配置失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if !autoTransferEnabled {
|
||||
utils.Info("自动转存功能已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取quark平台ID
|
||||
quarkPanID, err := a.getQuarkPanID()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取夸克网盘ID失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取所有账号
|
||||
accounts, err := a.cksRepo.FindAll()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取网盘账号失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤:只保留已激活、quark平台、剩余空间足够的账号
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
var validAccounts []entity.Cks
|
||||
for _, acc := range accounts {
|
||||
if acc.IsValid && acc.PanID == quarkPanID && acc.LeftSpace >= minSpaceBytes {
|
||||
validAccounts = append(validAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
utils.Info("没有可用的quark网盘账号")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("找到 %d 个可用quark网盘账号,开始自动转存处理...", len(validAccounts)))
|
||||
|
||||
// 计算处理数量限制
|
||||
// 假设每5秒转存一个资源,每分钟20个,5分钟100个
|
||||
// 根据时间间隔和账号数量计算大致的处理数量
|
||||
interval := 5 * time.Minute // 默认5分钟
|
||||
if autoProcessInterval, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoProcessInterval); err == nil && autoProcessInterval > 0 {
|
||||
interval = time.Duration(autoProcessInterval) * time.Minute
|
||||
}
|
||||
|
||||
// 计算每分钟能处理的资源数量:账号数 * 12(每分钟12个,即每5秒一个)
|
||||
resourcesPerMinute := len(validAccounts) * 12
|
||||
// 根据时间间隔计算总处理数量
|
||||
maxProcessCount := int(float64(resourcesPerMinute) * interval.Minutes())
|
||||
|
||||
utils.Info(fmt.Sprintf("时间间隔: %v, 账号数: %d, 每分钟处理能力: %d, 最大处理数量: %d",
|
||||
interval, len(validAccounts), resourcesPerMinute, maxProcessCount))
|
||||
|
||||
// 获取需要转存的资源(限制数量)
|
||||
resources, err := a.getResourcesForTransfer(quarkPanID, maxProcessCount)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取需要转存的资源失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
utils.Info("没有需要转存的资源")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("找到 %d 个需要转存的资源", len(resources)))
|
||||
|
||||
// 获取违禁词配置
|
||||
forbiddenWords, err := a.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取违禁词配置失败: %v", err))
|
||||
forbiddenWords = "" // 如果获取失败,使用空字符串
|
||||
}
|
||||
|
||||
// 过滤包含违禁词的资源,并标记违禁词错误
|
||||
var filteredResources []*entity.Resource
|
||||
var forbiddenResources []*entity.Resource
|
||||
|
||||
if forbiddenWords != "" {
|
||||
words := strings.Split(forbiddenWords, ",")
|
||||
// 清理违禁词数组,去除空格
|
||||
var cleanWords []string
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
cleanWords = append(cleanWords, word)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range resources {
|
||||
shouldSkip := false
|
||||
var matchedWords []string
|
||||
title := strings.ToLower(resource.Title)
|
||||
description := strings.ToLower(resource.Description)
|
||||
|
||||
for _, word := range cleanWords {
|
||||
wordLower := strings.ToLower(word)
|
||||
if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
||||
matchedWords = append(matchedWords, word)
|
||||
shouldSkip = true
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSkip {
|
||||
// 标记为违禁词错误
|
||||
resource.ErrorMsg = fmt.Sprintf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
||||
forbiddenResources = append(forbiddenResources, resource)
|
||||
utils.Info(fmt.Sprintf("标记违禁词资源: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
||||
} else {
|
||||
filteredResources = append(filteredResources, resource)
|
||||
}
|
||||
}
|
||||
utils.Info(fmt.Sprintf("违禁词过滤后,剩余 %d 个资源需要转存,违禁词资源 %d 个", len(filteredResources), len(forbiddenResources)))
|
||||
} else {
|
||||
filteredResources = resources
|
||||
}
|
||||
|
||||
// 注意:资源数量已在数据库查询时限制,无需再次限制
|
||||
|
||||
// 保存违禁词资源的错误信息
|
||||
for _, resource := range forbiddenResources {
|
||||
if err := a.resourceRepo.Update(resource); err != nil {
|
||||
utils.Error(fmt.Sprintf("保存违禁词错误信息失败 (ID: %d): %v", resource.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 并发自动转存
|
||||
resourceCh := make(chan *entity.Resource, len(filteredResources))
|
||||
for _, res := range filteredResources {
|
||||
resourceCh <- res
|
||||
}
|
||||
close(resourceCh)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, account := range validAccounts {
|
||||
wg.Add(1)
|
||||
go func(acc entity.Cks) {
|
||||
defer wg.Done()
|
||||
factory := panutils.GetInstance() // 使用单例模式
|
||||
for res := range resourceCh {
|
||||
if err := a.transferResource(res, []entity.Cks{acc}, factory); err != nil {
|
||||
utils.Error(fmt.Sprintf("转存资源失败 (ID: %d): %v", res.ID, err))
|
||||
} else {
|
||||
utils.Info(fmt.Sprintf("成功转存资源: %s", res.Title))
|
||||
rand.Seed(utils.GetCurrentTime().UnixNano())
|
||||
sleepSec := rand.Intn(3) + 1 // 1,2,3
|
||||
time.Sleep(time.Duration(sleepSec) * time.Second)
|
||||
}
|
||||
}
|
||||
}(account)
|
||||
}
|
||||
wg.Wait()
|
||||
utils.Info(fmt.Sprintf("自动转存处理完成,账号数: %d,处理资源数: %d,违禁词资源数: %d",
|
||||
len(validAccounts), len(filteredResources), len(forbiddenResources)))
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
func (a *AutoTransferScheduler) getQuarkPanID() (uint, error) {
|
||||
// 获取panRepo的实现,以便访问数据库
|
||||
panRepoImpl, ok := a.panRepo.(interface{ GetDB() *gorm.DB })
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("panRepo不支持GetDB方法")
|
||||
}
|
||||
|
||||
var quarkPan entity.Pan
|
||||
err := panRepoImpl.GetDB().Where("name = ?", "quark").First(&quarkPan).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("未找到quark平台: %v", err)
|
||||
}
|
||||
|
||||
return quarkPan.ID, nil
|
||||
}
|
||||
|
||||
// getResourcesForTransfer 获取需要转存的资源
|
||||
func (a *AutoTransferScheduler) getResourcesForTransfer(quarkPanID uint, limit int) ([]*entity.Resource, error) {
|
||||
// 获取最近24小时内的资源
|
||||
sinceTime := utils.GetCurrentTime().Add(-24 * time.Hour)
|
||||
|
||||
// 使用资源仓库的方法获取需要转存的资源
|
||||
repoImpl, ok := a.resourceRepo.(*repo.ResourceRepositoryImpl)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("资源仓库类型错误")
|
||||
}
|
||||
|
||||
return repoImpl.GetResourcesForTransfer(quarkPanID, sinceTime, limit)
|
||||
}
|
||||
|
||||
// transferResource 转存单个资源
|
||||
func (a *AutoTransferScheduler) transferResource(resource *entity.Resource, accounts []entity.Cks, factory *panutils.PanFactory) error {
|
||||
if len(accounts) == 0 {
|
||||
return fmt.Errorf("没有可用的网盘账号")
|
||||
}
|
||||
account := accounts[0]
|
||||
|
||||
service, err := factory.CreatePanService(resource.URL, &panutils.PanConfig{
|
||||
URL: resource.URL,
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建网盘服务失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查账号剩余空间
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
if account.LeftSpace < minSpaceBytes {
|
||||
return fmt.Errorf("账号剩余空间不足,需要 %d GB,当前剩余 %d GB", autoTransferMinSpace, account.LeftSpace/1024/1024/1024)
|
||||
}
|
||||
|
||||
// 提取分享ID
|
||||
shareID, _ := commonutils.ExtractShareIdString(resource.URL)
|
||||
|
||||
// 转存资源
|
||||
result, err := service.Transfer(shareID)
|
||||
if err != nil {
|
||||
// 更新错误信息
|
||||
resource.ErrorMsg = err.Error()
|
||||
a.resourceRepo.Update(resource)
|
||||
return fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil || !result.Success {
|
||||
errMsg := "转存失败"
|
||||
if result != nil && result.Message != "" {
|
||||
errMsg = result.Message
|
||||
}
|
||||
// 更新错误信息
|
||||
resource.ErrorMsg = errMsg
|
||||
a.resourceRepo.Update(resource)
|
||||
return fmt.Errorf("转存失败: %s", errMsg)
|
||||
}
|
||||
|
||||
// 提取转存链接、fid等
|
||||
var saveURL, fid string
|
||||
if data, ok := result.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
if v, ok := data["fid"]; ok {
|
||||
fid, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = result.ShareURL
|
||||
}
|
||||
|
||||
// 更新资源信息
|
||||
resource.SaveURL = saveURL
|
||||
resource.CkID = &account.ID
|
||||
resource.Fid = fid
|
||||
resource.ErrorMsg = ""
|
||||
|
||||
// 保存更新
|
||||
err = a.resourceRepo.Update(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存转存结果失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectBestAccount 选择最佳账号
|
||||
func (a *AutoTransferScheduler) selectBestAccount(accounts []entity.Cks) *entity.Cks {
|
||||
if len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置
|
||||
autoTransferMinSpace, err := a.systemConfigRepo.GetConfigInt(entity.ConfigKeyAutoTransferMinSpace)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最小存储空间配置失败: %v", err))
|
||||
return &accounts[0] // 返回第一个账号
|
||||
}
|
||||
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
|
||||
var bestAccount *entity.Cks
|
||||
var bestScore int64 = -1
|
||||
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.LeftSpace < minSpaceBytes {
|
||||
continue // 跳过空间不足的账号
|
||||
}
|
||||
|
||||
score := a.calculateAccountScore(account)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestAccount = account
|
||||
}
|
||||
}
|
||||
|
||||
return bestAccount
|
||||
}
|
||||
|
||||
// calculateAccountScore 计算账号评分
|
||||
func (a *AutoTransferScheduler) calculateAccountScore(account *entity.Cks) int64 {
|
||||
// TODO: 实现账号评分算法
|
||||
// 1. VIP账号加分
|
||||
// 2. 剩余空间大的账号加分
|
||||
// 3. 使用率低的账号加分
|
||||
// 4. 可以根据历史使用情况调整评分
|
||||
|
||||
score := int64(0)
|
||||
|
||||
// VIP账号加分
|
||||
if account.VipStatus {
|
||||
score += 1000
|
||||
}
|
||||
|
||||
// 剩余空间加分(每GB加1分)
|
||||
score += account.LeftSpace / (1024 * 1024 * 1024)
|
||||
|
||||
// 使用率加分(使用率越低分数越高)
|
||||
if account.Space > 0 {
|
||||
usageRate := float64(account.UsedSpace) / float64(account.Space)
|
||||
score += int64((1 - usageRate) * 500) // 使用率越低,加分越多
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/services"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
@@ -16,8 +17,20 @@ type GlobalScheduler struct {
|
||||
var (
|
||||
globalScheduler *GlobalScheduler
|
||||
once sync.Once
|
||||
// 全局Meilisearch管理器
|
||||
globalMeilisearchManager *services.MeilisearchManager
|
||||
)
|
||||
|
||||
// SetGlobalMeilisearchManager 设置全局Meilisearch管理器
|
||||
func SetGlobalMeilisearchManager(manager *services.MeilisearchManager) {
|
||||
globalMeilisearchManager = manager
|
||||
}
|
||||
|
||||
// GetGlobalMeilisearchManager 获取全局Meilisearch管理器
|
||||
func GetGlobalMeilisearchManager() *services.MeilisearchManager {
|
||||
return globalMeilisearchManager
|
||||
}
|
||||
|
||||
// GetGlobalScheduler 获取全局调度器实例(单例模式)
|
||||
func GetGlobalScheduler(hotDramaRepo repo.HotDramaRepository, readyResourceRepo repo.ReadyResourceRepository, resourceRepo repo.ResourceRepository, systemConfigRepo repo.SystemConfigRepository, panRepo repo.PanRepository, cksRepo repo.CksRepository, tagRepo repo.TagRepository, categoryRepo repo.CategoryRepository) *GlobalScheduler {
|
||||
once.Do(func() {
|
||||
@@ -34,12 +47,12 @@ func (gs *GlobalScheduler) StartHotDramaScheduler() {
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsHotDramaRunning() {
|
||||
utils.Info("热播剧定时任务已在运行中")
|
||||
utils.Debug("热播剧定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartHotDramaScheduler()
|
||||
utils.Info("全局调度器已启动热播剧定时任务")
|
||||
utils.Debug("全局调度器已启动热播剧定时任务")
|
||||
}
|
||||
|
||||
// StopHotDramaScheduler 停止热播剧定时任务
|
||||
@@ -48,12 +61,12 @@ func (gs *GlobalScheduler) StopHotDramaScheduler() {
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsHotDramaRunning() {
|
||||
utils.Info("热播剧定时任务未在运行")
|
||||
utils.Debug("热播剧定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopHotDramaScheduler()
|
||||
utils.Info("全局调度器已停止热播剧定时任务")
|
||||
utils.Debug("全局调度器已停止热播剧定时任务")
|
||||
}
|
||||
|
||||
// IsHotDramaSchedulerRunning 检查热播剧定时任务是否在运行
|
||||
@@ -74,12 +87,12 @@ func (gs *GlobalScheduler) StartReadyResourceScheduler() {
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsReadyResourceRunning() {
|
||||
utils.Info("待处理资源自动处理任务已在运行中")
|
||||
utils.Debug("待处理资源自动处理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartReadyResourceScheduler()
|
||||
utils.Info("全局调度器已启动待处理资源自动处理任务")
|
||||
utils.Debug("全局调度器已启动待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// StopReadyResourceScheduler 停止待处理资源自动处理任务
|
||||
@@ -88,12 +101,12 @@ func (gs *GlobalScheduler) StopReadyResourceScheduler() {
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsReadyResourceRunning() {
|
||||
utils.Info("待处理资源自动处理任务未在运行")
|
||||
utils.Debug("待处理资源自动处理任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopReadyResourceScheduler()
|
||||
utils.Info("全局调度器已停止待处理资源自动处理任务")
|
||||
utils.Debug("全局调度器已停止待处理资源自动处理任务")
|
||||
}
|
||||
|
||||
// IsReadyResourceRunning 检查待处理资源自动处理任务是否在运行
|
||||
@@ -103,41 +116,6 @@ func (gs *GlobalScheduler) IsReadyResourceRunning() bool {
|
||||
return gs.manager.IsReadyResourceRunning()
|
||||
}
|
||||
|
||||
// StartAutoTransferScheduler 启动自动转存定时任务
|
||||
func (gs *GlobalScheduler) StartAutoTransferScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("自动转存定时任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StartAutoTransferScheduler()
|
||||
utils.Info("全局调度器已启动自动转存定时任务")
|
||||
}
|
||||
|
||||
// StopAutoTransferScheduler 停止自动转存定时任务
|
||||
func (gs *GlobalScheduler) StopAutoTransferScheduler() {
|
||||
gs.mutex.Lock()
|
||||
defer gs.mutex.Unlock()
|
||||
|
||||
if !gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("自动转存定时任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
gs.manager.StopAutoTransferScheduler()
|
||||
utils.Info("全局调度器已停止自动转存定时任务")
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存定时任务是否在运行
|
||||
func (gs *GlobalScheduler) IsAutoTransferRunning() bool {
|
||||
gs.mutex.RLock()
|
||||
defer gs.mutex.RUnlock()
|
||||
return gs.manager.IsAutoTransferRunning()
|
||||
}
|
||||
|
||||
// UpdateSchedulerStatusWithAutoTransfer 根据系统配置更新调度器状态(包含自动转存)
|
||||
func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDramaEnabled bool, autoProcessReadyResources bool, autoTransferEnabled bool) {
|
||||
gs.mutex.Lock()
|
||||
@@ -169,16 +147,4 @@ func (gs *GlobalScheduler) UpdateSchedulerStatusWithAutoTransfer(autoFetchHotDra
|
||||
}
|
||||
}
|
||||
|
||||
// 处理自动转存功能
|
||||
if autoTransferEnabled {
|
||||
if !gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("系统配置启用自动转存,启动定时任务")
|
||||
gs.manager.StartAutoTransferScheduler()
|
||||
}
|
||||
} else {
|
||||
if gs.manager.IsAutoTransferRunning() {
|
||||
utils.Info("系统配置禁用自动转存,停止定时任务")
|
||||
gs.manager.StopAutoTransferScheduler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,13 +90,30 @@ func (h *HotDramaScheduler) processHotDramaNames(dramaNames []string) {
|
||||
// 收集所有数据
|
||||
var allDramas []*entity.HotDrama
|
||||
|
||||
// 获取电影数据
|
||||
movieDramas := h.processMovieData()
|
||||
allDramas = append(allDramas, movieDramas...)
|
||||
// 获取最近热门电影数据
|
||||
recentMovieDramas := h.processRecentMovies()
|
||||
allDramas = append(allDramas, recentMovieDramas...)
|
||||
|
||||
// 获取电视剧数据
|
||||
tvDramas := h.processTvData()
|
||||
allDramas = append(allDramas, tvDramas...)
|
||||
// 获取最近热门剧集数据
|
||||
recentTVDramas := h.processRecentTVs()
|
||||
allDramas = append(allDramas, recentTVDramas...)
|
||||
|
||||
// 获取最近热门综艺数据
|
||||
recentShowDramas := h.processRecentShows()
|
||||
allDramas = append(allDramas, recentShowDramas...)
|
||||
|
||||
// 获取豆瓣电影Top250数据
|
||||
top250Dramas := h.processTop250Movies()
|
||||
allDramas = append(allDramas, top250Dramas...)
|
||||
|
||||
// 获取豆瓣各类别排行数据
|
||||
randDramas := h.processSubTypeRank()
|
||||
allDramas = append(allDramas, randDramas...)
|
||||
|
||||
// 设置排名顺序(保持豆瓣返回的顺序)
|
||||
for i, drama := range allDramas {
|
||||
drama.Rank = i
|
||||
}
|
||||
|
||||
// 清空数据库
|
||||
utils.Info("准备清空数据库,当前共有 %d 条数据", len(allDramas))
|
||||
@@ -121,111 +138,201 @@ func (h *HotDramaScheduler) processHotDramaNames(dramaNames []string) {
|
||||
utils.Info("热播剧数据处理完成")
|
||||
}
|
||||
|
||||
// processMovieData 处理电影数据
|
||||
func (h *HotDramaScheduler) processMovieData() []*entity.HotDrama {
|
||||
utils.Info("开始处理电影数据...")
|
||||
// processRecentMovies 处理最近热门电影数据
|
||||
func (h *HotDramaScheduler) processRecentMovies() []*entity.HotDrama {
|
||||
utils.Info("开始处理最近热门电影数据...")
|
||||
|
||||
var movieDramas []*entity.HotDrama
|
||||
var recentMovies []*entity.HotDrama
|
||||
|
||||
// 使用GetTypePage方法获取电影数据
|
||||
movieResult, err := h.doubanService.GetTypePage("热门", "全部")
|
||||
items, err := h.doubanService.GetRecentHotMovies()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取电影榜单失败: %v", err))
|
||||
return movieDramas
|
||||
utils.Error(fmt.Sprintf("获取最近热门电影失败: %v", err))
|
||||
return recentMovies
|
||||
}
|
||||
|
||||
if movieResult.Success && movieResult.Data != nil {
|
||||
utils.Info("电影获取到 %d 个数据", len(movieResult.Data.Items))
|
||||
utils.Info("最近热门电影获取到 %d 个数据", len(items))
|
||||
|
||||
for _, item := range movieResult.Data.Items {
|
||||
drama := &entity.HotDrama{
|
||||
Title: item.Title,
|
||||
CardSubtitle: item.CardSubtitle,
|
||||
EpisodesInfo: item.EpisodesInfo,
|
||||
IsNew: item.IsNew,
|
||||
Rating: item.Rating.Value,
|
||||
RatingCount: item.Rating.Count,
|
||||
Year: item.Year,
|
||||
Region: item.Region,
|
||||
Genres: strings.Join(item.Genres, ", "),
|
||||
Directors: strings.Join(item.Directors, ", "),
|
||||
Actors: strings.Join(item.Actors, ", "),
|
||||
PosterURL: item.Pic.Normal,
|
||||
Category: "电影",
|
||||
SubType: "热门",
|
||||
Source: "douban",
|
||||
DoubanID: item.ID,
|
||||
DoubanURI: item.URI,
|
||||
}
|
||||
|
||||
movieDramas = append(movieDramas, drama)
|
||||
utils.Info("收集电影: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
} else {
|
||||
utils.Warn("电影获取数据失败或为空")
|
||||
for _, item := range items {
|
||||
drama := h.convertDoubanItemToHotDrama(item, "电影", "热门")
|
||||
recentMovies = append(recentMovies, drama)
|
||||
utils.Info("收集最近热门电影: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
|
||||
utils.Info("电影数据处理完成,共收集 %d 条数据", len(movieDramas))
|
||||
return movieDramas
|
||||
utils.Info("最近热门电影数据处理完成,共收集 %d 条数据", len(recentMovies))
|
||||
return recentMovies
|
||||
}
|
||||
|
||||
// processTvData 处理电视剧数据
|
||||
func (h *HotDramaScheduler) processTvData() []*entity.HotDrama {
|
||||
utils.Info("开始处理电视剧数据...")
|
||||
// processRecentTVs 处理最近热门剧集数据
|
||||
func (h *HotDramaScheduler) processRecentTVs() []*entity.HotDrama {
|
||||
utils.Info("开始处理最近热门剧集数据...")
|
||||
|
||||
var tvDramas []*entity.HotDrama
|
||||
var recentTVs []*entity.HotDrama
|
||||
|
||||
// 获取所有tv类型
|
||||
tvTypes := h.doubanService.GetAllTvTypes()
|
||||
utils.Info("获取到 %d 个tv类型: %v", len(tvTypes), tvTypes)
|
||||
items, err := h.doubanService.GetRecentHotTVs()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最近热门剧集失败: %v", err))
|
||||
return recentTVs
|
||||
}
|
||||
|
||||
// 遍历每个type,分别请求数据
|
||||
for _, tvType := range tvTypes {
|
||||
utils.Info("正在处理tv类型: %s", tvType)
|
||||
utils.Info("最近热门剧集获取到 %d 个数据", len(items))
|
||||
|
||||
// 使用GetTypePage方法请求数据
|
||||
tvResult, err := h.doubanService.GetTypePage("tv", tvType)
|
||||
for _, item := range items {
|
||||
drama := h.convertDoubanItemToHotDrama(item, "电视剧", "热门")
|
||||
recentTVs = append(recentTVs, drama)
|
||||
utils.Info("收集最近热门剧集: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
|
||||
utils.Info("最近热门剧集数据处理完成,共收集 %d 条数据", len(recentTVs))
|
||||
return recentTVs
|
||||
}
|
||||
|
||||
// processRecentShows 处理最近热门综艺数据
|
||||
func (h *HotDramaScheduler) processRecentShows() []*entity.HotDrama {
|
||||
utils.Info("开始处理最近热门综艺数据...")
|
||||
|
||||
var recentShows []*entity.HotDrama
|
||||
|
||||
items, err := h.doubanService.GetRecentHotShows()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取最近热门综艺失败: %v", err))
|
||||
return recentShows
|
||||
}
|
||||
|
||||
utils.Info("最近热门综艺获取到 %d 个数据", len(items))
|
||||
|
||||
for _, item := range items {
|
||||
drama := h.convertDoubanItemToHotDrama(item, "综艺", "热门")
|
||||
recentShows = append(recentShows, drama)
|
||||
utils.Info("收集最近热门综艺: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
|
||||
utils.Info("最近热门综艺数据处理完成,共收集 %d 条数据", len(recentShows))
|
||||
return recentShows
|
||||
}
|
||||
|
||||
// processTop250Movies 处理豆瓣电影Top250数据
|
||||
func (h *HotDramaScheduler) processTop250Movies() []*entity.HotDrama {
|
||||
utils.Info("开始处理豆瓣电影Top250数据...")
|
||||
|
||||
var top250Movies []*entity.HotDrama
|
||||
|
||||
items, err := h.doubanService.GetTop250Movies()
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取豆瓣电影Top250失败: %v", err))
|
||||
return top250Movies
|
||||
}
|
||||
|
||||
utils.Info("豆瓣电影Top250获取到 %d 个数据", len(items))
|
||||
|
||||
for _, item := range items {
|
||||
drama := h.convertDoubanItemToHotDrama(item, "电影", "Top250")
|
||||
top250Movies = append(top250Movies, drama)
|
||||
utils.Info("收集豆瓣Top250电影: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
|
||||
utils.Info("豆瓣电影Top250数据处理完成,共收集 %d 条数据", len(top250Movies))
|
||||
return top250Movies
|
||||
}
|
||||
|
||||
// processSubTypeRank 处理子类别排名数据
|
||||
func (h *HotDramaScheduler) processSubTypeRank() []*entity.HotDrama {
|
||||
utils.Info("开始处理子类别排名数据...")
|
||||
|
||||
// 定义子类别配置
|
||||
subTypeConfigs := []struct {
|
||||
category string
|
||||
subType string
|
||||
url string
|
||||
}{
|
||||
{"喜剧", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECAYN54KI/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"剧情", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_27/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"爱情", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECSAOJFTA/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"动作", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECBUOLQGY/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"科幻", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECZYOJPLI/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"动画", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/EC3UOBDQY/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"悬疑", "机器热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECPQOJP5Q/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"犯罪", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECLAN6LHQ/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"惊悚", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECBUOL2DA/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"冒险", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECDYOE7WY/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"家庭", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_41/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"儿童", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_42/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"音乐", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_39/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"历史", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_44/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"奇幻", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_48/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"恐怖", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECV4N4FBI/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"战争", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/EC6MOCTVQ/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"传记", "近期热门", "https://m.douban.com/rexxar/api/v2/subject_collection/EC3EOHEYY/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"歌舞", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_40/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"武侠", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_50/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"情色", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_37/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"灾难", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/natural_disasters/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"西部", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_47/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"古装", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/film_genre_51/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
{"运动", "热门", "https://m.douban.com/rexxar/api/v2/subject_collection/ECCEPGM4Y/items?start=0&count=20&updated_at=&items_only=1&type_tag=&for_mobile=1"},
|
||||
}
|
||||
|
||||
var allDramas []*entity.HotDrama
|
||||
|
||||
// 遍历每个子类别
|
||||
for _, config := range subTypeConfigs {
|
||||
utils.Info("处理子类别: %s (%s)", config.category, "排行")
|
||||
|
||||
items, err := h.doubanService.GetRank(config.url)
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("获取tv类型 %s 数据失败: %v", tvType, err))
|
||||
utils.Error(fmt.Sprintf("获取%s-%s数据失败: %v", config.category, "排行", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if tvResult.Success && tvResult.Data != nil {
|
||||
utils.Info("tv类型 %s 获取到 %d 个数据", tvType, len(tvResult.Data.Items))
|
||||
utils.Info("子类别%s-%s获取到%d个数据", config.category, "排行", len(items))
|
||||
|
||||
for _, item := range tvResult.Data.Items {
|
||||
drama := &entity.HotDrama{
|
||||
Title: item.Title,
|
||||
CardSubtitle: item.CardSubtitle,
|
||||
EpisodesInfo: item.EpisodesInfo,
|
||||
IsNew: item.IsNew,
|
||||
Rating: item.Rating.Value,
|
||||
RatingCount: item.Rating.Count,
|
||||
Year: item.Year,
|
||||
Region: item.Region,
|
||||
Genres: strings.Join(item.Genres, ", "),
|
||||
Directors: strings.Join(item.Directors, ", "),
|
||||
Actors: strings.Join(item.Actors, ", "),
|
||||
PosterURL: item.Pic.Normal,
|
||||
Category: "电视剧",
|
||||
SubType: tvType, // 使用具体的tv类型
|
||||
Source: "douban",
|
||||
DoubanID: item.ID,
|
||||
DoubanURI: item.URI,
|
||||
}
|
||||
|
||||
tvDramas = append(tvDramas, drama)
|
||||
utils.Info("收集tv类型 %s: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
tvType, item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
} else {
|
||||
utils.Warn("tv类型 %s 获取数据失败或为空", tvType)
|
||||
// 转换每个item
|
||||
for _, item := range items {
|
||||
drama := h.convertDoubanItemToHotDrama(item, config.category, "排行")
|
||||
allDramas = append(allDramas, drama)
|
||||
utils.Info("收集子类别%s-%s: %s (评分: %.1f, 年份: %s, 地区: %s)",
|
||||
config.category, config.subType, item.Title, item.Rating.Value, item.Year, item.Region)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("电视剧数据处理完成,共收集 %d 条数据", len(tvDramas))
|
||||
return tvDramas
|
||||
// 根据DoubanID去重
|
||||
seen := make(map[string]bool)
|
||||
uniqueDramas := make([]*entity.HotDrama, 0)
|
||||
for _, drama := range allDramas {
|
||||
if !seen[drama.DoubanID] {
|
||||
seen[drama.DoubanID] = true
|
||||
uniqueDramas = append(uniqueDramas, drama)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("子类别排名数据处理完成,去重后共收集%d条数据", len(uniqueDramas))
|
||||
return uniqueDramas
|
||||
}
|
||||
|
||||
// convertDoubanItemToHotDrama 转换DoubanItem为HotDrama实体
|
||||
func (h *HotDramaScheduler) convertDoubanItemToHotDrama(item utils.DoubanItem, category, subType string) *entity.HotDrama {
|
||||
return &entity.HotDrama{
|
||||
Title: item.Title,
|
||||
CardSubtitle: item.CardSubtitle,
|
||||
EpisodesInfo: item.EpisodesInfo,
|
||||
IsNew: item.IsNew,
|
||||
Rating: item.Rating.Value,
|
||||
RatingCount: item.Rating.Count,
|
||||
Year: item.Year,
|
||||
Region: item.Region,
|
||||
Genres: strings.Join(item.Genres, ", "),
|
||||
Directors: strings.Join(item.Directors, ", "),
|
||||
Actors: strings.Join(item.Actors, ", "),
|
||||
PosterURL: item.Pic.Normal,
|
||||
Category: category,
|
||||
SubType: subType,
|
||||
Source: "douban",
|
||||
DoubanID: item.ID,
|
||||
DoubanURI: item.URI,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHotDramaNames 获取热播剧名称列表(公共方法)
|
||||
|
||||
@@ -10,7 +10,6 @@ type Manager struct {
|
||||
baseScheduler *BaseScheduler
|
||||
hotDramaScheduler *HotDramaScheduler
|
||||
readyResourceScheduler *ReadyResourceScheduler
|
||||
autoTransferScheduler *AutoTransferScheduler
|
||||
}
|
||||
|
||||
// NewManager 创建调度器管理器
|
||||
@@ -39,46 +38,38 @@ func NewManager(
|
||||
// 创建各个具体的调度器
|
||||
hotDramaScheduler := NewHotDramaScheduler(baseScheduler)
|
||||
readyResourceScheduler := NewReadyResourceScheduler(baseScheduler)
|
||||
autoTransferScheduler := NewAutoTransferScheduler(baseScheduler)
|
||||
|
||||
return &Manager{
|
||||
baseScheduler: baseScheduler,
|
||||
hotDramaScheduler: hotDramaScheduler,
|
||||
readyResourceScheduler: readyResourceScheduler,
|
||||
autoTransferScheduler: autoTransferScheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// StartAll 启动所有调度任务
|
||||
func (m *Manager) StartAll() {
|
||||
utils.Info("启动所有调度任务")
|
||||
utils.Debug("启动所有调度任务")
|
||||
|
||||
// 启动热播剧调度任务
|
||||
m.hotDramaScheduler.Start()
|
||||
// 启动热播剧定时任务
|
||||
m.StartHotDramaScheduler()
|
||||
|
||||
// 启动待处理资源调度任务
|
||||
m.readyResourceScheduler.Start()
|
||||
|
||||
// 启动自动转存调度任务
|
||||
m.autoTransferScheduler.Start()
|
||||
|
||||
utils.Info("所有调度任务已启动")
|
||||
utils.Debug("所有调度任务已启动")
|
||||
}
|
||||
|
||||
// StopAll 停止所有调度任务
|
||||
func (m *Manager) StopAll() {
|
||||
utils.Info("停止所有调度任务")
|
||||
utils.Debug("停止所有调度任务")
|
||||
|
||||
// 停止热播剧调度任务
|
||||
m.hotDramaScheduler.Stop()
|
||||
// 停止热播剧定时任务
|
||||
m.StopHotDramaScheduler()
|
||||
|
||||
// 停止待处理资源调度任务
|
||||
m.readyResourceScheduler.Stop()
|
||||
|
||||
// 停止自动转存调度任务
|
||||
m.autoTransferScheduler.Stop()
|
||||
|
||||
utils.Info("所有调度任务已停止")
|
||||
utils.Debug("所有调度任务已停止")
|
||||
}
|
||||
|
||||
// StartHotDramaScheduler 启动热播剧调度任务
|
||||
@@ -111,21 +102,6 @@ func (m *Manager) IsReadyResourceRunning() bool {
|
||||
return m.readyResourceScheduler.IsReadyResourceRunning()
|
||||
}
|
||||
|
||||
// StartAutoTransferScheduler 启动自动转存调度任务
|
||||
func (m *Manager) StartAutoTransferScheduler() {
|
||||
m.autoTransferScheduler.Start()
|
||||
}
|
||||
|
||||
// StopAutoTransferScheduler 停止自动转存调度任务
|
||||
func (m *Manager) StopAutoTransferScheduler() {
|
||||
m.autoTransferScheduler.Stop()
|
||||
}
|
||||
|
||||
// IsAutoTransferRunning 检查自动转存调度任务是否正在运行
|
||||
func (m *Manager) IsAutoTransferRunning() bool {
|
||||
return m.autoTransferScheduler.IsAutoTransferRunning()
|
||||
}
|
||||
|
||||
// GetHotDramaNames 获取热播剧名称列表
|
||||
func (m *Manager) GetHotDramaNames() ([]string, error) {
|
||||
return m.hotDramaScheduler.GetHotDramaNames()
|
||||
@@ -136,6 +112,5 @@ func (m *Manager) GetStatus() map[string]bool {
|
||||
return map[string]bool{
|
||||
"hot_drama": m.IsHotDramaRunning(),
|
||||
"ready_resource": m.IsReadyResourceRunning(),
|
||||
"auto_transfer": m.IsAutoTransferRunning(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewReadyResourceScheduler(base *BaseScheduler) *ReadyResourceScheduler {
|
||||
// Start 启动待处理资源定时任务
|
||||
func (r *ReadyResourceScheduler) Start() {
|
||||
if r.readyResourceRunning {
|
||||
utils.Info("待处理资源自动处理任务已在运行中")
|
||||
utils.Debug("待处理资源自动处理任务已在运行中")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (r *ReadyResourceScheduler) Start() {
|
||||
r.processReadyResources()
|
||||
}()
|
||||
} else {
|
||||
utils.Info("上一次待处理资源任务还在执行中,跳过本次执行")
|
||||
utils.Debug("上一次待处理资源任务还在执行中,跳过本次执行")
|
||||
}
|
||||
case <-r.GetStopChan():
|
||||
utils.Info("停止待处理资源自动处理任务")
|
||||
@@ -76,7 +76,7 @@ func (r *ReadyResourceScheduler) Start() {
|
||||
// Stop 停止待处理资源定时任务
|
||||
func (r *ReadyResourceScheduler) Stop() {
|
||||
if !r.readyResourceRunning {
|
||||
utils.Info("待处理资源自动处理任务未在运行")
|
||||
utils.Debug("待处理资源自动处理任务未在运行")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func (r *ReadyResourceScheduler) IsReadyResourceRunning() bool {
|
||||
|
||||
// processReadyResources 处理待处理资源
|
||||
func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
utils.Info("开始处理待处理资源...")
|
||||
utils.Debug("开始处理待处理资源...")
|
||||
|
||||
// 检查系统配置,确认是否启用自动处理
|
||||
autoProcess, err := r.systemConfigRepo.GetConfigBool(entity.ConfigKeyAutoProcessReadyResources)
|
||||
@@ -102,7 +102,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
}
|
||||
|
||||
if !autoProcess {
|
||||
utils.Info("自动处理待处理资源功能已禁用")
|
||||
utils.Debug("自动处理待处理资源功能已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,11 +115,11 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
}
|
||||
|
||||
if len(readyResources) == 0 {
|
||||
utils.Info("没有待处理的资源")
|
||||
utils.Debug("没有待处理的资源")
|
||||
return
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
|
||||
utils.Debug(fmt.Sprintf("找到 %d 个待处理资源,开始处理...", len(readyResources)))
|
||||
|
||||
processedCount := 0
|
||||
factory := panutils.GetInstance() // 使用单例模式
|
||||
@@ -132,7 +132,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
continue
|
||||
}
|
||||
if exits {
|
||||
utils.Info(fmt.Sprintf("资源已存在: %s", readyResource.URL))
|
||||
utils.Debug(fmt.Sprintf("资源已存在: %s", readyResource.URL))
|
||||
r.readyResourceRepo.Delete(readyResource.ID)
|
||||
continue
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
if updateErr := r.readyResourceRepo.Update(&readyResource); updateErr != nil {
|
||||
utils.Error(fmt.Sprintf("更新错误信息失败 (ID: %d): %v", readyResource.ID, updateErr))
|
||||
} else {
|
||||
utils.Info(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
|
||||
utils.Debug(fmt.Sprintf("已保存错误信息到资源 (ID: %d): %s", readyResource.ID, err.Error()))
|
||||
}
|
||||
|
||||
// 处理失败后删除资源,避免重复处理
|
||||
@@ -155,11 +155,13 @@ func (r *ReadyResourceScheduler) processReadyResources() {
|
||||
// 处理成功,删除readyResource
|
||||
r.readyResourceRepo.Delete(readyResource.ID)
|
||||
processedCount++
|
||||
utils.Info(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
|
||||
utils.Debug(fmt.Sprintf("成功处理资源: %s", readyResource.URL))
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
|
||||
if processedCount > 0 {
|
||||
utils.Info(fmt.Sprintf("待处理资源处理完成,共处理 %d 个资源", processedCount))
|
||||
}
|
||||
}
|
||||
|
||||
// convertReadyResourceToResource 将待处理资源转换为正式资源
|
||||
@@ -187,28 +189,28 @@ func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource en
|
||||
}
|
||||
|
||||
// 检查违禁词
|
||||
forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
if err == nil && forbiddenWords != "" {
|
||||
words := strings.Split(forbiddenWords, ",")
|
||||
var matchedWords []string
|
||||
title := strings.ToLower(resource.Title)
|
||||
description := strings.ToLower(resource.Description)
|
||||
// forbiddenWords, err := r.systemConfigRepo.GetConfigValue(entity.ConfigKeyForbiddenWords)
|
||||
// if err == nil && forbiddenWords != "" {
|
||||
// words := strings.Split(forbiddenWords, ",")
|
||||
// var matchedWords []string
|
||||
// title := strings.ToLower(resource.Title)
|
||||
// description := strings.ToLower(resource.Description)
|
||||
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
wordLower := strings.ToLower(word)
|
||||
if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
||||
matchedWords = append(matchedWords, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
// for _, word := range words {
|
||||
// word = strings.TrimSpace(word)
|
||||
// if word != "" {
|
||||
// wordLower := strings.ToLower(word)
|
||||
// if strings.Contains(title, wordLower) || strings.Contains(description, wordLower) {
|
||||
// matchedWords = append(matchedWords, word)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if len(matchedWords) > 0 {
|
||||
utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
||||
return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
||||
}
|
||||
}
|
||||
// if len(matchedWords) > 0 {
|
||||
// utils.Warn(fmt.Sprintf("资源包含违禁词: %s, 违禁词: %s", resource.Title, strings.Join(matchedWords, ", ")))
|
||||
// return fmt.Errorf("存在违禁词: %s", strings.Join(matchedWords, ", "))
|
||||
// }
|
||||
// }
|
||||
|
||||
// 不是夸克,直接保存
|
||||
if serviceType != panutils.Quark {
|
||||
@@ -342,6 +344,31 @@ func (r *ReadyResourceScheduler) convertReadyResourceToResource(readyResource en
|
||||
}
|
||||
}
|
||||
|
||||
// 同步到Meilisearch
|
||||
utils.Debug(fmt.Sprintf("准备同步资源到Meilisearch - 资源ID: %d, URL: %s", resource.ID, resource.URL))
|
||||
utils.Debug(fmt.Sprintf("globalMeilisearchManager: %v", globalMeilisearchManager != nil))
|
||||
|
||||
if globalMeilisearchManager != nil {
|
||||
utils.Debug(fmt.Sprintf("Meilisearch管理器已初始化,检查启用状态"))
|
||||
isEnabled := globalMeilisearchManager.IsEnabled()
|
||||
utils.Debug(fmt.Sprintf("Meilisearch启用状态: %v", isEnabled))
|
||||
|
||||
if isEnabled {
|
||||
utils.Debug(fmt.Sprintf("Meilisearch已启用,开始同步资源"))
|
||||
go func() {
|
||||
if err := globalMeilisearchManager.SyncResourceToMeilisearch(resource); err != nil {
|
||||
utils.Error("同步资源到Meilisearch失败: %v", err)
|
||||
} else {
|
||||
utils.Info(fmt.Sprintf("资源已同步到Meilisearch: %s", resource.URL))
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
utils.Debug("Meilisearch未启用,跳过同步")
|
||||
}
|
||||
} else {
|
||||
utils.Debug("Meilisearch管理器未初始化,跳过同步")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
0
scripts/build.sh
Normal file → Executable file
0
scripts/build.sh
Normal file → Executable file
@@ -33,6 +33,7 @@ get_git_branch() {
|
||||
# 构建Docker镜像
|
||||
build_docker() {
|
||||
local version=$(get_version $1)
|
||||
local skip_frontend=$2
|
||||
local git_commit=$(get_git_commit)
|
||||
local git_branch=$(get_git_branch)
|
||||
local build_time=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
@@ -42,23 +43,35 @@ build_docker() {
|
||||
echo -e "Git提交: ${GREEN}${git_commit}${NC}"
|
||||
echo -e "Git分支: ${GREEN}${git_branch}${NC}"
|
||||
echo -e "构建时间: ${GREEN}${build_time}${NC}"
|
||||
if [ "$skip_frontend" = "true" ]; then
|
||||
echo -e "跳过前端构建: ${GREEN}是${NC}"
|
||||
fi
|
||||
|
||||
# 直接使用 docker build,避免 buildx 的复杂性
|
||||
BUILD_CMD="docker build"
|
||||
echo -e "${BLUE}使用构建命令: ${BUILD_CMD}${NC}"
|
||||
|
||||
# 构建前端镜像
|
||||
echo -e "${YELLOW}构建前端镜像...${NC}"
|
||||
FRONTEND_CMD="${BUILD_CMD} --build-arg VERSION=${version} --build-arg GIT_COMMIT=${git_commit} --build-arg GIT_BRANCH=${git_branch} --build-arg BUILD_TIME=${build_time} --target frontend -t ctwj/urldb-frontend:${version} ."
|
||||
echo -e "${BLUE}执行命令: ${FRONTEND_CMD}${NC}"
|
||||
${BUILD_CMD} \
|
||||
--build-arg VERSION=${version} \
|
||||
--build-arg GIT_COMMIT=${git_commit} \
|
||||
--build-arg GIT_BRANCH=${git_branch} \
|
||||
--build-arg "BUILD_TIME=${build_time}" \
|
||||
--target frontend \
|
||||
-t ctwj/urldb-frontend:${version} \
|
||||
.
|
||||
# 构建前端镜像(可选)
|
||||
if [ "$skip_frontend" != "true" ]; then
|
||||
echo -e "${YELLOW}构建前端镜像...${NC}"
|
||||
FRONTEND_CMD="${BUILD_CMD} --build-arg VERSION=${version} --build-arg GIT_COMMIT=${git_commit} --build-arg GIT_BRANCH=${git_branch} --build-arg BUILD_TIME=${build_time} --target frontend -t ctwj/urldb-frontend:${version} ."
|
||||
echo -e "${BLUE}执行命令: ${FRONTEND_CMD}${NC}"
|
||||
${BUILD_CMD} \
|
||||
--build-arg VERSION=${version} \
|
||||
--build-arg GIT_COMMIT=${git_commit} \
|
||||
--build-arg GIT_BRANCH=${git_branch} \
|
||||
--build-arg "BUILD_TIME=${build_time}" \
|
||||
--target frontend \
|
||||
-t ctwj/urldb-frontend:${version} \
|
||||
.
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}前端构建失败!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}跳过前端构建${NC}"
|
||||
fi
|
||||
|
||||
# 构建后端镜像
|
||||
echo -e "${YELLOW}构建后端镜像...${NC}"
|
||||
@@ -72,12 +85,18 @@ build_docker() {
|
||||
--target backend \
|
||||
-t ctwj/urldb-backend:${version} \
|
||||
.
|
||||
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED}后端构建失败!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Docker构建完成!${NC}"
|
||||
echo -e "镜像标签:"
|
||||
echo -e " ${GREEN}ctwj/urldb-backend:${version}${NC}"
|
||||
echo -e " ${GREEN}ctwj/urldb-frontend:${version}${NC}"
|
||||
if [ "$skip_frontend" != "true" ]; then
|
||||
echo -e " ${GREEN}ctwj/urldb-frontend:${version}${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# 推送镜像
|
||||
@@ -110,26 +129,35 @@ clean_images() {
|
||||
show_help() {
|
||||
echo -e "${BLUE}Docker构建脚本${NC}"
|
||||
echo ""
|
||||
echo "用法: $0 [命令] [版本]"
|
||||
echo "用法: $0 [命令] [版本] [选项]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " build [version] 构建Docker镜像"
|
||||
echo " push [version] 推送镜像到Docker Hub"
|
||||
echo " clean [version] 清理Docker镜像"
|
||||
echo " help 显示此帮助信息"
|
||||
echo " build [version] [--skip-frontend] 构建Docker镜像"
|
||||
echo " push [version] 推送镜像到Docker Hub"
|
||||
echo " clean [version] 清理Docker镜像"
|
||||
echo " help 显示此帮助信息"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " --skip-frontend 跳过前端构建"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 build # 构建当前版本镜像"
|
||||
echo " $0 build 1.2.4 # 构建指定版本镜像"
|
||||
echo " $0 push 1.2.4 # 推送指定版本镜像"
|
||||
echo " $0 clean # 清理当前版本镜像"
|
||||
echo " $0 build # 构建当前版本镜像"
|
||||
echo " $0 build 1.2.4 # 构建指定版本镜像"
|
||||
echo " $0 build 1.2.4 --skip-frontend # 构建指定版本镜像,跳过前端"
|
||||
echo " $0 push 1.2.4 # 推送指定版本镜像"
|
||||
echo " $0 clean # 清理当前版本镜像"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
case $1 in
|
||||
"build")
|
||||
build_docker $2
|
||||
# 检查是否有 --skip-frontend 选项
|
||||
local skip_frontend="false"
|
||||
if [ "$3" = "--skip-frontend" ]; then
|
||||
skip_frontend="true"
|
||||
fi
|
||||
build_docker $2 $skip_frontend
|
||||
;;
|
||||
"push")
|
||||
push_images $2
|
||||
|
||||
@@ -221,12 +221,52 @@ func (m *MeilisearchManager) GetStatusWithHealthCheck() (MeilisearchStatus, erro
|
||||
|
||||
// SyncResourceToMeilisearch 同步资源到Meilisearch
|
||||
func (m *MeilisearchManager) SyncResourceToMeilisearch(resource *entity.Resource) error {
|
||||
utils.Debug(fmt.Sprintf("开始同步资源到Meilisearch - 资源ID: %d, URL: %s", resource.ID, resource.URL))
|
||||
|
||||
if m.service == nil || !m.service.IsEnabled() {
|
||||
return nil
|
||||
utils.Debug("Meilisearch服务未初始化或未启用")
|
||||
return fmt.Errorf("Meilisearch服务未初始化或未启用")
|
||||
}
|
||||
|
||||
doc := m.convertResourceToDocument(resource)
|
||||
err := m.service.BatchAddDocuments([]MeilisearchDocument{doc})
|
||||
// 先进行健康检查
|
||||
if err := m.service.HealthCheck(); err != nil {
|
||||
utils.Error(fmt.Sprintf("Meilisearch健康检查失败: %v", err))
|
||||
return fmt.Errorf("Meilisearch健康检查失败: %v", err)
|
||||
}
|
||||
|
||||
// 确保索引存在
|
||||
if err := m.service.CreateIndex(); err != nil {
|
||||
utils.Error(fmt.Sprintf("创建Meilisearch索引失败: %v", err))
|
||||
return fmt.Errorf("创建Meilisearch索引失败: %v", err)
|
||||
}
|
||||
|
||||
// 重新加载资源及其关联数据,确保Tags被正确加载
|
||||
resourcesWithRelations, err := m.repoMgr.ResourceRepository.FindByIDs([]uint{resource.ID})
|
||||
if err != nil {
|
||||
utils.Error(fmt.Sprintf("重新加载资源失败: %v", err))
|
||||
return fmt.Errorf("重新加载资源失败: %v", err)
|
||||
}
|
||||
|
||||
if len(resourcesWithRelations) == 0 {
|
||||
utils.Error(fmt.Sprintf("资源未找到: %d", resource.ID))
|
||||
return fmt.Errorf("资源未找到: %d", resource.ID)
|
||||
}
|
||||
|
||||
resourceWithRelations := resourcesWithRelations[0]
|
||||
doc := m.convertResourceToDocument(&resourceWithRelations)
|
||||
|
||||
// 添加调试日志,记录标签数量
|
||||
utils.Debug(fmt.Sprintf("资源ID %d 的标签数量: %d", resource.ID, len(resourceWithRelations.Tags)))
|
||||
for i, tag := range resourceWithRelations.Tags {
|
||||
utils.Debug(fmt.Sprintf(" 标签 %d: ID=%d, Name=%s", i+1, tag.ID, tag.Name))
|
||||
}
|
||||
|
||||
// 验证转换后的文档
|
||||
utils.Debug(fmt.Sprintf("转换后的文档标签数量: %d", len(doc.Tags)))
|
||||
if len(doc.Tags) > 0 {
|
||||
utils.Debug(fmt.Sprintf("转换后的文档标签内容: %v", doc.Tags))
|
||||
}
|
||||
err = m.service.BatchAddDocuments([]MeilisearchDocument{doc})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -395,11 +435,14 @@ func (m *MeilisearchManager) syncAllResourcesInternal() {
|
||||
default:
|
||||
}
|
||||
|
||||
// 转换为Meilisearch文档(使用缓存)
|
||||
// 转换为Meilisearch文档(确保Tags被正确加载)
|
||||
var docs []MeilisearchDocument
|
||||
for _, resource := range resources {
|
||||
utils.Debug(fmt.Sprintf("批量同步开始处理资源 %d,标签数量: %d", resource.ID, len(resource.Tags)))
|
||||
// 使用带缓存的转换方法,但传入的资源已经预加载了Tags数据
|
||||
doc := m.convertResourceToDocumentWithCache(&resource, categoryCache, panCache)
|
||||
docs = append(docs, doc)
|
||||
utils.Debug(fmt.Sprintf("批量同步资源 %d 处理完成,最终标签数量: %d", resource.ID, len(doc.Tags)))
|
||||
}
|
||||
|
||||
// 检查是否需要停止
|
||||
@@ -621,12 +664,22 @@ func (m *MeilisearchManager) convertResourceToDocument(resource *entity.Resource
|
||||
|
||||
// 获取标签 - 从关联的Tags字段获取
|
||||
var tagNames []string
|
||||
if resource.Tags != nil {
|
||||
for _, tag := range resource.Tags {
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
if len(resource.Tags) > 0 {
|
||||
utils.Debug(fmt.Sprintf("处理资源 %d 的 %d 个标签", resource.ID, len(resource.Tags)))
|
||||
for i, tag := range resource.Tags {
|
||||
if tag.Name != "" {
|
||||
utils.Debug(fmt.Sprintf("标签 %d: ID=%d, Name='%s'", i+1, tag.ID, tag.Name))
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
} else {
|
||||
utils.Debug(fmt.Sprintf("标签 %d: ID=%d, Name为空,跳过", i+1, tag.ID))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
utils.Debug(fmt.Sprintf("资源 %d 没有关联的标签", resource.ID))
|
||||
}
|
||||
|
||||
utils.Debug(fmt.Sprintf("资源 %d 最终标签数量: %d", resource.ID, len(tagNames)))
|
||||
|
||||
return MeilisearchDocument{
|
||||
ID: resource.ID,
|
||||
Title: resource.Title,
|
||||
@@ -640,6 +693,7 @@ func (m *MeilisearchManager) convertResourceToDocument(resource *entity.Resource
|
||||
PanName: panName,
|
||||
PanID: resource.PanID,
|
||||
Author: resource.Author,
|
||||
Cover: resource.Cover,
|
||||
CreatedAt: resource.CreatedAt,
|
||||
UpdatedAt: resource.UpdatedAt,
|
||||
}
|
||||
@@ -664,12 +718,22 @@ func (m *MeilisearchManager) convertResourceToDocumentWithCache(resource *entity
|
||||
|
||||
// 获取标签 - 从关联的Tags字段获取
|
||||
var tagNames []string
|
||||
if resource.Tags != nil {
|
||||
for _, tag := range resource.Tags {
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
if len(resource.Tags) > 0 {
|
||||
utils.Debug(fmt.Sprintf("批量同步处理资源 %d 的 %d 个标签", resource.ID, len(resource.Tags)))
|
||||
for i, tag := range resource.Tags {
|
||||
if tag.Name != "" {
|
||||
utils.Debug(fmt.Sprintf("批量同步标签 %d: ID=%d, Name='%s'", i+1, tag.ID, tag.Name))
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
} else {
|
||||
utils.Debug(fmt.Sprintf("批量同步标签 %d: ID=%d, Name为空,跳过", i+1, tag.ID))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
utils.Debug(fmt.Sprintf("批量同步资源 %d 没有关联的标签", resource.ID))
|
||||
}
|
||||
|
||||
utils.Debug(fmt.Sprintf("批量同步资源 %d 最终标签数量: %d", resource.ID, len(tagNames)))
|
||||
|
||||
return MeilisearchDocument{
|
||||
ID: resource.ID,
|
||||
Title: resource.Title,
|
||||
@@ -683,6 +747,7 @@ func (m *MeilisearchManager) convertResourceToDocumentWithCache(resource *entity
|
||||
PanName: panName,
|
||||
PanID: resource.PanID,
|
||||
Author: resource.Author,
|
||||
Cover: resource.Cover,
|
||||
CreatedAt: resource.CreatedAt,
|
||||
UpdatedAt: resource.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type MeilisearchDocument struct {
|
||||
PanName string `json:"pan_name"`
|
||||
PanID *uint `json:"pan_id"`
|
||||
Author string `json:"author"`
|
||||
Cover string `json:"cover"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// 高亮字段
|
||||
@@ -89,7 +90,7 @@ func (m *MeilisearchService) HealthCheck() error {
|
||||
// 使用官方SDK的健康检查
|
||||
_, err := m.client.Health()
|
||||
if err != nil {
|
||||
utils.Error("Meilisearch健康检查失败: %v", err)
|
||||
// utils.Error("Meilisearch健康检查失败: %v", err)
|
||||
return fmt.Errorf("Meilisearch健康检查失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -197,27 +198,38 @@ func (m *MeilisearchService) UpdateIndexSettings() error {
|
||||
|
||||
// BatchAddDocuments 批量添加文档
|
||||
func (m *MeilisearchService) BatchAddDocuments(docs []MeilisearchDocument) error {
|
||||
utils.Debug(fmt.Sprintf("开始批量添加文档到Meilisearch - 文档数量: %d", len(docs)))
|
||||
|
||||
if !m.enabled {
|
||||
return nil
|
||||
utils.Debug("Meilisearch未启用,跳过批量添加")
|
||||
return fmt.Errorf("Meilisearch未启用")
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
utils.Debug("文档列表为空,跳过批量添加")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 转换为interface{}切片
|
||||
var documents []interface{}
|
||||
for _, doc := range docs {
|
||||
for i, doc := range docs {
|
||||
utils.Debug(fmt.Sprintf("转换文档 %d - ID: %d, 标题: %s, 标签数量: %d", i+1, doc.ID, doc.Title, len(doc.Tags)))
|
||||
if len(doc.Tags) > 0 {
|
||||
utils.Debug(fmt.Sprintf("文档 %d 的标签: %v", i+1, doc.Tags))
|
||||
}
|
||||
documents = append(documents, doc)
|
||||
}
|
||||
|
||||
utils.Debug(fmt.Sprintf("开始调用Meilisearch API添加 %d 个文档", len(documents)))
|
||||
|
||||
// 批量添加文档
|
||||
_, err := m.index.AddDocuments(documents, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("批量添加文档失败: %v", err)
|
||||
utils.Error(fmt.Sprintf("Meilisearch批量添加文档失败: %v", err))
|
||||
return fmt.Errorf("Meilisearch批量添加文档失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Debug("批量添加 %d 个文档到Meilisearch成功", len(docs))
|
||||
utils.Debug(fmt.Sprintf("成功批量添加 %d 个文档到Meilisearch", len(docs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -405,6 +417,13 @@ func (m *MeilisearchService) Search(query string, filters map[string]interface{}
|
||||
doc.Author = author
|
||||
}
|
||||
}
|
||||
case "cover":
|
||||
if rawCover, ok := value.(json.RawMessage); ok {
|
||||
var cover string
|
||||
if err := json.Unmarshal(rawCover, &cover); err == nil {
|
||||
doc.Cover = cover
|
||||
}
|
||||
}
|
||||
case "created_at":
|
||||
if rawCreatedAt, ok := value.(json.RawMessage); ok {
|
||||
var createdAt string
|
||||
@@ -536,6 +555,25 @@ func (m *MeilisearchService) GetIndexStats() (map[string]interface{}, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteDocument 删除单个文档
|
||||
func (m *MeilisearchService) DeleteDocument(documentID uint) error {
|
||||
if !m.enabled {
|
||||
return fmt.Errorf("Meilisearch未启用")
|
||||
}
|
||||
|
||||
utils.Debug("开始删除Meilisearch文档 - ID: %d", documentID)
|
||||
|
||||
// 删除单个文档
|
||||
documentIDStr := fmt.Sprintf("%d", documentID)
|
||||
_, err := m.index.DeleteDocument(documentIDStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除Meilisearch文档失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Debug("成功删除Meilisearch文档 - ID: %d", documentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearIndex 清空索引
|
||||
func (m *MeilisearchService) ClearIndex() error {
|
||||
if !m.enabled {
|
||||
|
||||
1760
services/telegram_bot_service.go
Normal file
1760
services/telegram_bot_service.go
Normal file
File diff suppressed because it is too large
Load Diff
533
task/expansion_processor.go
Normal file
533
task/expansion_processor.go
Normal file
@@ -0,0 +1,533 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
pan "github.com/ctwj/urldb/common"
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
"github.com/ctwj/urldb/db/repo"
|
||||
"github.com/ctwj/urldb/utils"
|
||||
)
|
||||
|
||||
// ExpansionProcessor 扩容任务处理器
|
||||
type ExpansionProcessor struct {
|
||||
repoMgr *repo.RepositoryManager
|
||||
}
|
||||
|
||||
// NewExpansionProcessor 创建扩容任务处理器
|
||||
func NewExpansionProcessor(repoMgr *repo.RepositoryManager) *ExpansionProcessor {
|
||||
return &ExpansionProcessor{
|
||||
repoMgr: repoMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaskType 获取任务类型
|
||||
func (ep *ExpansionProcessor) GetTaskType() string {
|
||||
return "expansion"
|
||||
}
|
||||
|
||||
// ExpansionInput 扩容任务输入数据结构
|
||||
type ExpansionInput struct {
|
||||
PanAccountID uint `json:"pan_account_id"`
|
||||
DataSource map[string]interface{} `json:"data_source,omitempty"`
|
||||
}
|
||||
|
||||
// TransferredResource 转存成功的资源信息
|
||||
type TransferredResource struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ExpansionOutput 扩容任务输出数据结构
|
||||
type ExpansionOutput struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Time string `json:"time"`
|
||||
TransferredResources []TransferredResource `json:"transferred_resources,omitempty"`
|
||||
}
|
||||
|
||||
// Process 处理扩容任务项
|
||||
func (ep *ExpansionProcessor) Process(ctx context.Context, taskID uint, item *entity.TaskItem) error {
|
||||
utils.Info("开始处理扩容任务项: %d", item.ID)
|
||||
|
||||
// 解析输入数据
|
||||
var input ExpansionInput
|
||||
if err := json.Unmarshal([]byte(item.InputData), &input); err != nil {
|
||||
return fmt.Errorf("解析输入数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证输入数据
|
||||
if err := ep.validateInput(&input); err != nil {
|
||||
return fmt.Errorf("输入数据验证失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查账号是否已经扩容过
|
||||
exists, err := ep.checkExpansionExists(input.PanAccountID)
|
||||
if err != nil {
|
||||
utils.Error("检查扩容记录失败: %v", err)
|
||||
return fmt.Errorf("检查扩容记录失败: %v", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
output := ExpansionOutput{
|
||||
Success: false,
|
||||
Message: "账号已扩容过",
|
||||
Error: "每个账号只能扩容一次",
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Info("账号已扩容过,跳过扩容: 账号ID %d", input.PanAccountID)
|
||||
return fmt.Errorf("账号已扩容过")
|
||||
}
|
||||
|
||||
// 检查账号类型(只支持quark账号)
|
||||
if err := ep.checkAccountType(input.PanAccountID); err != nil {
|
||||
output := ExpansionOutput{
|
||||
Success: false,
|
||||
Message: "账号类型不支持扩容",
|
||||
Error: err.Error(),
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Error("账号类型不支持扩容: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 执行扩容操作(传入数据源)
|
||||
transferred, err := ep.performExpansion(ctx, input.PanAccountID, input.DataSource)
|
||||
if err != nil {
|
||||
output := ExpansionOutput{
|
||||
Success: false,
|
||||
Message: "扩容失败",
|
||||
Error: err.Error(),
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Error("扩容任务项处理失败: %d, 错误: %v", item.ID, err)
|
||||
return fmt.Errorf("扩容失败: %v", err)
|
||||
}
|
||||
|
||||
// 扩容成功
|
||||
output := ExpansionOutput{
|
||||
Success: true,
|
||||
Message: "扩容成功",
|
||||
Time: utils.GetCurrentTimeString(),
|
||||
TransferredResources: transferred,
|
||||
}
|
||||
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
item.OutputData = string(outputJSON)
|
||||
|
||||
utils.Info("扩容任务项处理完成: %d, 账号ID: %d", item.ID, input.PanAccountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateInput 验证输入数据
|
||||
func (ep *ExpansionProcessor) validateInput(input *ExpansionInput) error {
|
||||
if input.PanAccountID == 0 {
|
||||
return fmt.Errorf("账号ID不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkExpansionExists 检查账号是否已经扩容过
|
||||
func (ep *ExpansionProcessor) checkExpansionExists(panAccountID uint) (bool, error) {
|
||||
// 查询所有expansion类型的任务
|
||||
tasks, _, err := ep.repoMgr.TaskRepository.GetList(1, 1000, "expansion", "completed")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("获取扩容任务列表失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查每个任务的配置中是否包含该账号ID
|
||||
for _, task := range tasks {
|
||||
if task.Config != "" {
|
||||
var taskConfig map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(task.Config), &taskConfig); err == nil {
|
||||
if configAccountID, ok := taskConfig["pan_account_id"].(float64); ok {
|
||||
if uint(configAccountID) == panAccountID {
|
||||
// 找到了该账号的扩容任务,检查任务状态
|
||||
if task.Status == "completed" {
|
||||
// 如果任务已完成,说明已经扩容过
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// checkAccountType 检查账号类型(只支持quark账号)
|
||||
func (ep *ExpansionProcessor) checkAccountType(panAccountID uint) error {
|
||||
// 获取账号信息
|
||||
cks, err := ep.repoMgr.CksRepository.FindByID(panAccountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取账号信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查是否为quark账号
|
||||
if cks.ServiceType != "quark" {
|
||||
return fmt.Errorf("只支持quark账号扩容,当前账号类型: %s", cks.ServiceType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// performExpansion 执行扩容操作
|
||||
func (ep *ExpansionProcessor) performExpansion(ctx context.Context, panAccountID uint, dataSource map[string]interface{}) ([]TransferredResource, error) {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
utils.Info("执行扩容操作,账号ID: %d, 数据源: %v", panAccountID, dataSource)
|
||||
|
||||
transferred := []TransferredResource{}
|
||||
|
||||
// 获取账号信息
|
||||
account, err := ep.repoMgr.CksRepository.FindByID(panAccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取账号信息失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
service, err := factory.CreatePanServiceByType(pan.Quark, &pan.PanConfig{
|
||||
URL: "",
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建网盘服务失败: %v", err)
|
||||
}
|
||||
service.SetCKSRepository(ep.repoMgr.CksRepository, *account)
|
||||
|
||||
// 定义扩容分类列表(按优先级排序)
|
||||
categories := []string{
|
||||
"情色", "喜剧", "动作", "科幻", "动画", "悬疑", "犯罪", "惊悚",
|
||||
"冒险", "恐怖", "战争", "传记", "剧情", "爱情", "家庭", "儿童",
|
||||
"音乐", "历史", "奇幻", "歌舞", "武侠", "灾难", "西部", "古装", "运动",
|
||||
}
|
||||
|
||||
// 获取数据源类型
|
||||
dataSourceType := "internal"
|
||||
var thirdPartyURL string
|
||||
if dataSource != nil {
|
||||
if dsType, ok := dataSource["type"].(string); ok {
|
||||
dataSourceType = dsType
|
||||
if dsType == "third-party" {
|
||||
if url, ok := dataSource["url"].(string); ok {
|
||||
thirdPartyURL = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("使用数据源类型: %s", dataSourceType)
|
||||
|
||||
totalTransferred := 0
|
||||
totalFailed := 0
|
||||
|
||||
// 逐个处理分类
|
||||
for _, category := range categories {
|
||||
utils.Info("开始处理分类: %s", category)
|
||||
|
||||
// 获取该分类的资源
|
||||
resources, err := ep.getHotResources(category)
|
||||
if err != nil {
|
||||
utils.Error("获取分类 %s 的资源失败: %v", category, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
utils.Info("分类 %s 没有可用资源,跳过", category)
|
||||
continue
|
||||
}
|
||||
|
||||
utils.Info("分类 %s 获取到 %d 个资源", category, len(resources))
|
||||
|
||||
// 转存该分类的资源(限制每个分类最多转存20个)
|
||||
maxPerCategory := 20
|
||||
transferredCount := 0
|
||||
|
||||
for _, resource := range resources {
|
||||
if transferredCount >= maxPerCategory {
|
||||
break
|
||||
}
|
||||
|
||||
// 检查是否还有存储空间
|
||||
hasSpace, err := ep.checkStorageSpace(service, &account.Ck)
|
||||
if err != nil {
|
||||
utils.Error("检查存储空间失败: %v", err)
|
||||
return transferred, fmt.Errorf("检查存储空间失败: %v", err)
|
||||
}
|
||||
|
||||
if !hasSpace {
|
||||
utils.Info("存储空间不足,停止扩容,但保存已转存的资源")
|
||||
// 存储空间不足时,停止继续转存,但返回已转存的资源作为成功结果
|
||||
break
|
||||
}
|
||||
|
||||
// 获取资源 , dataSourceType, thirdPartyURL
|
||||
resource, err := ep.getResourcesByHot(resource, dataSourceType, thirdPartyURL, *account, service)
|
||||
if resource == nil || err != nil {
|
||||
if resource != nil {
|
||||
utils.Error("获取资源失败: %s, 错误: %v", resource.Title, err)
|
||||
} else {
|
||||
utils.Error("获取资源失败, 错误: %v", err)
|
||||
}
|
||||
totalFailed++
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行转存
|
||||
saveURL, err := ep.transferResource(ctx, service, resource, *account)
|
||||
if err != nil {
|
||||
utils.Error("转存资源失败: %s, 错误: %v", resource.Title, err)
|
||||
totalFailed++
|
||||
continue
|
||||
}
|
||||
|
||||
// 随机休眠1-3秒,避免请求过于频繁
|
||||
sleepDuration := time.Duration(rand.Intn(3)+1) * time.Second
|
||||
time.Sleep(sleepDuration)
|
||||
|
||||
// 保存转存结果到任务输出
|
||||
transferred = append(transferred, TransferredResource{
|
||||
Title: resource.Title,
|
||||
URL: saveURL,
|
||||
})
|
||||
|
||||
totalTransferred++
|
||||
transferredCount++
|
||||
utils.Info("成功转存资源: %s -> %s", resource.Title, saveURL)
|
||||
|
||||
// 每转存5个资源检查一次存储空间
|
||||
if totalTransferred%5 == 0 {
|
||||
utils.Info("已转存 %d 个资源,检查存储空间", totalTransferred)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("分类 %s 处理完成,转存 %d 个资源", category, transferredCount)
|
||||
}
|
||||
|
||||
utils.Info("扩容完成,总共转存: %d 个资源,失败: %d 个资源", totalTransferred, totalFailed)
|
||||
return transferred, nil
|
||||
}
|
||||
|
||||
// getResourcesForCategory 获取指定分类的资源
|
||||
func (ep *ExpansionProcessor) getResourcesByHot(
|
||||
resource *entity.HotDrama, dataSourceType,
|
||||
thirdPartyURL string,
|
||||
entity entity.Cks,
|
||||
service pan.PanService,
|
||||
) (*entity.Resource, error) {
|
||||
if dataSourceType == "third-party" && thirdPartyURL != "" {
|
||||
// 从第三方API获取资源
|
||||
return ep.getResourcesFromThirdPartyAPI(resource, thirdPartyURL)
|
||||
}
|
||||
|
||||
// 从内部数据库获取资源
|
||||
return ep.getResourcesFromInternalDB(resource, entity, service)
|
||||
}
|
||||
|
||||
// getResourcesFromInternalDB 根据 HotDrama 的title 获取数据库中资源,并且资源的类型和 account 的资源类型一致
|
||||
func (ep *ExpansionProcessor) getResourcesFromInternalDB(HotDrama *entity.HotDrama, account entity.Cks, service pan.PanService) (*entity.Resource, error) {
|
||||
// 修改配置 isType = 1 只检测,不转存
|
||||
service.UpdateConfig(&pan.PanConfig{
|
||||
URL: "",
|
||||
ExpiredType: 0,
|
||||
IsType: 1,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
panID := account.PanID
|
||||
|
||||
// 1. 搜索标题
|
||||
params := map[string]interface{}{
|
||||
"search": HotDrama.Title,
|
||||
"pan_id": panID,
|
||||
"is_valid": true,
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
}
|
||||
resources, _, err := ep.repoMgr.ResourceRepository.SearchWithFilters(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索资源失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查结果是否有效,通过服务验证
|
||||
for _, res := range resources {
|
||||
if res.IsValid && res.URL != "" {
|
||||
// 使用服务验证资源是否可转存
|
||||
shareID, _ := pan.ExtractShareId(res.URL)
|
||||
if shareID != "" {
|
||||
result, err := service.Transfer(shareID)
|
||||
if err == nil && result != nil && result.Success {
|
||||
return &res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 没有有效资源,返回错误信息
|
||||
return nil, fmt.Errorf("未找到有效的资源")
|
||||
}
|
||||
|
||||
// getResourcesFromInternalDB 从内部数据库获取资源
|
||||
func (ep *ExpansionProcessor) getHotResources(category string) ([]*entity.HotDrama, error) {
|
||||
// 获取该分类下sub_type为"排行"的资源
|
||||
dramas, _, err := ep.repoMgr.HotDramaRepository.FindByCategoryAndSubType(category, "排行", 1, 20)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取分类 %s 的资源失败: %v", category, err)
|
||||
}
|
||||
|
||||
// 如果没有找到"排行"类型的资源,尝试获取该分类下的所有资源
|
||||
if len(dramas) == 0 {
|
||||
dramas, _, err = ep.repoMgr.HotDramaRepository.FindByCategory(category, 1, 20)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取分类 %s 的资源失败: %v", category, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为指针数组
|
||||
result := make([]*entity.HotDrama, len(dramas))
|
||||
for i := range dramas {
|
||||
result[i] = &dramas[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getResourcesFromThirdPartyAPI 从第三方API获取资源
|
||||
func (ep *ExpansionProcessor) getResourcesFromThirdPartyAPI(resource *entity.HotDrama, apiURL string) (*entity.Resource, error) {
|
||||
// 构建API请求URL,添加分类参数
|
||||
// requestURL := fmt.Sprintf("%s?category=%s&limit=20", apiURL, resource)
|
||||
|
||||
// TODO 使用第三方API接口,请求资源
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// checkStorageSpace 检查存储空间是否足够
|
||||
func (ep *ExpansionProcessor) checkStorageSpace(service pan.PanService, ck *string) (bool, error) {
|
||||
userInfo, err := service.GetUserInfo(ck)
|
||||
if err != nil {
|
||||
utils.Error("获取用户信息失败: %v", err)
|
||||
// 如果无法获取用户信息,假设还有空间继续
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 检查是否还有足够的空间(保留至少10GB空间)
|
||||
const reservedSpaceGB = 100
|
||||
reservedSpaceBytes := int64(reservedSpaceGB * 1024 * 1024 * 1024)
|
||||
|
||||
if userInfo.TotalSpace-userInfo.UsedSpace <= reservedSpaceBytes {
|
||||
utils.Info("存储空间不足,已使用: %d bytes,总容量: %d bytes",
|
||||
userInfo.UsedSpace, userInfo.TotalSpace)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// transferResource 执行单个资源的转存
|
||||
func (ep *ExpansionProcessor) transferResource(ctx context.Context, service pan.PanService, res *entity.Resource, account entity.Cks) (string, error) {
|
||||
// 修改配置 isType = 0 转存
|
||||
service.UpdateConfig(&pan.PanConfig{
|
||||
URL: "",
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
|
||||
// 如果没有URL,跳过转存
|
||||
if res.URL == "" {
|
||||
return "", fmt.Errorf("资源 %s 没有有效的URL", res.URL)
|
||||
}
|
||||
|
||||
// 提取分享ID
|
||||
shareID, _ := pan.ExtractShareId(res.URL)
|
||||
if shareID == "" {
|
||||
return "", fmt.Errorf("无法从URL %s 提取分享ID", res.URL)
|
||||
}
|
||||
|
||||
// 执行转存
|
||||
result, err := service.Transfer(shareID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
if result == nil || !result.Success {
|
||||
errorMsg := "转存失败"
|
||||
if result != nil {
|
||||
errorMsg = result.Message
|
||||
}
|
||||
return "", fmt.Errorf("转存失败: %s", errorMsg)
|
||||
}
|
||||
|
||||
// 提取转存链接
|
||||
var saveURL string
|
||||
if result.Data != nil {
|
||||
if data, ok := result.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = result.ShareURL
|
||||
}
|
||||
|
||||
if saveURL == "" {
|
||||
return "", fmt.Errorf("转存成功但未获取到分享链接")
|
||||
}
|
||||
|
||||
return saveURL, nil
|
||||
}
|
||||
|
||||
// recordTransferredResource 记录转存成功的资源
|
||||
// func (ep *ExpansionProcessor) recordTransferredResource(drama *entity.HotDrama, accountID uint, saveURL string) error {
|
||||
// // 获取夸克网盘的平台ID
|
||||
// panIDInt, err := ep.repoMgr.PanRepository.FindIdByServiceType("quark")
|
||||
// if err != nil {
|
||||
// utils.Error("获取夸克网盘平台ID失败: %v", err)
|
||||
// return err
|
||||
// }
|
||||
|
||||
// // 转换为uint
|
||||
// panID := uint(panIDInt)
|
||||
|
||||
// // 创建资源记录
|
||||
// resource := &entity.Resource{
|
||||
// Title: drama.Title,
|
||||
// URL: drama.PosterURL,
|
||||
// SaveURL: saveURL,
|
||||
// PanID: &panID,
|
||||
// CreatedAt: time.Now(),
|
||||
// UpdatedAt: time.Now(),
|
||||
// IsValid: true,
|
||||
// IsPublic: false, // 扩容资源默认不公开
|
||||
// }
|
||||
|
||||
// // 保存到数据库
|
||||
// err = ep.repoMgr.ResourceRepository.Create(resource)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("保存资源记录失败: %v", err)
|
||||
// }
|
||||
|
||||
// utils.Info("成功记录转存资源: %s (ID: %d)", drama.Title, resource.ID)
|
||||
// return nil
|
||||
// }
|
||||
@@ -39,7 +39,7 @@ func (tm *TaskManager) RegisterProcessor(processor TaskProcessor) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
tm.processors[processor.GetTaskType()] = processor
|
||||
utils.Info("注册任务处理器: %s", processor.GetTaskType())
|
||||
utils.Debug("注册任务处理器: %s", processor.GetTaskType())
|
||||
}
|
||||
|
||||
// getRegisteredProcessors 获取已注册的处理器列表(用于调试)
|
||||
@@ -56,11 +56,11 @@ func (tm *TaskManager) StartTask(taskID uint) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
utils.Info("StartTask: 尝试启动任务 %d", taskID)
|
||||
utils.Debug("StartTask: 尝试启动任务 %d", taskID)
|
||||
|
||||
// 检查任务是否已在运行
|
||||
if _, exists := tm.running[taskID]; exists {
|
||||
utils.Info("任务 %d 已在运行中", taskID)
|
||||
utils.Debug("任务 %d 已在运行中", taskID)
|
||||
return fmt.Errorf("任务 %d 已在运行中", taskID)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (tm *TaskManager) StartTask(taskID uint) error {
|
||||
return fmt.Errorf("获取任务失败: %v", err)
|
||||
}
|
||||
|
||||
utils.Info("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
|
||||
utils.Debug("StartTask: 获取到任务 %d, 类型: %s, 状态: %s", task.ID, task.Type, task.Status)
|
||||
|
||||
// 获取处理器
|
||||
processor, exists := tm.processors[string(task.Type)]
|
||||
@@ -80,13 +80,13 @@ func (tm *TaskManager) StartTask(taskID uint) error {
|
||||
return fmt.Errorf("未找到任务类型 %s 的处理器", task.Type)
|
||||
}
|
||||
|
||||
utils.Info("StartTask: 找到处理器 %s", task.Type)
|
||||
utils.Debug("StartTask: 找到处理器 %s", task.Type)
|
||||
|
||||
// 创建上下文
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
tm.running[taskID] = cancel
|
||||
|
||||
utils.Info("StartTask: 启动后台任务协程")
|
||||
utils.Debug("StartTask: 启动后台任务协程")
|
||||
// 启动后台任务
|
||||
go tm.processTask(ctx, task, processor)
|
||||
|
||||
@@ -189,10 +189,10 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
||||
tm.mu.Lock()
|
||||
delete(tm.running, task.ID)
|
||||
tm.mu.Unlock()
|
||||
utils.Info("processTask: 任务 %d 处理完成,清理资源", task.ID)
|
||||
utils.Debug("processTask: 任务 %d 处理完成,清理资源", task.ID)
|
||||
}()
|
||||
|
||||
utils.Info("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
|
||||
utils.Debug("processTask: 开始处理任务: %d, 类型: %s", task.ID, task.Type)
|
||||
|
||||
// 更新任务状态为运行中
|
||||
err := tm.repoMgr.TaskRepository.UpdateStatus(task.ID, "running")
|
||||
@@ -201,6 +201,12 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
||||
return
|
||||
}
|
||||
|
||||
// 更新任务开始时间
|
||||
err = tm.repoMgr.TaskRepository.UpdateStartedAt(task.ID)
|
||||
if err != nil {
|
||||
utils.Error("更新任务开始时间失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取任务项统计信息,用于计算正确的进度
|
||||
stats, err := tm.repoMgr.TaskItemRepository.GetStatsByTaskID(task.ID)
|
||||
if err != nil {
|
||||
@@ -230,7 +236,7 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
||||
|
||||
// 如果当前批次有处理中的任务项,重置它们为pending状态(服务器重启恢复)
|
||||
if processingItems > 0 {
|
||||
utils.Info("任务 %d 发现 %d 个处理中的任务项,重置为pending状态", task.ID, processingItems)
|
||||
utils.Debug("任务 %d 发现 %d 个处理中的任务项,重置为pending状态", task.ID, processingItems)
|
||||
err = tm.repoMgr.TaskItemRepository.ResetProcessingItems(task.ID)
|
||||
if err != nil {
|
||||
utils.Error("重置处理中任务项失败: %v", err)
|
||||
@@ -249,13 +255,13 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
||||
successItems := completedItems
|
||||
failedItems := initialFailedItems
|
||||
|
||||
utils.Info("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
|
||||
utils.Debug("任务 %d 统计信息: 总计=%d, 已完成=%d, 已失败=%d, 待处理=%d",
|
||||
task.ID, totalItems, completedItems, failedItems, currentBatchItems)
|
||||
|
||||
for _, item := range items {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
utils.Info("任务 %d 被取消", task.ID)
|
||||
utils.Debug("任务 %d 被取消", task.ID)
|
||||
return
|
||||
default:
|
||||
// 处理单个任务项
|
||||
@@ -294,6 +300,14 @@ func (tm *TaskManager) processTask(ctx context.Context, task *entity.Task, proce
|
||||
utils.Error("更新任务状态失败: %v", err)
|
||||
}
|
||||
|
||||
// 如果任务完成,更新完成时间
|
||||
if status == "completed" || status == "failed" || status == "partial_success" {
|
||||
err = tm.repoMgr.TaskRepository.UpdateCompletedAt(task.ID)
|
||||
if err != nil {
|
||||
utils.Error("更新任务完成时间失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("任务 %d 处理完成: %s", task.ID, message)
|
||||
}
|
||||
|
||||
@@ -324,13 +338,21 @@ func (tm *TaskManager) processTaskItem(ctx context.Context, taskID uint, item *e
|
||||
}
|
||||
|
||||
// 处理成功
|
||||
outputData := map[string]interface{}{
|
||||
"success": true,
|
||||
"time": utils.GetCurrentTime(),
|
||||
// 如果处理器已经设置了 output_data(比如 ExpansionProcessor),则不覆盖
|
||||
var outputJSON string
|
||||
if item.OutputData == "" {
|
||||
outputData := map[string]interface{}{
|
||||
"success": true,
|
||||
"time": utils.GetCurrentTime(),
|
||||
}
|
||||
outputBytes, _ := json.Marshal(outputData)
|
||||
outputJSON = string(outputBytes)
|
||||
} else {
|
||||
// 使用处理器设置的 output_data
|
||||
outputJSON = item.OutputData
|
||||
}
|
||||
outputJSON, _ := json.Marshal(outputData)
|
||||
|
||||
err = tm.repoMgr.TaskItemRepository.UpdateStatusAndOutput(item.ID, "completed", string(outputJSON))
|
||||
err = tm.repoMgr.TaskItemRepository.UpdateStatusAndOutput(item.ID, "completed", outputJSON)
|
||||
if err != nil {
|
||||
utils.Error("更新成功任务项状态失败: %v", err)
|
||||
}
|
||||
@@ -369,6 +391,12 @@ func (tm *TaskManager) markTaskFailed(taskID uint, message string) {
|
||||
if err != nil {
|
||||
utils.Error("标记任务失败状态失败: %v", err)
|
||||
}
|
||||
|
||||
// 更新任务完成时间
|
||||
err = tm.repoMgr.TaskRepository.UpdateCompletedAt(taskID)
|
||||
if err != nil {
|
||||
utils.Error("更新任务完成时间失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetTaskStatus 获取任务状态
|
||||
|
||||
@@ -80,6 +80,10 @@ func (tp *TransferProcessor) Process(ctx context.Context, taskID uint, item *ent
|
||||
}
|
||||
}
|
||||
|
||||
if len(selectedAccounts) == 0 {
|
||||
utils.Error("失败: %v", "没有指定转存账号")
|
||||
}
|
||||
|
||||
// 检查资源是否已存在
|
||||
exists, existingResource, err := tp.checkResourceExists(input.URL)
|
||||
if err != nil {
|
||||
@@ -108,8 +112,14 @@ func (tp *TransferProcessor) Process(ctx context.Context, taskID uint, item *ent
|
||||
}
|
||||
}
|
||||
|
||||
// 查询出 账号列表
|
||||
cks, err := tp.repoMgr.CksRepository.FindByIds(selectedAccounts)
|
||||
if err != nil {
|
||||
utils.Error("读取账号失败: %v", err)
|
||||
}
|
||||
|
||||
// 执行转存操作
|
||||
resourceID, saveURL, err := tp.performTransfer(ctx, &input, selectedAccounts)
|
||||
resourceID, saveURL, err := tp.performTransfer(ctx, &input, cks)
|
||||
if err != nil {
|
||||
// 转存失败,更新输出数据
|
||||
output := TransferOutput{
|
||||
@@ -175,10 +185,17 @@ func (tp *TransferProcessor) validateInput(input *TransferInput) error {
|
||||
|
||||
// isValidURL 验证URL格式
|
||||
func (tp *TransferProcessor) isValidURL(url string) bool {
|
||||
// 简单的URL验证,可以根据需要扩展
|
||||
quarkPattern := `https://pan\.quark\.cn/s/[a-zA-Z0-9]+`
|
||||
matched, _ := regexp.MatchString(quarkPattern, url)
|
||||
return matched
|
||||
patterns := []string{
|
||||
`https://pan\.quark\.cn/s/[a-zA-Z0-9]+`, // 夸克网盘
|
||||
`https://pan\.xunlei\.com/s/.+`, // 迅雷网盘
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
matched, _ := regexp.MatchString(pattern, url)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkResourceExists 检查资源是否已存在
|
||||
@@ -197,22 +214,42 @@ func (tp *TransferProcessor) checkResourceExists(url string) (bool, *entity.Reso
|
||||
}
|
||||
|
||||
// performTransfer 执行转存操作
|
||||
func (tp *TransferProcessor) performTransfer(ctx context.Context, input *TransferInput, selectedAccounts []uint) (uint, string, error) {
|
||||
// 解析URL获取分享信息
|
||||
shareInfo, err := tp.parseShareURL(input.URL)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("解析分享链接失败: %v", err)
|
||||
func (tp *TransferProcessor) performTransfer(ctx context.Context, input *TransferInput, cks []*entity.Cks) (uint, string, error) {
|
||||
// 从 cks 中,挑选出,能够转存的账号,
|
||||
urlType := pan.ExtractServiceType(input.URL)
|
||||
if urlType == pan.NotFound {
|
||||
return 0, "", fmt.Errorf("未识别资源类型: %v", input.URL)
|
||||
}
|
||||
|
||||
serviceType := ""
|
||||
switch urlType {
|
||||
case pan.Quark:
|
||||
serviceType = "quark"
|
||||
case pan.Xunlei:
|
||||
serviceType = "xunlei"
|
||||
default:
|
||||
serviceType = ""
|
||||
}
|
||||
|
||||
var account *entity.Cks
|
||||
for _, ck := range cks {
|
||||
if ck.ServiceType == serviceType {
|
||||
account = ck
|
||||
}
|
||||
}
|
||||
if account == nil {
|
||||
return 0, "", fmt.Errorf("为找到匹配的账号: %v", serviceType)
|
||||
}
|
||||
|
||||
// 先执行转存操作
|
||||
saveURL, err := tp.transferToCloud(ctx, shareInfo, selectedAccounts)
|
||||
saveData, err := tp.transferToCloud(ctx, input.URL, account)
|
||||
if err != nil {
|
||||
utils.Error("云端转存失败: %v", err)
|
||||
return 0, "", fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证转存链接是否有效
|
||||
if saveURL == "" {
|
||||
if saveData.SaveURL == "" {
|
||||
utils.Error("转存成功但未获取到分享链接")
|
||||
return 0, "", fmt.Errorf("转存成功但未获取到分享链接")
|
||||
}
|
||||
@@ -223,29 +260,16 @@ func (tp *TransferProcessor) performTransfer(ctx context.Context, input *Transfe
|
||||
categoryID = &input.CategoryID
|
||||
}
|
||||
|
||||
// 确定平台ID
|
||||
var panID uint
|
||||
if input.PanID != 0 {
|
||||
// 使用指定的平台ID
|
||||
panID = input.PanID
|
||||
utils.Info("使用指定的平台ID: %d", panID)
|
||||
} else {
|
||||
// 如果没有指定,默认使用夸克平台ID
|
||||
quarkPanID, err := tp.getQuarkPanID()
|
||||
if err != nil {
|
||||
utils.Error("获取夸克平台ID失败: %v", err)
|
||||
return 0, "", fmt.Errorf("获取夸克平台ID失败: %v", err)
|
||||
}
|
||||
panID = quarkPanID
|
||||
utils.Info("使用默认夸克平台ID: %d", panID)
|
||||
}
|
||||
// 确定平台ID 根据 serviceType 确认 panId
|
||||
panID, _ := tp.repoMgr.PanRepository.FindIdByServiceType(serviceType)
|
||||
panIdInt := uint(panID)
|
||||
|
||||
resource := &entity.Resource{
|
||||
Title: input.Title,
|
||||
URL: input.URL,
|
||||
CategoryID: categoryID,
|
||||
PanID: &panID, // 设置平台ID
|
||||
SaveURL: saveURL, // 直接设置转存链接
|
||||
PanID: &panIdInt, // 设置平台ID
|
||||
SaveURL: saveData.SaveURL, // 直接设置转存链接
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -266,8 +290,8 @@ func (tp *TransferProcessor) performTransfer(ctx context.Context, input *Transfe
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("转存成功,资源已创建 - 资源ID: %d, 转存链接: %s", resource.ID, saveURL)
|
||||
return resource.ID, saveURL, nil
|
||||
utils.Info("转存成功,资源已创建 - 资源ID: %d, 转存链接: %s", resource.ID, saveData.SaveURL)
|
||||
return resource.ID, saveData.SaveURL, nil
|
||||
}
|
||||
|
||||
// ShareInfo 分享信息结构
|
||||
@@ -277,23 +301,23 @@ type ShareInfo struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// parseShareURL 解析分享链接
|
||||
func (tp *TransferProcessor) parseShareURL(url string) (*ShareInfo, error) {
|
||||
// 解析夸克网盘链接
|
||||
quarkPattern := `https://pan\.quark\.cn/s/([a-zA-Z0-9]+)`
|
||||
re := regexp.MustCompile(quarkPattern)
|
||||
matches := re.FindStringSubmatch(url)
|
||||
// // parseShareURL 解析分享链接
|
||||
// func (tp *TransferProcessor) parseShareURL(url string) (*ShareInfo, error) {
|
||||
// // 解析夸克网盘链接
|
||||
// quarkPattern := `https://pan\.quark\.cn/s/([a-zA-Z0-9]+)`
|
||||
// re := regexp.MustCompile(quarkPattern)
|
||||
// matches := re.FindStringSubmatch(url)
|
||||
|
||||
if len(matches) >= 2 {
|
||||
return &ShareInfo{
|
||||
PanType: "quark",
|
||||
ShareID: matches[1],
|
||||
URL: url,
|
||||
}, nil
|
||||
}
|
||||
// if len(matches) >= 2 {
|
||||
// return &ShareInfo{
|
||||
// PanType: "quark",
|
||||
// ShareID: matches[1],
|
||||
// URL: url,
|
||||
// }, nil
|
||||
// }
|
||||
|
||||
return nil, fmt.Errorf("不支持的分享链接格式: %s", url)
|
||||
}
|
||||
// return nil, fmt.Errorf("不支持的分享链接格式: %s", url)
|
||||
// }
|
||||
|
||||
// addResourceTags 添加资源标签
|
||||
func (tp *TransferProcessor) addResourceTags(resourceID uint, tagIDs []uint) error {
|
||||
@@ -313,102 +337,65 @@ func (tp *TransferProcessor) addResourceTags(resourceID uint, tagIDs []uint) err
|
||||
}
|
||||
|
||||
// transferToCloud 执行云端转存
|
||||
func (tp *TransferProcessor) transferToCloud(ctx context.Context, shareInfo *ShareInfo, selectedAccounts []uint) (string, error) {
|
||||
// 转存任务独立于自动转存开关,直接执行转存逻辑
|
||||
// 获取转存相关的配置(如最小存储空间等),但不检查自动转存开关
|
||||
|
||||
// 如果指定了账号,使用指定的账号
|
||||
if len(selectedAccounts) > 0 {
|
||||
utils.Info("使用指定的账号进行转存,账号数量: %d", len(selectedAccounts))
|
||||
|
||||
// 获取指定的账号
|
||||
var validAccounts []entity.Cks
|
||||
for _, accountID := range selectedAccounts {
|
||||
account, err := tp.repoMgr.CksRepository.FindByID(accountID)
|
||||
if err != nil {
|
||||
utils.Error("获取账号 %d 失败: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !account.IsValid {
|
||||
utils.Error("账号 %d 无效", accountID)
|
||||
continue
|
||||
}
|
||||
|
||||
validAccounts = append(validAccounts, *account)
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
return "", fmt.Errorf("指定的账号都无效或不存在")
|
||||
}
|
||||
|
||||
utils.Info("找到 %d 个有效账号,开始转存处理...", len(validAccounts))
|
||||
|
||||
// 使用第一个有效账号进行转存
|
||||
account := validAccounts[0]
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
|
||||
// 执行转存
|
||||
result := tp.transferSingleResource(shareInfo, account, factory)
|
||||
if !result.Success {
|
||||
return "", fmt.Errorf("转存失败: %s", result.ErrorMsg)
|
||||
}
|
||||
|
||||
return result.SaveURL, nil
|
||||
}
|
||||
|
||||
// 如果没有指定账号,使用原来的逻辑(自动选择)
|
||||
utils.Info("未指定账号,使用自动选择逻辑")
|
||||
|
||||
// 获取夸克平台ID
|
||||
quarkPanID, err := tp.getQuarkPanID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取夸克平台ID失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取可用的夸克账号
|
||||
accounts, err := tp.repoMgr.CksRepository.FindAll()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取网盘账号失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取最小存储空间配置(转存任务需要关注此配置)
|
||||
autoTransferMinSpace, err := tp.repoMgr.SystemConfigRepository.GetConfigInt("auto_transfer_min_space")
|
||||
if err != nil {
|
||||
utils.Error("获取最小存储空间配置失败: %v", err)
|
||||
autoTransferMinSpace = 5 // 默认5GB
|
||||
}
|
||||
|
||||
// 过滤:只保留已激活、夸克平台、剩余空间足够的账号
|
||||
minSpaceBytes := int64(autoTransferMinSpace) * 1024 * 1024 * 1024
|
||||
var validAccounts []entity.Cks
|
||||
for _, acc := range accounts {
|
||||
if acc.IsValid && acc.PanID == quarkPanID && acc.LeftSpace >= minSpaceBytes {
|
||||
validAccounts = append(validAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAccounts) == 0 {
|
||||
return "", fmt.Errorf("没有可用的夸克网盘账号(需要剩余空间 >= %d GB)", autoTransferMinSpace)
|
||||
}
|
||||
|
||||
utils.Info("找到 %d 个可用夸克网盘账号,开始转存处理...", len(validAccounts))
|
||||
|
||||
// 使用第一个可用账号进行转存
|
||||
account := validAccounts[0]
|
||||
func (tp *TransferProcessor) transferToCloud(ctx context.Context, url string, account *entity.Cks) (*TransferResult, error) {
|
||||
|
||||
// 创建网盘服务工厂
|
||||
factory := pan.NewPanFactory()
|
||||
|
||||
service, err := factory.CreatePanService(url, &pan.PanConfig{
|
||||
URL: url,
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
service.SetCKSRepository(tp.repoMgr.CksRepository, *account)
|
||||
|
||||
// 提取分享ID
|
||||
shareID, _ := pan.ExtractShareId(url)
|
||||
|
||||
// 执行转存
|
||||
result := tp.transferSingleResource(shareInfo, account, factory)
|
||||
if !result.Success {
|
||||
return "", fmt.Errorf("转存失败: %s", result.ErrorMsg)
|
||||
transferResult, err := service.Transfer(shareID) // 有些链接还需要其他信息从 url 中自行解析
|
||||
if err != nil {
|
||||
utils.Error("转存失败: %v", err)
|
||||
return nil, fmt.Errorf("转存失败: %v", err)
|
||||
}
|
||||
|
||||
return result.SaveURL, nil
|
||||
if transferResult == nil || !transferResult.Success {
|
||||
errMsg := "转存失败"
|
||||
if transferResult != nil && transferResult.Message != "" {
|
||||
errMsg = transferResult.Message
|
||||
}
|
||||
return nil, fmt.Errorf("转存失败: %v", errMsg)
|
||||
}
|
||||
|
||||
// 提取转存链接
|
||||
var saveURL string
|
||||
var fid string
|
||||
|
||||
if data, ok := transferResult.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
if v, ok := data["fid"]; ok {
|
||||
fid, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = transferResult.ShareURL
|
||||
}
|
||||
|
||||
if saveURL == "" {
|
||||
return nil, fmt.Errorf("转存失败: %v", "转存成功但未获取到分享链接")
|
||||
}
|
||||
|
||||
utils.Info("转存成功 - 资源ID: %d, 转存链接: %s", transferResult.Fid, saveURL)
|
||||
|
||||
return &TransferResult{
|
||||
Success: true,
|
||||
SaveURL: saveURL,
|
||||
Fid: fid,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
// getQuarkPanID 获取夸克网盘ID
|
||||
@@ -432,82 +419,6 @@ func (tp *TransferProcessor) getQuarkPanID() (uint, error) {
|
||||
type TransferResult struct {
|
||||
Success bool `json:"success"`
|
||||
SaveURL string `json:"save_url"`
|
||||
Fid string `json:"fid`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
|
||||
// transferSingleResource 转存单个资源
|
||||
func (tp *TransferProcessor) transferSingleResource(shareInfo *ShareInfo, account entity.Cks, factory *pan.PanFactory) TransferResult {
|
||||
utils.Info("开始转存资源 - 分享ID: %s, 账号: %s", shareInfo.ShareID, account.Username)
|
||||
|
||||
service, err := factory.CreatePanService(shareInfo.URL, &pan.PanConfig{
|
||||
URL: shareInfo.URL,
|
||||
ExpiredType: 0,
|
||||
IsType: 0,
|
||||
Cookie: account.Ck,
|
||||
})
|
||||
if err != nil {
|
||||
utils.Error("创建网盘服务失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("创建网盘服务失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// 执行转存
|
||||
transferResult, err := service.Transfer(shareInfo.ShareID)
|
||||
if err != nil {
|
||||
utils.Error("转存失败: %v", err)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: fmt.Sprintf("转存失败: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if transferResult == nil || !transferResult.Success {
|
||||
errMsg := "转存失败"
|
||||
if transferResult != nil && transferResult.Message != "" {
|
||||
errMsg = transferResult.Message
|
||||
}
|
||||
utils.Error("转存失败: %s", errMsg)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: errMsg,
|
||||
}
|
||||
}
|
||||
|
||||
// 提取转存链接
|
||||
var saveURL string
|
||||
if data, ok := transferResult.Data.(map[string]interface{}); ok {
|
||||
if v, ok := data["shareUrl"]; ok {
|
||||
saveURL, _ = v.(string)
|
||||
}
|
||||
}
|
||||
if saveURL == "" {
|
||||
saveURL = transferResult.ShareURL
|
||||
}
|
||||
|
||||
// 验证转存链接是否有效
|
||||
if saveURL == "" {
|
||||
utils.Error("转存成功但未获取到分享链接 - 分享ID: %s", shareInfo.ShareID)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "转存成功但未获取到分享链接",
|
||||
}
|
||||
}
|
||||
|
||||
// 验证链接格式
|
||||
if !strings.HasPrefix(saveURL, "http") {
|
||||
utils.Error("转存链接格式无效 - 分享ID: %s, 链接: %s", shareInfo.ShareID, saveURL)
|
||||
return TransferResult{
|
||||
Success: false,
|
||||
ErrorMsg: "转存链接格式无效",
|
||||
}
|
||||
}
|
||||
|
||||
utils.Info("转存成功 - 分享ID: %s, 转存链接: %s", shareInfo.ShareID, saveURL)
|
||||
|
||||
return TransferResult{
|
||||
Success: true,
|
||||
SaveURL: saveURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,25 @@ package utils
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// top250
|
||||
// api: https://m.douban.com/rexxar/api/v2/subject_collection/movie_top250/items?start=0&count=10&items_only=1&type_tag=&for_mobile=1
|
||||
|
||||
// 最近热门电影 https://movie.douban.com/explore
|
||||
// api: https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie?start=0&limit=20
|
||||
|
||||
// 最近热门剧集 https://movie.douban.com/tv/
|
||||
// api: https://m.douban.com/rexxar/api/v2/subject/recent_hot/tv?start=20&limit=20
|
||||
|
||||
// 最近热门综艺
|
||||
// api: https://m.douban.com/rexxar/api/v2/subject/recent_hot/tv?limit=50&category=show&type=show
|
||||
|
||||
// DoubanService 豆瓣服务
|
||||
type DoubanService struct {
|
||||
baseURL string
|
||||
@@ -104,230 +117,148 @@ func NewDoubanService() *DoubanService {
|
||||
client.SetRetryWaitTime(1 * time.Second)
|
||||
client.SetRetryMaxWaitTime(5 * time.Second)
|
||||
|
||||
// 初始化剧集榜单配置
|
||||
tvCategories := map[string]map[string]map[string]string{
|
||||
"最近热门剧集": {
|
||||
// "综合": {"category": "tv", "type": "tv"},
|
||||
"国产剧": {"category": "tv", "type": "tv_domestic"},
|
||||
"欧美剧": {"category": "tv", "type": "tv_american"},
|
||||
"日剧": {"category": "tv", "type": "tv_japanese"},
|
||||
"韩剧": {"category": "tv", "type": "tv_korean"},
|
||||
"动画": {"category": "tv", "type": "tv_animation"},
|
||||
"纪录片": {"category": "tv", "type": "tv_documentary"},
|
||||
},
|
||||
"最近热门综艺": {
|
||||
// "综合": {"category": "show", "type": "show"},
|
||||
"国内": {"category": "show", "type": "show_domestic"},
|
||||
"国外": {"category": "show", "type": "show_foreign"},
|
||||
},
|
||||
}
|
||||
|
||||
return &DoubanService{
|
||||
baseURL: "https://m.douban.com/rexxar/api/v2",
|
||||
client: client,
|
||||
TvCategories: tvCategories,
|
||||
baseURL: "https://m.douban.com/rexxar/api/v2",
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTypePage 获取指定类型的数据
|
||||
func (ds *DoubanService) GetTypePage(category, rankingType string) (*DoubanResult, error) {
|
||||
// 构建请求参数
|
||||
// GetRecentHotMovies fetches recent hot movies
|
||||
func (ds *DoubanService) GetRecentHotMovies() ([]DoubanItem, error) {
|
||||
url := "https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie"
|
||||
params := map[string]string{
|
||||
"start": "0",
|
||||
"limit": "50",
|
||||
"os": "window",
|
||||
"_": "0",
|
||||
"loc_id": "108288",
|
||||
"start": "0",
|
||||
"limit": "20",
|
||||
}
|
||||
items := []DoubanItem{}
|
||||
for {
|
||||
pageItems, total, err := ds.fetchPage(url, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
if len(items) >= total {
|
||||
break
|
||||
}
|
||||
start := len(items)
|
||||
params["start"] = strconv.Itoa(start)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
Debug("请求参数: %+v", params)
|
||||
Debug("请求URL: %s/subject_collection/%s/items", ds.baseURL, rankingType)
|
||||
// GetRecentHotTVs fetches recent hot TV shows
|
||||
func (ds *DoubanService) GetRecentHotTVs() ([]DoubanItem, error) {
|
||||
url := "https://m.douban.com/rexxar/api/v2/subject/recent_hot/tv"
|
||||
params := map[string]string{
|
||||
"start": "0",
|
||||
"limit": "300",
|
||||
}
|
||||
items := []DoubanItem{}
|
||||
for {
|
||||
pageItems, total, err := ds.fetchPage(url, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
if len(items) >= total {
|
||||
break
|
||||
}
|
||||
start := len(items)
|
||||
params["start"] = strconv.Itoa(start)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetRank fetches recent rank info
|
||||
func (ds *DoubanService) GetRank(url string) ([]DoubanItem, error) {
|
||||
params := map[string]string{
|
||||
// "start": "0",
|
||||
// "count": "20",
|
||||
// "updated_at": "",
|
||||
// "items_only": "",
|
||||
// "type_tag": "",
|
||||
// "for_mobile": "1",
|
||||
}
|
||||
items := []DoubanItem{}
|
||||
pageItems, _, err := ds.fetchPage(url, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetRecentHotShows fetches recent hot shows
|
||||
func (ds *DoubanService) GetRecentHotShows() ([]DoubanItem, error) {
|
||||
url := "https://m.douban.com/rexxar/api/v2/subject/recent_hot/tv"
|
||||
params := map[string]string{
|
||||
"limit": "300",
|
||||
"category": "show",
|
||||
"type": "show",
|
||||
"start": "0",
|
||||
}
|
||||
items := []DoubanItem{}
|
||||
for {
|
||||
pageItems, total, err := ds.fetchPage(url, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
if len(items) >= total {
|
||||
break
|
||||
}
|
||||
start := len(items)
|
||||
params["start"] = strconv.Itoa(start)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetTop250Movies fetches top 250 movies
|
||||
func (ds *DoubanService) GetTop250Movies() ([]DoubanItem, error) {
|
||||
url := "https://m.douban.com/rexxar/api/v2/subject_collection/movie_top250/items"
|
||||
params := map[string]string{
|
||||
"start": "0",
|
||||
"count": "250",
|
||||
"items_only": "1",
|
||||
"type_tag": "",
|
||||
"for_mobile": "1",
|
||||
}
|
||||
items, _, err := ds.fetchPage(url, params)
|
||||
return items, err
|
||||
}
|
||||
|
||||
// fetchPage fetches a page of items from a given URL and parameters
|
||||
func (ds *DoubanService) fetchPage(url string, params map[string]string) ([]DoubanItem, int, error) {
|
||||
var response *resty.Response
|
||||
var err error
|
||||
|
||||
// 尝试调用豆瓣API
|
||||
Debug("开始发送HTTP请求...")
|
||||
response, err = ds.client.R().
|
||||
SetQueryParams(params).
|
||||
Get(ds.baseURL + "/subject_collection/" + rankingType + "/items")
|
||||
Get(url)
|
||||
|
||||
if err != nil {
|
||||
Error("=== 豆瓣API调用失败 ===")
|
||||
Error("错误详情: %v", err)
|
||||
return &DoubanResult{
|
||||
Success: false,
|
||||
Message: "API调用失败: " + err.Error(),
|
||||
}, nil
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
Debug("=== HTTP请求成功 ===")
|
||||
Debug("响应状态码: %d", response.StatusCode())
|
||||
Debug("响应体长度: %d bytes", len(response.Body()))
|
||||
|
||||
// 记录响应体的前500个字符用于调试
|
||||
responseBody := string(response.Body())
|
||||
Debug("响应体原始长度: %d 字符", len(responseBody))
|
||||
|
||||
if len(responseBody) > 500 {
|
||||
Debug("响应体前500字符: %s...", responseBody[:500])
|
||||
} else {
|
||||
Debug("完整响应体: %s", responseBody)
|
||||
}
|
||||
|
||||
// 检查响应体是否包含有效JSON
|
||||
if len(responseBody) == 0 {
|
||||
Warn("=== 响应体为空 ===")
|
||||
return &DoubanResult{
|
||||
Success: false,
|
||||
Message: "响应体为空",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 尝试解析JSON
|
||||
var apiResponse map[string]interface{}
|
||||
if err := json.Unmarshal(response.Body(), &apiResponse); err != nil {
|
||||
Error("=== 解析API响应失败 ===")
|
||||
Error("JSON解析错误: %v", err)
|
||||
Debug("响应体内容: %s", string(response.Body()))
|
||||
|
||||
// 尝试检查是否是HTML错误页面
|
||||
if len(responseBody) > 100 && (strings.Contains(responseBody, "<html>") || strings.Contains(responseBody, "<!DOCTYPE")) {
|
||||
Warn("检测到HTML响应,可能是错误页面")
|
||||
return &DoubanResult{
|
||||
Success: false,
|
||||
Message: "返回HTML错误页面",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &DoubanResult{
|
||||
Success: false,
|
||||
Message: "解析API响应失败: " + err.Error(),
|
||||
}, nil
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
log.Printf("=== JSON解析成功 ===")
|
||||
log.Printf("解析后的数据结构: %+v", apiResponse)
|
||||
|
||||
// 打印完整的API响应JSON
|
||||
log.Printf("=== 完整API响应JSON ===")
|
||||
if responseBytes, err := json.MarshalIndent(apiResponse, "", " "); err == nil {
|
||||
log.Printf("完整响应:\n%s", string(responseBytes))
|
||||
} else {
|
||||
log.Printf("序列化响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 处理豆瓣移动端API的响应格式
|
||||
items := ds.extractItems(apiResponse)
|
||||
categories := ds.extractCategories(apiResponse)
|
||||
total := ds.extractTotal(apiResponse)
|
||||
|
||||
log.Printf("提取到的数据数量: %d", len(items))
|
||||
log.Printf("提取到的分类数量: %d", len(categories))
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// 如果没有获取到真实数据,返回空结果
|
||||
if len(items) == 0 {
|
||||
log.Printf("=== API返回空数据 ===")
|
||||
return &DoubanResult{
|
||||
Success: true,
|
||||
Data: &DoubanResponse{
|
||||
Items: []DoubanItem{},
|
||||
Total: 0,
|
||||
Categories: []DoubanCategory{},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 如果没有获取到categories,使用默认分类
|
||||
if len(categories) == 0 {
|
||||
log.Printf("=== 使用默认分类 ===")
|
||||
categories = []DoubanCategory{
|
||||
{Category: category, Selected: true, Type: rankingType, Title: rankingType},
|
||||
}
|
||||
}
|
||||
|
||||
// 根据请求的category和type更新selected状态
|
||||
for i := range categories {
|
||||
categories[i].Selected = categories[i].Category == category && categories[i].Type == rankingType
|
||||
}
|
||||
|
||||
// 限制返回数量(最多50条)
|
||||
limit := 50
|
||||
if len(items) > limit {
|
||||
log.Printf("限制返回数量从 %d 到 %d", len(items), limit)
|
||||
items = items[:limit]
|
||||
}
|
||||
|
||||
// 获取总数,优先使用API返回的total字段
|
||||
total := len(items)
|
||||
if totalData, ok := apiResponse["total"]; ok {
|
||||
// extractTotal extracts the total number of items from the API response
|
||||
func (ds *DoubanService) extractTotal(response map[string]interface{}) int {
|
||||
if totalData, ok := response["total"]; ok {
|
||||
if totalFloat, ok := totalData.(float64); ok {
|
||||
total = int(totalFloat)
|
||||
return int(totalFloat)
|
||||
}
|
||||
}
|
||||
|
||||
result := &DoubanResponse{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Categories: categories,
|
||||
IsMockData: false,
|
||||
MockReason: "",
|
||||
}
|
||||
|
||||
log.Printf("=== 数据获取完成 ===")
|
||||
log.Printf("最终返回数据数量: %d", len(items))
|
||||
|
||||
return &DoubanResult{
|
||||
Success: true,
|
||||
Data: result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetTvByType 获取指定type的全部剧集数据
|
||||
func (ds *DoubanService) GetTvByType(tvType string) ([]map[string]interface{}, error) {
|
||||
url := ds.baseURL + "/subject_collection/" + tvType + "/items"
|
||||
params := map[string]string{
|
||||
"start": "0",
|
||||
"limit": "1000", // 假设不会超过1000条
|
||||
}
|
||||
|
||||
resp, err := ds.client.R().
|
||||
SetQueryParams(params).
|
||||
Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items, ok := result["subject_collection_items"].([]interface{})
|
||||
if !ok {
|
||||
return nil, nil // 没有数据
|
||||
}
|
||||
|
||||
// 转换为[]map[string]interface{}
|
||||
var out []map[string]interface{}
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAllTvTypes 获取所有tv类型(type列表)
|
||||
func (ds *DoubanService) GetAllTvTypes() []string {
|
||||
types := []string{}
|
||||
for _, sub := range ds.TvCategories {
|
||||
for _, v := range sub {
|
||||
if t, ok := v["type"]; ok {
|
||||
types = append(types, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return types
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractItems 从API响应中提取项目列表
|
||||
@@ -405,16 +336,3 @@ func (ds *DoubanService) parseCardSubtitle(item *DoubanItem) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractCategories 从API响应中提取分类列表
|
||||
func (ds *DoubanService) extractCategories(response map[string]interface{}) []DoubanCategory {
|
||||
var categories []DoubanCategory
|
||||
|
||||
if categoriesData, ok := response["categories"]; ok {
|
||||
if categoriesBytes, err := json.Marshal(categoriesData); err == nil {
|
||||
json.Unmarshal(categoriesBytes, &categories)
|
||||
}
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
287
utils/forbidden_words.go
Normal file
287
utils/forbidden_words.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ctwj/urldb/db/entity"
|
||||
)
|
||||
|
||||
// ForbiddenWordsProcessor 违禁词处理器
|
||||
type ForbiddenWordsProcessor struct{}
|
||||
|
||||
// NewForbiddenWordsProcessor 创建违禁词处理器实例
|
||||
func NewForbiddenWordsProcessor() *ForbiddenWordsProcessor {
|
||||
return &ForbiddenWordsProcessor{}
|
||||
}
|
||||
|
||||
// CheckContainsForbiddenWords 检查字符串是否包含违禁词
|
||||
// 参数:
|
||||
// - text: 要检查的文本
|
||||
// - forbiddenWords: 违禁词列表
|
||||
//
|
||||
// 返回:
|
||||
// - bool: 是否包含违禁词
|
||||
// - []string: 匹配到的违禁词列表
|
||||
func (p *ForbiddenWordsProcessor) CheckContainsForbiddenWords(text string, forbiddenWords []string) (bool, []string) {
|
||||
if len(forbiddenWords) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var matchedWords []string
|
||||
textLower := strings.ToLower(text)
|
||||
|
||||
for _, word := range forbiddenWords {
|
||||
wordLower := strings.ToLower(word)
|
||||
if strings.Contains(textLower, wordLower) {
|
||||
matchedWords = append(matchedWords, word)
|
||||
}
|
||||
}
|
||||
|
||||
return len(matchedWords) > 0, matchedWords
|
||||
}
|
||||
|
||||
// ReplaceForbiddenWords 替换字符串中的违禁词为 *
|
||||
// 参数:
|
||||
// - text: 要处理的文本
|
||||
// - forbiddenWords: 违禁词列表
|
||||
//
|
||||
// 返回:
|
||||
// - string: 替换后的文本
|
||||
func (p *ForbiddenWordsProcessor) ReplaceForbiddenWords(text string, forbiddenWords []string) string {
|
||||
if len(forbiddenWords) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
result := text
|
||||
// 按长度降序排序,避免短词替换后影响长词的匹配
|
||||
sortedWords := make([]string, len(forbiddenWords))
|
||||
copy(sortedWords, forbiddenWords)
|
||||
|
||||
// 简单的长度排序(这里可以优化为更复杂的排序)
|
||||
for i := 0; i < len(sortedWords)-1; i++ {
|
||||
for j := i + 1; j < len(sortedWords); j++ {
|
||||
if len(sortedWords[i]) < len(sortedWords[j]) {
|
||||
sortedWords[i], sortedWords[j] = sortedWords[j], sortedWords[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, word := range sortedWords {
|
||||
// 使用正则表达式进行不区分大小写的替换
|
||||
// 对于中文,不使用单词边界,直接替换
|
||||
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(word))
|
||||
// 使用字符长度而不是字节长度
|
||||
charCount := len([]rune(word))
|
||||
result = re.ReplaceAllString(result, strings.Repeat("*", charCount))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ReplaceForbiddenWordsWithHighlight 替换字符串中的违禁词为 *(处理高亮标记)
|
||||
// 参数:
|
||||
// - text: 要处理的文本(可能包含高亮标记)
|
||||
// - forbiddenWords: 违禁词列表
|
||||
//
|
||||
// 返回:
|
||||
// - string: 替换后的文本
|
||||
func (p *ForbiddenWordsProcessor) ReplaceForbiddenWordsWithHighlight(text string, forbiddenWords []string) string {
|
||||
if len(forbiddenWords) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
// 1. 先移除所有高亮标记,获取纯文本
|
||||
cleanText := regexp.MustCompile(`<mark>(.*?)</mark>`).ReplaceAllString(text, "$1")
|
||||
|
||||
// 2. 检查纯文本中是否包含违禁词
|
||||
hasForbidden := false
|
||||
for _, word := range forbiddenWords {
|
||||
re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(word))
|
||||
if re.MatchString(cleanText) {
|
||||
hasForbidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果包含违禁词,则替换非高亮文本
|
||||
if hasForbidden {
|
||||
return p.ReplaceForbiddenWords(text, forbiddenWords)
|
||||
}
|
||||
|
||||
// 4. 如果不包含违禁词,直接返回原文本
|
||||
return text
|
||||
}
|
||||
|
||||
// ProcessForbiddenWords 处理违禁词:检查并替换
|
||||
// 参数:
|
||||
// - text: 要处理的文本
|
||||
// - forbiddenWords: 违禁词列表
|
||||
//
|
||||
// 返回:
|
||||
// - bool: 是否包含违禁词
|
||||
// - []string: 匹配到的违禁词列表
|
||||
// - string: 替换后的文本
|
||||
func (p *ForbiddenWordsProcessor) ProcessForbiddenWords(text string, forbiddenWords []string) (bool, []string, string) {
|
||||
contains, matchedWords := p.CheckContainsForbiddenWords(text, forbiddenWords)
|
||||
replacedText := p.ReplaceForbiddenWords(text, forbiddenWords)
|
||||
return contains, matchedWords, replacedText
|
||||
}
|
||||
|
||||
// ParseForbiddenWordsConfig 解析违禁词配置字符串
|
||||
// 参数:
|
||||
// - config: 违禁词配置字符串,多个词用逗号分隔
|
||||
//
|
||||
// 返回:
|
||||
// - []string: 处理后的违禁词列表
|
||||
func (p *ForbiddenWordsProcessor) ParseForbiddenWordsConfig(config string) []string {
|
||||
if config == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
words := strings.Split(config, ",")
|
||||
var cleanWords []string
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if word != "" {
|
||||
cleanWords = append(cleanWords, word)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanWords
|
||||
}
|
||||
|
||||
// 全局实例,方便直接调用
|
||||
var DefaultForbiddenWordsProcessor = NewForbiddenWordsProcessor()
|
||||
|
||||
// 便捷函数,直接调用全局实例
|
||||
|
||||
// CheckContainsForbiddenWords 检查字符串是否包含违禁词(便捷函数)
|
||||
func CheckContainsForbiddenWords(text string, forbiddenWords []string) (bool, []string) {
|
||||
return DefaultForbiddenWordsProcessor.CheckContainsForbiddenWords(text, forbiddenWords)
|
||||
}
|
||||
|
||||
// ReplaceForbiddenWords 替换字符串中的违禁词为 *(便捷函数)
|
||||
func ReplaceForbiddenWords(text string, forbiddenWords []string) string {
|
||||
return DefaultForbiddenWordsProcessor.ReplaceForbiddenWords(text, forbiddenWords)
|
||||
}
|
||||
|
||||
// ReplaceForbiddenWordsWithHighlight 替换字符串中的违禁词为 *(处理高亮标记,便捷函数)
|
||||
func ReplaceForbiddenWordsWithHighlight(text string, forbiddenWords []string) string {
|
||||
return DefaultForbiddenWordsProcessor.ReplaceForbiddenWordsWithHighlight(text, forbiddenWords)
|
||||
}
|
||||
|
||||
// ProcessForbiddenWords 处理违禁词:检查并替换(便捷函数)
|
||||
func ProcessForbiddenWords(text string, forbiddenWords []string) (bool, []string, string) {
|
||||
return DefaultForbiddenWordsProcessor.ProcessForbiddenWords(text, forbiddenWords)
|
||||
}
|
||||
|
||||
// ParseForbiddenWordsConfig 解析违禁词配置字符串(便捷函数)
|
||||
func ParseForbiddenWordsConfig(config string) []string {
|
||||
return DefaultForbiddenWordsProcessor.ParseForbiddenWordsConfig(config)
|
||||
}
|
||||
|
||||
// RemoveDuplicates 去除字符串切片中的重复项
|
||||
func RemoveDuplicates(slice []string) []string {
|
||||
keys := make(map[string]bool)
|
||||
var result []string
|
||||
for _, item := range slice {
|
||||
if _, value := keys[item]; !value {
|
||||
keys[item] = true
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ResourceForbiddenInfo 资源违禁词信息
|
||||
type ResourceForbiddenInfo struct {
|
||||
HasForbiddenWords bool `json:"has_forbidden_words"`
|
||||
ForbiddenWords []string `json:"forbidden_words"`
|
||||
ProcessedTitle string `json:"-"` // 不序列化,仅内部使用
|
||||
ProcessedDesc string `json:"-"` // 不序列化,仅内部使用
|
||||
}
|
||||
|
||||
// CheckResourceForbiddenWords 检查资源是否包含违禁词(检查标题和描述)
|
||||
// 参数:
|
||||
// - title: 资源标题
|
||||
// - description: 资源描述
|
||||
// - forbiddenWords: 违禁词列表
|
||||
//
|
||||
// 返回:
|
||||
// - ResourceForbiddenInfo: 包含检查结果和处理后的文本
|
||||
func CheckResourceForbiddenWords(title, description string, forbiddenWords []string) ResourceForbiddenInfo {
|
||||
|
||||
if len(forbiddenWords) == 0 {
|
||||
return ResourceForbiddenInfo{
|
||||
HasForbiddenWords: false,
|
||||
ForbiddenWords: []string{},
|
||||
ProcessedTitle: title,
|
||||
ProcessedDesc: description,
|
||||
}
|
||||
}
|
||||
|
||||
// 分别检查标题和描述
|
||||
titleHasForbidden, titleMatchedWords := CheckContainsForbiddenWords(title, forbiddenWords)
|
||||
descHasForbidden, descMatchedWords := CheckContainsForbiddenWords(description, forbiddenWords)
|
||||
|
||||
// 合并结果
|
||||
hasForbiddenWords := titleHasForbidden || descHasForbidden
|
||||
var matchedWords []string
|
||||
if titleHasForbidden {
|
||||
matchedWords = append(matchedWords, titleMatchedWords...)
|
||||
}
|
||||
if descHasForbidden {
|
||||
matchedWords = append(matchedWords, descMatchedWords...)
|
||||
}
|
||||
// 去重
|
||||
matchedWords = RemoveDuplicates(matchedWords)
|
||||
|
||||
// 处理文本(替换违禁词)
|
||||
processedTitle := ReplaceForbiddenWords(title, forbiddenWords)
|
||||
processedDesc := ReplaceForbiddenWords(description, forbiddenWords)
|
||||
|
||||
return ResourceForbiddenInfo{
|
||||
HasForbiddenWords: hasForbiddenWords,
|
||||
ForbiddenWords: matchedWords,
|
||||
ProcessedTitle: processedTitle,
|
||||
ProcessedDesc: processedDesc,
|
||||
}
|
||||
}
|
||||
|
||||
// GetForbiddenWordsFromConfig 从系统配置获取违禁词列表
|
||||
// 参数:
|
||||
// - getConfigFunc: 获取配置的函数
|
||||
//
|
||||
// 返回:
|
||||
// - []string: 解析后的违禁词列表
|
||||
// - error: 获取配置时的错误
|
||||
func GetForbiddenWordsFromConfig(getConfigFunc func() (string, error)) ([]string, error) {
|
||||
forbiddenWords, err := getConfigFunc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseForbiddenWordsConfig(forbiddenWords), nil
|
||||
}
|
||||
|
||||
// ProcessResourcesForbiddenWords 批量处理资源的违禁词
|
||||
// 参数:
|
||||
// - resources: 资源切片
|
||||
// - forbiddenWords: 违禁词列表
|
||||
//
|
||||
// 返回:
|
||||
// - 处理后的资源切片
|
||||
func ProcessResourcesForbiddenWords(resources []entity.Resource, forbiddenWords []string) []entity.Resource {
|
||||
if len(forbiddenWords) == 0 {
|
||||
return resources
|
||||
}
|
||||
|
||||
for i := range resources {
|
||||
// 处理标题中的违禁词
|
||||
resources[i].Title = ReplaceForbiddenWords(resources[i].Title, forbiddenWords)
|
||||
// 处理描述中的违禁词
|
||||
resources[i].Description = ReplaceForbiddenWords(resources[i].Description, forbiddenWords)
|
||||
}
|
||||
|
||||
return resources
|
||||
}
|
||||
232
utils/log_telegram.go
Normal file
232
utils/log_telegram.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelegramLogEntry Telegram日志条目
|
||||
type TelegramLogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Category string `json:"category,omitempty"` // telegram, push, message等
|
||||
}
|
||||
|
||||
// GetTelegramLogs 获取Telegram相关的日志
|
||||
func GetTelegramLogs(startTime *time.Time, endTime *time.Time, limit int) ([]TelegramLogEntry, error) {
|
||||
logDir := "logs"
|
||||
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
||||
return []TelegramLogEntry{}, nil
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查找日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return []TelegramLogEntry{}, nil
|
||||
}
|
||||
|
||||
// 按时间排序,最近的在前面
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(files)))
|
||||
|
||||
var allEntries []TelegramLogEntry
|
||||
|
||||
// 编译Telegram相关的正则表达式
|
||||
telegramRegex := regexp.MustCompile(`(?i)(\[TELEGRAM.*?\])`)
|
||||
messageRegex := regexp.MustCompile(`\[(\w+)\]\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})\s+\[.*?\]\s+(.*)`)
|
||||
|
||||
for _, file := range files {
|
||||
entries, err := parseTelegramLogsFromFile(file, telegramRegex, messageRegex, startTime, endTime)
|
||||
if err != nil {
|
||||
continue // 跳过读取失败的文件
|
||||
}
|
||||
|
||||
allEntries = append(allEntries, entries...)
|
||||
|
||||
// 如果已经达到限制数量,退出
|
||||
if limit > 0 && len(allEntries) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间排序,最新的在前面
|
||||
sort.Slice(allEntries, func(i, j int) bool {
|
||||
return allEntries[i].Timestamp.After(allEntries[j].Timestamp)
|
||||
})
|
||||
|
||||
// 限制返回数量
|
||||
if limit > 0 && len(allEntries) > limit {
|
||||
allEntries = allEntries[:limit]
|
||||
}
|
||||
|
||||
// 分类日志
|
||||
for i := range allEntries {
|
||||
allEntries[i].Category = categorizeLog(allEntries[i].Message)
|
||||
}
|
||||
|
||||
return allEntries, nil
|
||||
}
|
||||
|
||||
// parseTelegramLogsFromFile 解析单个日志文件中的Telegram日志
|
||||
func parseTelegramLogsFromFile(filePath string, telegramRegex, messageRegex *regexp.Regexp, startTime, endTime *time.Time) ([]TelegramLogEntry, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var entries []TelegramLogEntry
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// 检查是否是Telegram相关日志
|
||||
if !telegramRegex.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析日志行
|
||||
entry, err := parseLogLine(line, messageRegex)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 时间过滤
|
||||
if startTime != nil && entry.Timestamp.Before(*startTime) {
|
||||
continue
|
||||
}
|
||||
if endTime != nil && entry.Timestamp.After(*endTime) {
|
||||
continue
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
|
||||
// parseLogLine 解析单行日志
|
||||
func parseLogLine(line string, messageRegex *regexp.Regexp) (TelegramLogEntry, error) {
|
||||
// 匹配日志格式: [LEVEL] 2006/01/02 15:04:05 [file:line] message
|
||||
matches := messageRegex.FindStringSubmatch(line)
|
||||
if len(matches) < 4 {
|
||||
return TelegramLogEntry{}, fmt.Errorf("无法解析日志行: %s", line)
|
||||
}
|
||||
|
||||
level := matches[1]
|
||||
timeStr := matches[2]
|
||||
message := matches[3]
|
||||
|
||||
// 解析时间
|
||||
timestamp, err := time.Parse("2006/01/02 15:04:05", timeStr)
|
||||
if err != nil {
|
||||
return TelegramLogEntry{}, fmt.Errorf("时间解析失败: %v", err)
|
||||
}
|
||||
|
||||
return TelegramLogEntry{
|
||||
Timestamp: timestamp,
|
||||
Level: level,
|
||||
Message: message,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// categorizeLog 对日志进行分类
|
||||
func categorizeLog(message string) string {
|
||||
message = strings.ToLower(message)
|
||||
|
||||
switch {
|
||||
case strings.Contains(message, "推送") || strings.Contains(message, "push"):
|
||||
return "push"
|
||||
case strings.Contains(message, "消息") || strings.Contains(message, "message") || strings.Contains(message, "收到"):
|
||||
return "message"
|
||||
case strings.Contains(message, "频道") || strings.Contains(message, "群组") || strings.Contains(message, "register"):
|
||||
return "channel"
|
||||
case strings.Contains(message, "启动") || strings.Contains(message, "停止") || strings.Contains(message, "start") || strings.Contains(message, "stop"):
|
||||
return "service"
|
||||
default:
|
||||
return "general"
|
||||
}
|
||||
}
|
||||
|
||||
// GetTelegramLogStats 获取Telegram日志统计
|
||||
func GetTelegramLogStats(hours int) (map[string]interface{}, error) {
|
||||
endTime := time.Now()
|
||||
startTime := endTime.Add(-time.Duration(hours) * time.Hour)
|
||||
|
||||
entries, err := GetTelegramLogs(&startTime, &endTime, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_logs": len(entries),
|
||||
"categories": make(map[string]int),
|
||||
"levels": make(map[string]int),
|
||||
"timeline": make(map[string]int), // 按小时统计
|
||||
}
|
||||
|
||||
categoryStats := stats["categories"].(map[string]int)
|
||||
levelStats := stats["levels"].(map[string]int)
|
||||
timelineStats := stats["timeline"].(map[string]int)
|
||||
|
||||
for _, entry := range entries {
|
||||
// 分类统计
|
||||
categoryStats[entry.Category]++
|
||||
|
||||
// 级别统计
|
||||
levelStats[entry.Level]++
|
||||
|
||||
// 时间线统计(按小时)
|
||||
hourKey := entry.Timestamp.Format("2006-01-02 15:00")
|
||||
timelineStats[hourKey]++
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ClearOldTelegramLogs 清理旧的Telegram日志(保留最近N天的日志)
|
||||
func ClearOldTelegramLogs(daysToKeep int) error {
|
||||
logDir := "logs"
|
||||
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
||||
return nil // 日志目录不存在,无需清理
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(logDir, "app_*.log"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找日志文件失败: %v", err)
|
||||
}
|
||||
|
||||
cutoffTime := time.Now().AddDate(0, 0, -daysToKeep)
|
||||
|
||||
deletedCount := 0
|
||||
for _, file := range files {
|
||||
fileInfo, err := os.Stat(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if fileInfo.ModTime().Before(cutoffTime) {
|
||||
if err := os.Remove(file); err == nil {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
Info("清理了 %d 个旧的日志文件", deletedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
/* 搜索高亮样式 */
|
||||
mark {
|
||||
@apply bg-yellow-200 text-yellow-900 px-1 py-0.5 rounded font-medium;
|
||||
@apply bg-yellow-200 text-yellow-900 rounded font-medium;
|
||||
}
|
||||
|
||||
/* 暗色模式下的高亮样式 */
|
||||
|
||||
10
web/components.d.ts
vendored
10
web/components.d.ts
vendored
@@ -15,17 +15,23 @@ declare module 'vue' {
|
||||
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCode: typeof import('naive-ui')['NCode']
|
||||
NCollapse: typeof import('naive-ui')['NCollapse']
|
||||
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
|
||||
NDataTable: typeof import('naive-ui')['NDataTable']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDescriptions: typeof import('naive-ui')['NDescriptions']
|
||||
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NDrawer: typeof import('naive-ui')['NDrawer']
|
||||
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NImageGroup: typeof import('naive-ui')['NImageGroup']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NList: typeof import('naive-ui')['NList']
|
||||
NListItem: typeof import('naive-ui')['NListItem']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
@@ -35,16 +41,18 @@ declare module 'vue' {
|
||||
NPagination: typeof import('naive-ui')['NPagination']
|
||||
NProgress: typeof import('naive-ui')['NProgress']
|
||||
NQrCode: typeof import('naive-ui')['NQrCode']
|
||||
NRadio: typeof import('naive-ui')['NRadio']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSpin: typeof import('naive-ui')['NSpin']
|
||||
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||
NTable: typeof import('naive-ui')['NTable']
|
||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||
NTabs: typeof import('naive-ui')['NTabs']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NThing: typeof import('naive-ui')['NThing']
|
||||
NTimePicker: typeof import('naive-ui')['NTimePicker']
|
||||
NUpload: typeof import('naive-ui')['NUpload']
|
||||
NUploadDragger: typeof import('naive-ui')['NUploadDragger']
|
||||
NVirtualList: typeof import('naive-ui')['NVirtualList']
|
||||
|
||||
203
web/components/Admin/AnnouncementConfig.vue
Normal file
203
web/components/Admin/AnnouncementConfig.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-lg font-semibold text-gray-800 dark:text-gray-200">公告配置</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">开启后可在网站显示公告信息</span>
|
||||
</div>
|
||||
<n-button v-if="modelValue.enable_announcements" @click="addAnnouncement" type="primary" size="small">
|
||||
<template #icon>
|
||||
<i class="fas fa-plus"></i>
|
||||
</template>
|
||||
添加公告
|
||||
</n-button>
|
||||
</div>
|
||||
<n-switch v-model:value="enableAnnouncements" />
|
||||
|
||||
<!-- 公告列表 -->
|
||||
<div v-if="modelValue.enable_announcements && modelValue.announcements && modelValue.announcements.length > 0" class="announcement-list space-y-3">
|
||||
<div v-for="(announcement, index) in modelValue.announcements" :key="index" class="announcement-item border rounded-lg p-3 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100">公告 {{ index + 1 }}</h4>
|
||||
<n-switch :value="announcement.enabled" @update:value="handleEnabledChange(index, $event)" size="small" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<n-button text @click="moveAnnouncementUp(index)" :disabled="index === 0" size="small">
|
||||
<template #icon>
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button text @click="moveAnnouncementDown(index)" :disabled="index === modelValue.announcements.length - 1" size="small">
|
||||
<template #icon>
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button text @click="removeAnnouncement(index)" type="error" size="small">
|
||||
<template #icon>
|
||||
<i class="fas fa-trash"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<n-input
|
||||
:value="announcement.content"
|
||||
@update:value="handleContentChange(index, $event)"
|
||||
placeholder="公告内容,支持HTML标签"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 公告接口
|
||||
interface Announcement {
|
||||
content: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// 配置数据接口
|
||||
interface ConfigData {
|
||||
enable_announcements: boolean
|
||||
announcements: Announcement[]
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
modelValue: ConfigData
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ConfigData]
|
||||
}>()
|
||||
|
||||
// 计算属性用于双向绑定
|
||||
const enableAnnouncements = computed({
|
||||
get: () => props.modelValue.enable_announcements,
|
||||
set: (value: boolean) => {
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: value,
|
||||
announcements: props.modelValue.announcements
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 更新数据
|
||||
const updateValue = (newValue: ConfigData) => {
|
||||
emit('update:modelValue', newValue)
|
||||
}
|
||||
|
||||
// 监听单个公告内容变化
|
||||
const handleContentChange = (index: number, content: string) => {
|
||||
const newAnnouncements = [...props.modelValue.announcements]
|
||||
newAnnouncements[index] = { ...newAnnouncements[index], content }
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
|
||||
// 监听单个公告启用状态变化
|
||||
const handleEnabledChange = (index: number, enabled: boolean) => {
|
||||
const newAnnouncements = [...props.modelValue.announcements]
|
||||
newAnnouncements[index] = { ...newAnnouncements[index], enabled }
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
|
||||
// 计算属性用于公告内容双向绑定
|
||||
const announcementContent = (index: number) => computed({
|
||||
get: () => props.modelValue.announcements[index]?.content || '',
|
||||
set: (value: string) => {
|
||||
const newAnnouncements = [...props.modelValue.announcements]
|
||||
newAnnouncements[index] = { ...newAnnouncements[index], content: value }
|
||||
updateValue({
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性用于公告启用状态双向绑定
|
||||
const announcementEnabled = (index: number) => computed({
|
||||
get: () => props.modelValue.announcements[index]?.enabled || false,
|
||||
set: (value: boolean) => {
|
||||
const newAnnouncements = [...props.modelValue.announcements]
|
||||
newAnnouncements[index] = { ...newAnnouncements[index], enabled: value }
|
||||
updateValue({
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 添加公告
|
||||
const addAnnouncement = () => {
|
||||
const newAnnouncements = [...props.modelValue.announcements, {
|
||||
content: '',
|
||||
enabled: true
|
||||
}]
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
|
||||
// 删除公告
|
||||
const removeAnnouncement = (index: number) => {
|
||||
const currentAnnouncements = Array.isArray(props.modelValue.announcements) ? props.modelValue.announcements : []
|
||||
const newAnnouncements = currentAnnouncements.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
|
||||
// 上移公告
|
||||
const moveAnnouncementUp = (index: number) => {
|
||||
if (index > 0) {
|
||||
const currentAnnouncements = Array.isArray(props.modelValue.announcements) ? props.modelValue.announcements : []
|
||||
const newAnnouncements = [...currentAnnouncements]
|
||||
const temp = newAnnouncements[index]
|
||||
newAnnouncements[index] = newAnnouncements[index - 1]
|
||||
newAnnouncements[index - 1] = temp
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 下移公告
|
||||
const moveAnnouncementDown = (index: number) => {
|
||||
const currentAnnouncements = Array.isArray(props.modelValue.announcements) ? props.modelValue.announcements : []
|
||||
if (index < currentAnnouncements.length - 1) {
|
||||
const newAnnouncements = [...currentAnnouncements]
|
||||
const temp = newAnnouncements[index]
|
||||
newAnnouncements[index] = newAnnouncements[index + 1]
|
||||
newAnnouncements[index + 1] = temp
|
||||
emit('update:modelValue', {
|
||||
enable_announcements: props.modelValue.enable_announcements,
|
||||
announcements: newAnnouncements
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
171
web/components/Admin/FloatButtonsConfig.vue
Normal file
171
web/components/Admin/FloatButtonsConfig.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-lg font-semibold text-gray-800 dark:text-gray-200">浮动按钮配置</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">开启后显示右下角浮动按钮</span>
|
||||
</div>
|
||||
<n-switch v-model:value="enableFloatButtons" />
|
||||
|
||||
<!-- 浮动按钮设置 -->
|
||||
<div v-if="modelValue.enable_float_buttons" class="float-buttons-config space-y-4">
|
||||
<!-- 微信搜一搜图片 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">微信搜一搜图片</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">选择微信搜一搜的二维码图片</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div v-if="modelValue.wechat_search_image" class="flex-shrink-0">
|
||||
<n-image
|
||||
:src="getImageUrl(modelValue.wechat_search_image)"
|
||||
alt="微信搜一搜"
|
||||
width="80"
|
||||
height="80"
|
||||
object-fit="cover"
|
||||
class="rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<n-button type="primary" @click="openWechatSelector">
|
||||
<template #icon>
|
||||
<i class="fas fa-image"></i>
|
||||
</template>
|
||||
{{ modelValue.wechat_search_image ? '更换图片' : '选择图片' }}
|
||||
</n-button>
|
||||
<n-button v-if="modelValue.wechat_search_image" @click="clearWechatImage" class="ml-2">
|
||||
<template #icon>
|
||||
<i class="fas fa-times"></i>
|
||||
</template>
|
||||
清除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram二维码 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label class="text-base font-semibold text-gray-800 dark:text-gray-200">Telegram二维码</label>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">选择Telegram群组的二维码图片</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div v-if="modelValue.telegram_qr_image" class="flex-shrink-0">
|
||||
<n-image
|
||||
:src="getImageUrl(modelValue.telegram_qr_image)"
|
||||
alt="Telegram二维码"
|
||||
width="80"
|
||||
height="80"
|
||||
object-fit="cover"
|
||||
class="rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<n-button type="primary" @click="openTelegramSelector">
|
||||
<template #icon>
|
||||
<i class="fas fa-image"></i>
|
||||
</template>
|
||||
{{ modelValue.telegram_qr_image ? '更换图片' : '选择图片' }}
|
||||
</n-button>
|
||||
<n-button v-if="modelValue.telegram_qr_image" @click="clearTelegramImage" class="ml-2">
|
||||
<template #icon>
|
||||
<i class="fas fa-times"></i>
|
||||
</template>
|
||||
清除
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
// 使用图片URL composable
|
||||
const { getImageUrl } = useImageUrl()
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImageSelectorModal from '~/components/Admin/ImageSelectorModal.vue'
|
||||
// 配置数据接口
|
||||
interface ConfigData {
|
||||
enable_float_buttons: boolean
|
||||
wechat_search_image: string
|
||||
telegram_qr_image: string
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
modelValue: ConfigData
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ConfigData]
|
||||
'openWechatSelector': []
|
||||
'openTelegramSelector': []
|
||||
}>()
|
||||
|
||||
// 计算属性用于双向绑定
|
||||
const enableFloatButtons = computed({
|
||||
get: () => props.modelValue.enable_float_buttons,
|
||||
set: (value: boolean) => {
|
||||
emit('update:modelValue', {
|
||||
enable_float_buttons: value,
|
||||
wechat_search_image: props.modelValue.wechat_search_image,
|
||||
telegram_qr_image: props.modelValue.telegram_qr_image
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 使用图片URL composable
|
||||
const { getImageUrl } = useImageUrl()
|
||||
|
||||
// 选择器状态
|
||||
const showWechatSelector = ref(false)
|
||||
const showTelegramSelector = ref(false)
|
||||
|
||||
// 清除微信图片
|
||||
const clearWechatImage = () => {
|
||||
emit('update:modelValue', {
|
||||
enable_float_buttons: props.modelValue.enable_float_buttons,
|
||||
wechat_search_image: '',
|
||||
telegram_qr_image: props.modelValue.telegram_qr_image
|
||||
})
|
||||
}
|
||||
|
||||
// 清除Telegram图片
|
||||
const clearTelegramImage = () => {
|
||||
emit('update:modelValue', {
|
||||
enable_float_buttons: props.modelValue.enable_float_buttons,
|
||||
wechat_search_image: props.modelValue.wechat_search_image,
|
||||
telegram_qr_image: ''
|
||||
})
|
||||
}
|
||||
|
||||
// 打开微信选择器
|
||||
const openWechatSelector = () => {
|
||||
emit('openWechatSelector')
|
||||
}
|
||||
|
||||
// 打开Telegram选择器
|
||||
const openTelegramSelector = () => {
|
||||
emit('openTelegramSelector')
|
||||
}
|
||||
|
||||
// 处理微信图片选择
|
||||
const handleWechatImageSelect = (file: any) => {
|
||||
emit('update:modelValue', {
|
||||
enable_float_buttons: props.modelValue.enable_float_buttons,
|
||||
wechat_search_image: file.access_url,
|
||||
telegram_qr_image: props.modelValue.telegram_qr_image
|
||||
})
|
||||
}
|
||||
|
||||
// 处理Telegram图片选择
|
||||
const handleTelegramImageSelect = (file: any) => {
|
||||
emit('update:modelValue', {
|
||||
enable_float_buttons: props.modelValue.enable_float_buttons,
|
||||
wechat_search_image: props.modelValue.wechat_search_image,
|
||||
telegram_qr_image: file.access_url
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
256
web/components/Admin/ImageSelectorModal.vue
Normal file
256
web/components/Admin/ImageSelectorModal.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<n-modal v-model:show="showModal" preset="card" :title="title" style="width: 90vw; max-width: 1200px; max-height: 80vh;">
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索 -->
|
||||
<div class="flex gap-4">
|
||||
<n-input
|
||||
v-model:value="searchKeyword"
|
||||
placeholder="搜索文件名..."
|
||||
@keyup.enter="handleSearch"
|
||||
class="flex-1"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<n-button type="primary" @click="handleSearch" class="w-20">
|
||||
<template #icon>
|
||||
<i class="fas fa-search"></i>
|
||||
</template>
|
||||
搜索
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="fileList.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-file-upload text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">暂无图片文件</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-grid">
|
||||
<div
|
||||
v-for="file in fileList"
|
||||
:key="file.id"
|
||||
class="file-item cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg p-3 transition-colors"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-300 dark:border-blue-600': selectedFileId === file.id }"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<div class="image-preview">
|
||||
<n-image
|
||||
:src="getImageUrl(file.access_url)"
|
||||
:alt="file.original_name"
|
||||
:lazy="false"
|
||||
object-fit="cover"
|
||||
class="preview-image rounded"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
<div class="image-info mt-2">
|
||||
<div class="file-name text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{{ file.original_name }}
|
||||
</div>
|
||||
<div class="file-size text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatFileSize(file.file_size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<n-pagination
|
||||
v-model:page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-count="Math.ceil(pagination.total / pagination.pageSize)"
|
||||
:page-sizes="pagination.pageSizes"
|
||||
show-size-picker
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="closeModal">取消</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="confirmSelection"
|
||||
:disabled="!selectedFileId"
|
||||
>
|
||||
确认选择
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
}>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'select': [file: any]
|
||||
}>()
|
||||
|
||||
// 使用图片URL composable
|
||||
const { getImageUrl } = useImageUrl()
|
||||
|
||||
// 响应式数据
|
||||
const showModal = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value)
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
const selectedFileId = ref<number | null>(null)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
// 监听show变化,重新加载数据
|
||||
watch(() => props.show, (newValue) => {
|
||||
if (newValue) {
|
||||
loadFileList()
|
||||
} else {
|
||||
// 重置状态
|
||||
selectedFileId.value = null
|
||||
searchKeyword.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 加载文件列表
|
||||
const loadFileList = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { useFileApi } = await import('~/composables/useFileApi')
|
||||
const fileApi = useFileApi()
|
||||
|
||||
const response = await fileApi.getFileList({
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize,
|
||||
search: searchKeyword.value,
|
||||
fileType: 'image',
|
||||
status: 'active'
|
||||
}) as any
|
||||
|
||||
if (response && response.data) {
|
||||
fileList.value = response.data.files || []
|
||||
pagination.value.total = response.data.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取文件列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.value.page = page
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (pageSize: number) => {
|
||||
pagination.value.pageSize = pageSize
|
||||
pagination.value.page = 1
|
||||
loadFileList()
|
||||
}
|
||||
|
||||
// 文件选择
|
||||
const selectFile = (file: any) => {
|
||||
selectedFileId.value = file.id
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const confirmSelection = () => {
|
||||
if (selectedFileId.value) {
|
||||
const file = fileList.value.find(f => f.id === selectedFileId.value)
|
||||
if (file) {
|
||||
emit('select', file)
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
// 文件大小格式化
|
||||
const formatFileSize = (size: number) => {
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
// 图片加载处理
|
||||
const handleImageError = (event: any) => {
|
||||
console.error('图片加载失败:', event)
|
||||
}
|
||||
|
||||
const handleImageLoad = (event: any) => {
|
||||
console.log('图片加载成功:', event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,112 +1,110 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6 h-full">
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<n-card title="批量转存资源列表">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 左侧:资源输入 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
资源内容 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="resourceText"
|
||||
type="textarea"
|
||||
placeholder="请输入资源内容,格式:标题和URL为一组..."
|
||||
:autosize="{ minRows: 10, maxRows: 15 }"
|
||||
show-count
|
||||
:maxlength="100000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:配置选项 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
默认分类
|
||||
</label>
|
||||
<CategorySelector
|
||||
v-model="selectedCategory"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
标签
|
||||
</label>
|
||||
<TagSelector
|
||||
v-model="selectedTags"
|
||||
placeholder="选择标签"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
网盘账号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-select
|
||||
v-model:value="selectedAccounts"
|
||||
:options="accountOptions"
|
||||
placeholder="选择网盘账号"
|
||||
multiple
|
||||
filterable
|
||||
:loading="accountsLoading"
|
||||
@update:value="handleAccountChange"
|
||||
>
|
||||
<template #option="{ option: accountOption }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm">{{ accountOption.label }}</span>
|
||||
<n-tag v-if="accountOption.is_valid" type="success" size="small">有效</n-tag>
|
||||
<n-tag v-else type="error" size="small">无效</n-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatSpace(accountOption.left_space) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-select>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
请选择要使用的网盘账号,系统将使用选中的账号进行转存操作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="space-y-3 pt-4">
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="processing"
|
||||
:disabled="!resourceText.trim() || !selectedAccounts.length || processing"
|
||||
@click="handleBatchTransfer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
开始批量转存
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
block
|
||||
@click="clearInput"
|
||||
:disabled="processing"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-trash"></i>
|
||||
</template>
|
||||
清空内容
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 左侧:资源输入 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
资源内容 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-input
|
||||
v-model:value="resourceText"
|
||||
type="textarea"
|
||||
placeholder="请输入资源内容,格式:标题和URL为一组..."
|
||||
:autosize="{ minRows: 10, maxRows: 15 }"
|
||||
show-count
|
||||
:maxlength="100000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 右侧:配置选项 -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
默认分类
|
||||
</label>
|
||||
<CategorySelector
|
||||
v-model="selectedCategory"
|
||||
placeholder="选择分类"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
标签
|
||||
</label>
|
||||
<TagSelector
|
||||
v-model="selectedTags"
|
||||
placeholder="选择标签"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
网盘账号 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<n-select
|
||||
v-model:value="selectedAccounts"
|
||||
:options="accountOptions"
|
||||
placeholder="选择网盘账号"
|
||||
multiple
|
||||
filterable
|
||||
:loading="accountsLoading"
|
||||
@update:value="handleAccountChange"
|
||||
>
|
||||
<template #option="scope">
|
||||
<div class="flex items-center justify-between w-full" v-if="scope && scope.option">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm">{{ scope.option.label || '未知账号' }}</span>
|
||||
<n-tag v-if="scope.option.is_valid" type="success" size="small">有效</n-tag>
|
||||
<n-tag v-else type="error" size="small">无效</n-tag>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatSpace(scope.option.left_space || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-select>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
请选择要使用的网盘账号,系统将使用选中的账号进行转存操作
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="space-y-3 pt-4">
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="processing"
|
||||
:disabled="!resourceText.trim() || !selectedAccounts.length || processing"
|
||||
@click="handleBatchTransfer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-upload"></i>
|
||||
</template>
|
||||
开始批量转存
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
block
|
||||
@click="clearInput"
|
||||
:disabled="processing"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-trash"></i>
|
||||
</template>
|
||||
清空内容
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 处理结果 -->
|
||||
<n-card v-if="results.length > 0" title="转存结果">
|
||||
@@ -202,15 +200,15 @@ const invalidUrls = computed(() => {
|
||||
})
|
||||
|
||||
const successCount = computed(() => {
|
||||
return results.value.filter((r: any) => r.status === 'success').length
|
||||
return results.value ? results.value.filter((r: any) => r && r.status === 'success').length : 0
|
||||
})
|
||||
|
||||
const failedCount = computed(() => {
|
||||
return results.value.filter((r: any) => r.status === 'failed').length
|
||||
return results.value ? results.value.filter((r: any) => r && r.status === 'failed').length : 0
|
||||
})
|
||||
|
||||
const processingCount = computed(() => {
|
||||
return results.value.filter((r: any) => r.status === 'processing').length
|
||||
return results.value ? results.value.filter((r: any) => r && r.status === 'processing').length : 0
|
||||
})
|
||||
|
||||
// 结果表格列
|
||||
@@ -243,9 +241,10 @@ const resultColumns = [
|
||||
pending: { color: 'warning', text: '等待中', icon: 'fas fa-clock' }
|
||||
}
|
||||
const status = statusMap[row.status as keyof typeof statusMap] || statusMap.failed
|
||||
return h('n-tag', { type: status.color }, {
|
||||
icon: () => h('i', { class: status.icon }),
|
||||
default: () => status.text
|
||||
const safeStatus = status || statusMap.failed
|
||||
return h('n-tag', { type: safeStatus.color }, {
|
||||
icon: () => h('i', { class: safeStatus.icon }),
|
||||
default: () => safeStatus.text
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -264,7 +263,7 @@ const resultColumns = [
|
||||
tooltip: true
|
||||
},
|
||||
render: (row: any) => {
|
||||
if (row.saveUrl) {
|
||||
if (row && row.saveUrl) {
|
||||
return h('a', {
|
||||
href: row.saveUrl,
|
||||
target: '_blank',
|
||||
@@ -281,7 +280,7 @@ const isValidUrl = (url: string) => {
|
||||
try {
|
||||
new URL(url)
|
||||
// 简单检查是否包含常见网盘域名
|
||||
const diskDomains = ['quark.cn', 'pan.baidu.com', 'aliyundrive.com']
|
||||
const diskDomains = ['quark.cn', 'pan.baidu.com', 'aliyundrive.com', 'pan.xunlei.com']
|
||||
return diskDomains.some(domain => url.includes(domain))
|
||||
} catch {
|
||||
return false
|
||||
@@ -354,7 +353,11 @@ const handleBatchTransfer = async () => {
|
||||
// 第三步:创建任务
|
||||
const taskResponse = await taskApi.createBatchTransferTask(taskData) as any
|
||||
console.log('任务创建响应:', taskResponse)
|
||||
|
||||
|
||||
if (!taskResponse || !taskResponse.task_id) {
|
||||
throw new Error('创建任务失败:响应数据无效')
|
||||
}
|
||||
|
||||
currentTaskId.value = taskResponse.task_id
|
||||
|
||||
// 第四步:启动任务
|
||||
@@ -369,6 +372,8 @@ const handleBatchTransfer = async () => {
|
||||
console.error('创建任务失败:', error)
|
||||
message.error('创建任务失败: ' + (error.message || '未知错误'))
|
||||
processing.value = false
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,14 +526,17 @@ const getAccountOptions = async () => {
|
||||
const response = await cksApi.getCks() as any
|
||||
const accounts = Array.isArray(response) ? response : []
|
||||
|
||||
accountOptions.value = accounts.map((account: any) => ({
|
||||
label: `${account.username || '未知用户'} (${account.pan?.name || '未知平台'})`,
|
||||
value: account.id,
|
||||
is_valid: account.is_valid,
|
||||
left_space: account.left_space,
|
||||
username: account.username,
|
||||
pan_name: account.pan?.name || '未知平台'
|
||||
}))
|
||||
accountOptions.value = accounts.map((account: any) => {
|
||||
if (!account) return null
|
||||
return {
|
||||
label: `${account.username || '未知用户'} (${account.pan?.name || '未知平台'})`,
|
||||
value: account.id,
|
||||
is_valid: account.is_valid || false,
|
||||
left_space: account.left_space || 0,
|
||||
username: account.username || '未知用户',
|
||||
pan_name: account.pan?.name || '未知平台'
|
||||
}
|
||||
}).filter(option => option !== null) as any[]
|
||||
} catch (error) {
|
||||
console.error('获取网盘账号选项失败:', error)
|
||||
message.error('获取网盘账号失败')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2 h-full">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="flex-0 grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索已转存资源..."
|
||||
@@ -34,28 +34,111 @@
|
||||
</div>
|
||||
|
||||
<!-- 调试信息 -->
|
||||
<div class="text-sm text-gray-500 mb-2">
|
||||
<div class="flex-0 text-sm text-gray-500">
|
||||
数据数量: {{ resources.length }}, 总数: {{ total }}, 加载状态: {{ loading }}
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="resources"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:remote="true"
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
:row-key="(row: any) => row.id"
|
||||
virtual-scroll
|
||||
max-height="500"
|
||||
/>
|
||||
<!-- 资源列表 -->
|
||||
<div class="flex-1 h-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="resources.length === 0" class="text-center py-8">
|
||||
<i class="fas fa-inbox text-4xl text-gray-400 mb-4"></i>
|
||||
<p class="text-gray-500">暂无已转存的资源</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="h-full">
|
||||
<!-- 虚拟列表 -->
|
||||
<n-virtual-list
|
||||
:items="resources"
|
||||
:item-size="120"
|
||||
class="h-full"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 资源信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<!-- ID -->
|
||||
<span class="text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 px-2 py-1 rounded">
|
||||
ID: {{ item.id }}
|
||||
</span>
|
||||
|
||||
<!-- 标题 -->
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white line-clamp-1 flex-1">
|
||||
{{ item.title || '未命名资源' }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm mt-3">
|
||||
<!-- 分类 -->
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-folder mr-1 text-gray-400"></i>
|
||||
<span class="text-gray-600 dark:text-gray-400">分类:</span>
|
||||
<span class="ml-2">{{ item.category_name || '未分类' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 转存时间 -->
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-calendar mr-1 text-gray-400"></i>
|
||||
<span class="text-gray-600 dark:text-gray-400">转存时间:</span>
|
||||
<span class="ml-2">{{ formatDate(item.updated_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 浏览数 -->
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-eye mr-1 text-gray-400"></i>
|
||||
<span class="text-gray-600 dark:text-gray-400">浏览数:</span>
|
||||
<span class="ml-2">{{ item.view_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 转存链接 -->
|
||||
<div class="mt-3">
|
||||
<div class="flex items-start space-x-2">
|
||||
<span class="text-xs text-gray-400">转存链接:</span>
|
||||
<a
|
||||
v-if="item.save_url"
|
||||
:href="item.save_url"
|
||||
target="_blank"
|
||||
class="text-xs text-green-500 hover:text-green-700 break-all"
|
||||
>
|
||||
{{ item.save_url.length > 60 ? item.save_url.substring(0, 60) + '...' : item.save_url }}
|
||||
</a>
|
||||
<span v-else class="text-xs text-gray-500">暂无转存链接</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex-0">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:item-count="total"
|
||||
:page-sizes="[10000, 20000, 50000, 100000]"
|
||||
show-size-picker
|
||||
show-quick-jumper
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useResourceApi, usePanApi } from '~/composables/useApi'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
@@ -81,78 +164,17 @@ const panApi = usePanApi()
|
||||
// 获取平台数据
|
||||
const { data: platformsData } = await useAsyncData('transferredPlatforms', () => panApi.getPans())
|
||||
|
||||
// 平台选项
|
||||
const platformOptions = computed(() => {
|
||||
const data = platformsData.value as any
|
||||
const platforms = data?.data || data || []
|
||||
return platforms.map((platform: any) => ({
|
||||
label: platform.remark || platform.name,
|
||||
value: platform.id
|
||||
}))
|
||||
})
|
||||
|
||||
// 获取平台名称
|
||||
const getPlatformName = (platformId: number) => {
|
||||
const platform = (platformsData.value as any)?.data?.find((plat: any) => plat.id === platformId)
|
||||
return platform?.remark || platform?.name || '未知平台'
|
||||
}
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10000,
|
||||
itemCount: 0,
|
||||
pageSizes: [10000, 20000, 50000, 100000],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
prefix: ({ itemCount }: any) => `共 ${itemCount} 条`
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const columns: any[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
fixed: 'left' as const
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
key: 'title',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
key: 'category_name',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '转存链接',
|
||||
key: 'save_url',
|
||||
width: 200,
|
||||
ellipsis: {
|
||||
tooltip: true
|
||||
},
|
||||
render: (row: any) => {
|
||||
return h('a', {
|
||||
href: row.save_url,
|
||||
target: '_blank',
|
||||
class: 'text-green-500 hover:text-green-700'
|
||||
}, row.save_url.length > 30 ? row.save_url.substring(0, 30) + '...' : row.save_url)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '转存时间',
|
||||
key: 'updated_at',
|
||||
width: 130,
|
||||
render: (row: any) => {
|
||||
return new Date(row.updated_at).toLocaleDateString()
|
||||
}
|
||||
}
|
||||
]
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '未知时间'
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
||||
// 获取已转存资源
|
||||
const fetchTransferredResources = async () => {
|
||||
@@ -183,24 +205,20 @@ const fetchTransferredResources = async () => {
|
||||
console.log('使用嵌套data格式,数量:', result.data.data.length)
|
||||
resources.value = result.data.data
|
||||
total.value = result.data.total || 0
|
||||
pagination.itemCount = result.data.total || 0
|
||||
} else {
|
||||
// 处理直接的data结构:{data: [...], total: ...}
|
||||
console.log('使用直接data格式,数量:', result.data.length)
|
||||
resources.value = result.data
|
||||
total.value = result.total || 0
|
||||
pagination.itemCount = result.total || 0
|
||||
}
|
||||
} else if (Array.isArray(result)) {
|
||||
console.log('使用数组格式,数量:', result.length)
|
||||
resources.value = result
|
||||
total.value = result.length
|
||||
pagination.itemCount = result.length
|
||||
} else {
|
||||
console.log('未知格式,设置空数组')
|
||||
resources.value = []
|
||||
total.value = 0
|
||||
pagination.itemCount = 0
|
||||
}
|
||||
|
||||
console.log('最终 resources.value:', resources.value)
|
||||
@@ -223,22 +241,18 @@ const fetchTransferredResources = async () => {
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
pagination.page = 1
|
||||
fetchTransferredResources()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
pagination.page = page
|
||||
fetchTransferredResources()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
pagination.pageSize = size
|
||||
currentPage.value = 1
|
||||
pagination.page = 1
|
||||
fetchTransferredResources()
|
||||
}
|
||||
|
||||
@@ -246,4 +260,13 @@ const handlePageSizeChange = (size: number) => {
|
||||
onMounted(() => {
|
||||
fetchTransferredResources()
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="h-full flex flex-col gap-2">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="flex-0 grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<n-input
|
||||
v-model:value="searchQuery"
|
||||
placeholder="搜索未转存资源..."
|
||||
@@ -41,47 +41,45 @@
|
||||
</div>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<n-card>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<n-checkbox
|
||||
:checked="isAllSelected"
|
||||
@update:checked="toggleSelectAll"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">全选</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">
|
||||
共 {{ total }} 个资源,已选择 {{ selectedResources.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="selectedResources.length === 0"
|
||||
:loading="batchTransferring"
|
||||
@click="handleBatchTransfer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</template>
|
||||
批量转存 ({{ selectedResources.length }})
|
||||
</n-button>
|
||||
|
||||
<n-button @click="refreshData">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
<div class="flex-0 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<n-checkbox
|
||||
:checked="isAllSelected"
|
||||
@update:checked="toggleSelectAll"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">全选</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">
|
||||
共 {{ total }} 个资源,已选择 {{ selectedResources.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="selectedResources.length === 0"
|
||||
:loading="batchTransferring"
|
||||
@click="handleBatchTransfer"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</template>
|
||||
批量转存 ({{ selectedResources.length }})
|
||||
</n-button>
|
||||
|
||||
<n-button @click="refreshData">
|
||||
<template #icon>
|
||||
<i class="fas fa-refresh"></i>
|
||||
</template>
|
||||
刷新
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 资源列表 -->
|
||||
<n-card>
|
||||
<div class="flex-1 h-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
@@ -91,13 +89,12 @@
|
||||
<p class="text-gray-500">暂无未转存的夸克资源</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-else class="h-full">
|
||||
<!-- 虚拟列表 -->
|
||||
<n-virtual-list
|
||||
:items="resources"
|
||||
:item-size="120"
|
||||
style="max-height: 500px"
|
||||
container-style="height: 500px;"
|
||||
class="h-full"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
@@ -167,22 +164,23 @@
|
||||
</div>
|
||||
</template>
|
||||
</n-virtual-list>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-4 flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:item-count="total"
|
||||
:page-sizes="[10000, 20000, 50000, 100000]"
|
||||
show-size-picker
|
||||
show-quick-jumper
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
</div>
|
||||
<div class="flex-0">
|
||||
<div class="flex justify-center">
|
||||
<n-pagination
|
||||
v-model:page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:item-count="total"
|
||||
:page-sizes="[10000, 20000, 50000, 100000]"
|
||||
show-size-picker
|
||||
show-quick-jumper
|
||||
@update:page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网盘账号选择模态框 -->
|
||||
<n-modal v-model:show="showAccountSelectionModal" preset="card" title="选择网盘账号" style="width: 600px">
|
||||
|
||||
80
web/components/AdminPageLayout.vue
Normal file
80
web/components/AdminPageLayout.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col gap-3">
|
||||
<!-- 顶部标题和按钮区域 -->
|
||||
<div class="flex-0 w-full flex">
|
||||
<div v-if="isSubPage" class="flex-0 mr-4 flex items-center">
|
||||
<n-button @click="goBack" type="text" size="small">
|
||||
<template #icon>
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
<!-- 页面头部内容 -->
|
||||
<div class="flex-1 w-1 flex items-center justify-between">
|
||||
<slot name="page-header"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知提示区域 -->
|
||||
<div v-if="hasNoticeSection" class="flex-shrink-0">
|
||||
<slot name="notice-section"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 过滤栏区域 -->
|
||||
<div v-if="hasFilterBar" class="flex-shrink-0">
|
||||
<slot name="filter-bar"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 - 自适应剩余高度 -->
|
||||
<div class="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<!-- 内容区header -->
|
||||
<div v-if="hasContentHeader" class="px-6 py-4 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700 whitespace-nowrap">
|
||||
<slot name="content-header"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 内容区content - 自适应剩余高度 -->
|
||||
<div class="flex-1 h-1 content-slot overflow-hidden">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 内容区footer -->
|
||||
<div v-if="hasContentFooter" class="flex-shrink-0 bg-gray-50 dark:bg-gray-700 border-t border-gray-200 dark:border-gray-700">
|
||||
<slot name="content-footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const router = useRouter()
|
||||
const $slots = useSlots()
|
||||
const hasNoticeSection = computed(() => $slots['notice-section'] !== undefined)
|
||||
const hasFilterBar = computed(() => $slots['filter-bar'] !== undefined)
|
||||
const hasContentHeader = computed(() => $slots['content-header'] !== undefined)
|
||||
const hasContentFooter = computed(() => $slots['content-footer'] !== undefined)
|
||||
|
||||
const goBack = () => {
|
||||
try {
|
||||
router.back()
|
||||
} catch (error) {
|
||||
navigateTo('/admin')
|
||||
}
|
||||
}
|
||||
|
||||
defineProps({
|
||||
minHeight: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
isSubPage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.content-slot) {
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
118
web/components/Announcement.vue
Normal file
118
web/components/Announcement.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div v-if="shouldShowAnnouncement" class="announcement-container px-3 py-1">
|
||||
<div class="flex items-center justify-between min-h-[24px]">
|
||||
<div class="flex items-center gap-2 flex-1 overflow-hidden">
|
||||
<i class="fas fa-bullhorn text-blue-600 dark:text-blue-400 text-sm flex-shrink-0"></i>
|
||||
<div class="announcement-content overflow-hidden">
|
||||
<div class="announcement-item active">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300 truncate" v-html="validAnnouncements[currentIndex].content"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 flex-shrink-0 ml-2">
|
||||
<span>{{ (currentIndex + 1) }}/{{ validAnnouncements.length }}</span>
|
||||
<button @click="nextAnnouncement" class="hover:text-blue-500 transition-colors">
|
||||
<i class="fas fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 使用系统配置store获取公告数据
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(false, false)
|
||||
const systemConfig = computed(() => systemConfigStore.config)
|
||||
|
||||
interface AnnouncementItem {
|
||||
content: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const currentIndex = ref(0)
|
||||
const interval = ref<NodeJS.Timeout | null>(null)
|
||||
|
||||
// 计算有效公告(开启状态且有内容的公告)
|
||||
const validAnnouncements = computed(() => {
|
||||
if (!systemConfig.value?.announcements) return []
|
||||
|
||||
const announcements = Array.isArray(systemConfig.value.announcements)
|
||||
? systemConfig.value.announcements
|
||||
: JSON.parse(systemConfig.value.announcements || '[]')
|
||||
|
||||
return announcements.filter((item: AnnouncementItem) =>
|
||||
item.enabled && item.content && item.content.trim()
|
||||
)
|
||||
})
|
||||
|
||||
// 判断是否应该显示公告
|
||||
const shouldShowAnnouncement = computed(() => {
|
||||
return systemConfig.value?.enable_announcements && validAnnouncements.value.length > 0
|
||||
})
|
||||
|
||||
// 自动切换公告
|
||||
const startAutoSwitch = () => {
|
||||
if (validAnnouncements.value.length > 1) {
|
||||
interval.value = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % validAnnouncements.value.length
|
||||
}, 4000) // 每4秒切换一次
|
||||
}
|
||||
}
|
||||
|
||||
// 手动切换到下一条公告
|
||||
const nextAnnouncement = () => {
|
||||
currentIndex.value = (currentIndex.value + 1) % validAnnouncements.value.length
|
||||
}
|
||||
|
||||
// 监听公告数据变化,重新开始自动切换
|
||||
watch(() => validAnnouncements.value.length, (newLength) => {
|
||||
if (newLength > 0) {
|
||||
currentIndex.value = 0
|
||||
stopAutoSwitch()
|
||||
startAutoSwitch()
|
||||
}
|
||||
})
|
||||
|
||||
// 清理定时器
|
||||
const stopAutoSwitch = () => {
|
||||
if (interval.value) {
|
||||
clearInterval(interval.value)
|
||||
interval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (shouldShowAnnouncement.value) {
|
||||
startAutoSwitch()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoSwitch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-content {
|
||||
position: relative;
|
||||
height: 20px; /* 固定高度 */
|
||||
}
|
||||
|
||||
.announcement-item {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
transition: all 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.announcement-item.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
.dark-theme .announcement-container {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -21,15 +21,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useApiFetch } from '~/composables/useApiFetch'
|
||||
import { parseApiResponse } from '~/composables/useApi'
|
||||
|
||||
// 使用版本信息组合式函数
|
||||
const { versionInfo, fetchVersionInfo } = useVersion()
|
||||
|
||||
// 获取系统配置
|
||||
const { data: systemConfigData } = await useAsyncData('footerSystemConfig',
|
||||
() => useApiFetch('/system/config').then(parseApiResponse)
|
||||
)
|
||||
|
||||
const systemConfig = computed(() => (systemConfigData.value as any) || { copyright: '© 2025 老九网盘资源数据库 By 老九' })
|
||||
import { useSystemConfigStore } from '~/stores/systemConfig'
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
await systemConfigStore.initConfig(false, false)
|
||||
const systemConfig = computed(() => systemConfigStore.config)
|
||||
// console.log(systemConfig.value)
|
||||
|
||||
// 组件挂载时获取版本信息
|
||||
onMounted(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user